Skip to content

Commit d089144

Browse files
fix: migrate PluginUpdate→HomebridgeUpdater via direct config.json read to fix scoped plugin upgrade clearing config
Agent-Logs-Url: https://github.com/homebridge-plugins/homebridge-updater/sessions/004aa914-1c4d-4972-92fa-b50c628f35dd Co-authored-by: donavanbecker <9875439+donavanbecker@users.noreply.github.com>
1 parent f37a447 commit d089144

3 files changed

Lines changed: 155 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
### Bug Fixes
55

66
* persist failureSensorType to JSON config by marking it required in schema ([#245](https://github.com/homebridge-plugins/homebridge-updater/issues/245)) ([accf14b](https://github.com/homebridge-plugins/homebridge-updater/commit/accf14b8343609b4534bdf9224d9866505dd056f))
7+
* fix scoped plugin upgrade clearing config: migrate `PluginUpdate``HomebridgeUpdater` by reading `config.json` directly instead of relying on the Homebridge UI API, which cannot return legacy platform entries for the new plugin name ([#246](https://github.com/homebridge-plugins/homebridge-updater/issues/246))
78

89

910

src/configMigration.test.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { describe, expect, it, vi, beforeEach } from 'vitest'
22

3-
import { migratePlatformAliasInPluginConfigs } from './configMigration.js'
3+
// Mock node:fs before any imports that use it — vitest hoists vi.mock automatically.
4+
vi.mock('node:fs', () => ({
5+
readFileSync: vi.fn(),
6+
writeFileSync: vi.fn(),
7+
}))
8+
9+
import * as fs from 'node:fs'
10+
import { migratePlatformAliasInPluginConfigs, migrateLegacyPlatformAlias } from './configMigration.js'
411

512
describe('config migration', () => {
613
it('should migrate legacy platform alias in plugin config entries', () => {
@@ -26,4 +33,117 @@ describe('config migration', () => {
2633
expect(updated).toBe(0)
2734
expect(pluginConfigs[0].platform).toBe('HomebridgeUpdater')
2835
})
36+
37+
it('should return 0 for a non-array input', () => {
38+
expect(migratePlatformAliasInPluginConfigs(null as any)).toBe(0)
39+
expect(migratePlatformAliasInPluginConfigs(undefined as any)).toBe(0)
40+
})
41+
})
42+
43+
describe('migrateLegacyPlatformAlias (direct file migration)', () => {
44+
const FAKE_STORAGE = '/fake/homebridge'
45+
const FAKE_CONFIG_PATH = `${FAKE_STORAGE}/config.json`
46+
47+
const readFileSyncMock = vi.mocked(fs.readFileSync)
48+
const writeFileSyncMock = vi.mocked(fs.writeFileSync)
49+
50+
function makeApi(storagePath: string | undefined) {
51+
return {
52+
user: { storagePath: () => storagePath },
53+
} as any
54+
}
55+
56+
beforeEach(() => {
57+
vi.clearAllMocks()
58+
})
59+
60+
it('should do nothing when storagePath is missing', async () => {
61+
await migrateLegacyPlatformAlias(makeApi(undefined))
62+
63+
expect(readFileSyncMock).not.toHaveBeenCalled()
64+
expect(writeFileSyncMock).not.toHaveBeenCalled()
65+
})
66+
67+
it('should migrate PluginUpdate → HomebridgeUpdater entries directly in config.json', async () => {
68+
const originalConfig = {
69+
bridge: { name: 'Homebridge', username: 'AA:BB:CC:DD:EE:FF', port: 51826 },
70+
platforms: [
71+
{ platform: 'PluginUpdate', name: 'Plugin Update', checkPluginUpdates: true },
72+
{ platform: 'OtherPlugin', name: 'Other' },
73+
],
74+
}
75+
76+
readFileSyncMock.mockReturnValue(JSON.stringify(originalConfig) as any)
77+
writeFileSyncMock.mockImplementation(() => {})
78+
79+
await migrateLegacyPlatformAlias(makeApi(FAKE_STORAGE))
80+
81+
expect(writeFileSyncMock).toHaveBeenCalledTimes(1)
82+
const [writtenPath, writtenData] = writeFileSyncMock.mock.calls[0] as [string, string, string]
83+
expect(writtenPath).toBe(FAKE_CONFIG_PATH)
84+
85+
const written = JSON.parse(writtenData)
86+
expect(written.platforms[0].platform).toBe('HomebridgeUpdater')
87+
expect(written.platforms[0].name).toBe('Plugin Update')
88+
expect(written.platforms[0].checkPluginUpdates).toBe(true)
89+
// Unrelated platform entries must remain untouched
90+
expect(written.platforms[1].platform).toBe('OtherPlugin')
91+
})
92+
93+
it('should not write config.json when no legacy entries exist', async () => {
94+
const currentConfig = {
95+
bridge: { name: 'Homebridge', username: 'AA:BB:CC:DD:EE:FF', port: 51826 },
96+
platforms: [
97+
{ platform: 'HomebridgeUpdater', name: 'Updater' },
98+
],
99+
}
100+
101+
// readFileSync is called at least once (for config.json). Any extra calls (e.g. from
102+
// the UiApi fallback path) should also not find legacy entries.
103+
readFileSyncMock.mockReturnValue(JSON.stringify(currentConfig) as any)
104+
writeFileSyncMock.mockImplementation(() => {})
105+
106+
await migrateLegacyPlatformAlias(makeApi(FAKE_STORAGE))
107+
108+
expect(writeFileSyncMock).not.toHaveBeenCalled()
109+
})
110+
111+
it('should not throw when config.json cannot be read', async () => {
112+
readFileSyncMock.mockImplementation(() => {
113+
throw new Error('ENOENT: no such file')
114+
})
115+
writeFileSyncMock.mockImplementation(() => {})
116+
117+
await expect(migrateLegacyPlatformAlias(makeApi(FAKE_STORAGE))).resolves.toBeUndefined()
118+
expect(writeFileSyncMock).not.toHaveBeenCalled()
119+
})
120+
121+
it('should preserve all config fields (_bridge, etc.) when migrating', async () => {
122+
const configWithBridge = {
123+
bridge: { name: 'Homebridge', username: 'AA:BB:CC:DD:EE:FF', port: 51826 },
124+
platforms: [
125+
{
126+
platform: 'PluginUpdate',
127+
name: 'Plugin Update',
128+
_bridge: { username: '11:22:33:44:55:66', port: 51828, name: 'Plugin Update Child Bridge' },
129+
checkPluginUpdates: true,
130+
autoUpdatePlugins: false,
131+
},
132+
],
133+
}
134+
135+
readFileSyncMock.mockReturnValue(JSON.stringify(configWithBridge) as any)
136+
writeFileSyncMock.mockImplementation(() => {})
137+
138+
await migrateLegacyPlatformAlias(makeApi(FAKE_STORAGE))
139+
140+
expect(writeFileSyncMock).toHaveBeenCalledTimes(1)
141+
const written = JSON.parse((writeFileSyncMock.mock.calls[0] as [string, string, string])[1])
142+
const migratedPlatform = written.platforms[0]
143+
expect(migratedPlatform.platform).toBe('HomebridgeUpdater')
144+
// Child bridge config must be preserved intact
145+
expect(migratedPlatform._bridge).toEqual(configWithBridge.platforms[0]._bridge)
146+
expect(migratedPlatform.checkPluginUpdates).toBe(true)
147+
expect(migratedPlatform.autoUpdatePlugins).toBe(false)
148+
})
29149
})

src/configMigration.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { API } from 'homebridge'
1+
import type { API, HomebridgeConfig } from 'homebridge'
2+
3+
import { readFileSync, writeFileSync } from 'node:fs'
4+
import path from 'node:path'
25

36
import { PLUGIN_NAME, PLATFORM_NAME } from './settings.js'
47
import { UiApi } from './ui-api.js'
@@ -35,6 +38,32 @@ export async function migrateLegacyPlatformAlias(api: API): Promise<void> {
3538
}
3639

3740
try {
41+
// Primary approach: read config.json directly so that legacy 'PluginUpdate' entries
42+
// are found and migrated regardless of which npm package name they were last saved
43+
// under. This is required when upgrading from the old unscoped package
44+
// (homebridge-plugin-update-check) or from a v2.x install where the platform alias
45+
// was still 'PluginUpdate', because the Homebridge UI API only returns entries for
46+
// the current plugin alias ('HomebridgeUpdater') and will return an empty array
47+
// before the first migration completes.
48+
const configPath = path.resolve(hbStoragePath, 'config.json')
49+
let hbConfig: HomebridgeConfig | undefined
50+
try {
51+
hbConfig = JSON.parse(readFileSync(configPath, 'utf8')) as HomebridgeConfig
52+
}
53+
catch {
54+
hbConfig = undefined
55+
}
56+
57+
if (hbConfig?.platforms && Array.isArray(hbConfig.platforms)) {
58+
const updatedDirect = migratePlatformAliasInPluginConfigs(hbConfig.platforms as Array<Record<string, unknown>>)
59+
if (updatedDirect > 0) {
60+
writeFileSync(configPath, JSON.stringify(hbConfig, null, 4), 'utf8')
61+
return
62+
}
63+
}
64+
65+
// Fallback: use the UI API for cases where the direct file approach found nothing
66+
// but the UI API may have additional context (e.g. partial earlier migration).
3867
const uiApi = new UiApi(hbStoragePath, silentLog as any)
3968
if (!uiApi.isConfigured()) {
4069
return
@@ -48,7 +77,8 @@ export async function migrateLegacyPlatformAlias(api: API): Promise<void> {
4877

4978
await uiApi.updatePluginConfig(PLUGIN_NAME, pluginConfigs)
5079
await uiApi.savePluginConfig(PLUGIN_NAME)
51-
} catch {
80+
}
81+
catch {
5282
// Ignore migration errors so plugin startup is never blocked.
5383
}
5484
}

0 commit comments

Comments
 (0)