@switchbot/homebridge-switchbot 5.2.2-beta.0 → 5.2.2

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.
Files changed (45) hide show
  1. package/.github/copilot-instructions.md +4 -0
  2. package/dist/SwitchBotHAPPlatform.d.ts.map +1 -1
  3. package/dist/SwitchBotHAPPlatform.js +8 -6
  4. package/dist/SwitchBotHAPPlatform.js.map +1 -1
  5. package/dist/cacheRepair.d.ts +19 -0
  6. package/dist/cacheRepair.d.ts.map +1 -0
  7. package/dist/cacheRepair.js +68 -0
  8. package/dist/cacheRepair.js.map +1 -0
  9. package/dist/deviceCommandMapper.d.ts +19 -5
  10. package/dist/deviceCommandMapper.d.ts.map +1 -1
  11. package/dist/deviceCommandMapper.js +132 -312
  12. package/dist/deviceCommandMapper.js.map +1 -1
  13. package/dist/devices/genericDevice.d.ts.map +1 -1
  14. package/dist/devices/genericDevice.js +8 -2
  15. package/dist/devices/genericDevice.js.map +1 -1
  16. package/dist/homebridge-ui/deviceCommandMapper.js +132 -312
  17. package/dist/homebridge-ui/deviceCommandMapper.js.map +1 -1
  18. package/dist/homebridge-ui/switchbotClient.js +30 -9
  19. package/dist/homebridge-ui/switchbotClient.js.map +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/switchbotClient.d.ts +1 -0
  24. package/dist/switchbotClient.d.ts.map +1 -1
  25. package/dist/switchbotClient.js +30 -9
  26. package/dist/switchbotClient.js.map +1 -1
  27. package/dist/utils.d.ts.map +1 -1
  28. package/dist/utils.js +21 -1
  29. package/dist/utils.js.map +1 -1
  30. package/docs/variables/default.html +1 -1
  31. package/package.json +2 -2
  32. package/src/SwitchBotHAPPlatform.ts +10 -6
  33. package/src/cacheRepair.ts +79 -0
  34. package/src/deviceCommandMapper.ts +142 -317
  35. package/src/devices/genericDevice.ts +8 -2
  36. package/src/index.ts +3 -0
  37. package/src/switchbotClient.ts +30 -9
  38. package/src/utils.ts +21 -1
  39. package/test/client/node-switchbot-commands.spec.ts +85 -0
  40. package/test/client/switchbot-client-debounce.spec.ts +17 -0
  41. package/test/client/switchbot-client-discovery.spec.ts +40 -0
  42. package/test/device/genericDevice-blepoll.spec.ts +42 -0
  43. package/test/platform/hap-cache-restart.spec.ts +112 -0
  44. package/test/utils/utils.spec.ts +26 -0
  45. package/.github/npm-version-script-esm.js +0 -100
@@ -0,0 +1,85 @@
1
+ import { SwitchBot } from 'node-switchbot'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { hsToRgb, toKelvin, toRgb } from '../../src/deviceCommandMapper'
5
+ import { SwitchBotClient } from '../../src/switchbotClient'
6
+
7
+ const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }
8
+
9
+ // Drive real node-switchbot devices (OpenAPI only) through the plugin client and record what
10
+ // reaches the OpenAPI. Commands used to be looked up by device type string, and most real types
11
+ // ("Plug Mini (US)", "Strip Light", "Lock Pro", ...) had no entry, so they failed as unsupported.
12
+ async function send(deviceType: string, ...bodies: any[]) {
13
+ const switchbot = new SwitchBot({ enableBLE: false, token: 'token', secret: 'secret', enableRetry: false })
14
+ const api = switchbot.getAPIClient()!
15
+ vi.spyOn(api, 'getDevices').mockResolvedValue({
16
+ deviceList: [{ deviceId: 'DEV', deviceName: 'Test', deviceType, enableCloudService: true, hubDeviceId: 'HUB' }],
17
+ infraredRemoteList: [],
18
+ } as any)
19
+ const sendCommand = vi.spyOn(api, 'sendCommand').mockResolvedValue({ statusCode: 100, message: 'success', body: {} } as any)
20
+ const client = new SwitchBotClient({ logger, writeDebounceMs: 0 } as any)
21
+ ;(client as any).client = switchbot
22
+ for (const body of bodies) {
23
+ await client.setDeviceState('DEV', body)
24
+ }
25
+ return sendCommand.mock.calls.map(([, command, parameter]) => [command, parameter])
26
+ }
27
+
28
+ describe('commands reach node-switchbot devices', () => {
29
+ it.each(['Plug Mini (US)', 'Plug Mini (JP)', 'Plug', 'Relay Switch 1', 'Strip Light', 'Color Bulb', 'Ceiling Light'])('%s turns on and off', async (type) => {
30
+ expect(await send(type, { command: 'turnOn' }, { command: 'turnOff' })).toEqual([['turnOn', undefined], ['turnOff', undefined]])
31
+ })
32
+
33
+ it('lock Pro locks and unlocks', async () => {
34
+ const calls = await send('Smart Lock Pro', { command: 'lock' }, { command: 'unlock' })
35
+ expect(calls.map(([command]) => command)).toEqual(['lock', 'unlock'])
36
+ })
37
+
38
+ // Brightness arrives as a string; color as "hue,saturation"; color temperature in mireds.
39
+ it('color Bulb converts brightness, color and color temperature', async () => {
40
+ const calls = await send(
41
+ 'Color Bulb',
42
+ { command: 'setBrightness', parameter: '40' },
43
+ { command: 'setColor', parameter: '120,100' },
44
+ { command: 'setColorTemperature', parameter: '250' },
45
+ )
46
+ expect(calls).toEqual([
47
+ ['setBrightness', 40],
48
+ ['setColor', '0:255:0'],
49
+ ['setColorTemperature', 4000],
50
+ ])
51
+ })
52
+
53
+ it('curtain and Blind Tilt set their position', async () => {
54
+ expect(await send('Curtain3', { command: 'setPosition', parameter: '30' })).toEqual([['setPosition', '0,ff,30']])
55
+ expect(await send('Blind Tilt', { command: 'setPosition', parameter: '50' })).toEqual([['setPosition', 'down;100']])
56
+ })
57
+
58
+ it('a vacuum starts and docks', async () => {
59
+ expect(await send('K10+', { command: 'start' }, { command: 'dock' })).toEqual([['start', undefined], ['dock', undefined]])
60
+ })
61
+
62
+ it('an unsupported command is rejected', async () => {
63
+ await expect(send('Meter', { command: 'turnOn' })).rejects.toThrow('Unsupported command')
64
+ })
65
+ })
66
+
67
+ describe('parameter conversion', () => {
68
+ it('converts mireds to kelvin and passes kelvin through', () => {
69
+ expect(toKelvin(140)).toBe(6500)
70
+ expect(toKelvin(500)).toBe(2700)
71
+ expect(toKelvin(4000)).toBe(4000)
72
+ })
73
+
74
+ it('converts hue and saturation to RGB', () => {
75
+ expect(hsToRgb(0, 100)).toEqual([255, 0, 0])
76
+ expect(hsToRgb(240, 100)).toEqual([0, 0, 255])
77
+ expect(hsToRgb(0, 0)).toEqual([255, 255, 255])
78
+ })
79
+
80
+ it('accepts hex and r:g:b colors', () => {
81
+ expect(toRgb('#FF8000')).toEqual([255, 128, 0])
82
+ expect(toRgb('10:20:30')).toEqual([10, 20, 30])
83
+ expect(() => toRgb('nope')).toThrow('Unrecognised color')
84
+ })
85
+ })
@@ -32,4 +32,21 @@ describe('switchBotClient debounce', () => {
32
32
  expect(doSetDeviceState).toHaveBeenCalledWith('device-1', { command: 'turnOn' })
33
33
  expect(doSetDeviceState).toHaveBeenCalledWith('device-2', { command: 'turnOff' })
34
34
  })
35
+
36
+ // HomeKit writes On and Brightness together when a light is turned on at a level. Debouncing by
37
+ // device alone kept only the last body, so turnOn was dropped while both callers saw success.
38
+ it('keeps different operations on the same device', async () => {
39
+ const client = new SwitchBotClient({ logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } } as any)
40
+ const doSetDeviceState = vi.spyOn(client as any, '_doSetDeviceState').mockResolvedValue({ status: 'success' })
41
+
42
+ await Promise.all([
43
+ client.setDeviceState('bulb', { command: 'turnOn' }),
44
+ client.setDeviceState('bulb', { command: 'setBrightness', parameter: 40 }),
45
+ client.setDeviceState('bulb', { command: 'setBrightness', parameter: 50 }),
46
+ ])
47
+
48
+ expect(doSetDeviceState).toHaveBeenCalledTimes(2)
49
+ expect(doSetDeviceState).toHaveBeenCalledWith('bulb', { command: 'turnOn' })
50
+ expect(doSetDeviceState).toHaveBeenCalledWith('bulb', { command: 'setBrightness', parameter: 50 })
51
+ })
35
52
  })
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { SwitchBotClient } from '../../src/switchbotClient'
4
+
5
+ function client(devices: any[] = []) {
6
+ const c = new SwitchBotClient({ logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } } as any)
7
+ const discover = vi.fn(async () => {
8
+ await new Promise(resolve => setTimeout(resolve, 10))
9
+ return devices
10
+ })
11
+ ;(c as any).client = { discover, devices: { get: () => undefined, list: () => devices } }
12
+ return { c, discover }
13
+ }
14
+
15
+ // A configured device that is offline or out of range is not in the device list. Every lookup
16
+ // forced a full discovery (BLE scan plus an OpenAPI getDevices call), and concurrent lookups each
17
+ // started their own.
18
+ describe('switchBotClient rediscovery for a missing device', () => {
19
+ it('shares one discovery between concurrent lookups', async () => {
20
+ const { c, discover } = client([{ id: 'present' }])
21
+ await Promise.all([c.getDevice('missing-1'), c.getDevice('missing-2'), c.getDevice('missing-3')])
22
+ expect(discover).toHaveBeenCalledOnce()
23
+ })
24
+
25
+ it('does not rediscover again within the cache period', async () => {
26
+ const { c, discover } = client([{ id: 'present' }])
27
+ await c.getDevice('missing')
28
+ await c.getDevice('missing')
29
+ await c.getDevice('missing')
30
+ expect(discover).toHaveBeenCalledOnce()
31
+ })
32
+
33
+ it('still finds a device that appears after a rediscovery', async () => {
34
+ const devices: any[] = []
35
+ const { c, discover } = client(devices)
36
+ devices.push({ id: 'late' })
37
+ await expect(c.getDevice('late')).resolves.toEqual({ id: 'late' })
38
+ expect(discover).toHaveBeenCalledOnce()
39
+ })
40
+ })
@@ -45,3 +45,45 @@ describe('genericDevice BLE polling config', () => {
45
45
  expect((device as any)._blePollIntervalMs).toBe(600000)
46
46
  })
47
47
  })
48
+
49
+ // "BLE polling" called getState() for every device every 10 minutes, even with BLE disabled or for
50
+ // a cloud-only device. getState() then reads status over the OpenAPI, outside the plugin's daily
51
+ // API budget and on top of the normal refresh, which can use up the 10,000 calls a day.
52
+ describe('genericDevice BLE polling only polls over BLE', () => {
53
+ function device(cfg: Record<string, unknown>, hasBLE: boolean | undefined) {
54
+ const nodeDevice = hasBLE === undefined ? undefined : { hasBLE: () => hasBLE }
55
+ const client = { devices: { get: () => nodeDevice }, getDevice: vi.fn(async () => nodeDevice) }
56
+ const d = new GenericDevice({ id: 'dev', type: 'Unknown', log: mockLogger, blePollIntervalMs: 60_000 }, { log: mockLogger, _client: client, ...cfg } as any)
57
+ const getState = vi.spyOn(d, 'getState').mockResolvedValue({})
58
+ return { d, getState }
59
+ }
60
+
61
+ it('does not start when BLE is disabled', () => {
62
+ const { d } = device({ enableBLE: false }, true)
63
+ expect((d as any)._blePollTimer).toBeNull()
64
+ })
65
+
66
+ it('skips devices without a BLE connection', async () => {
67
+ vi.useFakeTimers()
68
+ try {
69
+ const { d, getState } = device({}, false)
70
+ await vi.advanceTimersByTimeAsync(60_000)
71
+ expect(getState).not.toHaveBeenCalled()
72
+ await d.destroy()
73
+ } finally {
74
+ vi.useRealTimers()
75
+ }
76
+ })
77
+
78
+ it('polls devices that have BLE', async () => {
79
+ vi.useFakeTimers()
80
+ try {
81
+ const { d, getState } = device({}, true)
82
+ await vi.advanceTimersByTimeAsync(60_000)
83
+ expect(getState).toHaveBeenCalledOnce()
84
+ await d.destroy()
85
+ } finally {
86
+ vi.useRealTimers()
87
+ }
88
+ })
89
+ })
@@ -0,0 +1,112 @@
1
+ import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import * as hap from '@homebridge/hap-nodejs'
6
+ import { Characteristic, Service, uuid } from '@homebridge/hap-nodejs'
7
+ import { describe, expect, it, vi } from 'vitest'
8
+
9
+ // Homebridge exports only the PlatformAccessory type. This test needs the real class, because its
10
+ // serialize/deserialize are what write and read the accessory cache.
11
+ // eslint-disable-next-line antfu/no-import-dist, antfu/no-import-node-modules-by-path
12
+ import { PlatformAccessory } from '../../node_modules/homebridge/dist/platformAccessory.js'
13
+ import { repairCachedAccessories } from '../../src/cacheRepair.js'
14
+ import { createDevice } from '../../src/deviceFactory.js'
15
+ import { PLATFORM_NAME, PLUGIN_NAME } from '../../src/settings.js'
16
+ import { SwitchBotHAPPlatform } from '../../src/SwitchBotHAPPlatform.js'
17
+ import { normalizeTypeForMatter } from '../../src/utils.js'
18
+
19
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
20
+ const INFO = Service.AccessoryInformation.UUID
21
+
22
+ async function startOnce(cache: any[] | undefined, devices: { id: string, type: string, name: string }[], mutate?: (json: any) => void) {
23
+ // Homebridge sets these when it registers an accessory; serialize() requires them.
24
+ const register = (accs: any[]) => accs.forEach((a) => {
25
+ a._associatedPlugin = PLUGIN_NAME
26
+ a._associatedPlatform = PLATFORM_NAME
27
+ })
28
+ const api = { hap: { Service, Characteristic, uuid }, platformAccessory: PlatformAccessory, registerPlatformAccessories: vi.fn((_p, _q, accs) => register(accs)), updatePlatformAccessories: vi.fn() }
29
+ const platform = Object.create(SwitchBotHAPPlatform.prototype) as any
30
+ platform.api = api
31
+ platform.log = log
32
+ platform.config = { devices: devices.map(d => ({ deviceId: d.id, configDeviceType: d.type })) }
33
+ platform.hapPushTargets = new Map()
34
+ platform.accessories = new Map()
35
+ for (const json of cache ?? []) {
36
+ mutate?.(json)
37
+ const acc: any = PlatformAccessory.deserialize(json) // the call that throws in #1424
38
+ platform.accessories.set(acc.UUID, acc)
39
+ }
40
+ const list = []
41
+ for (const d of devices) {
42
+ const type = normalizeTypeForMatter(d.type)
43
+ const _client = { getDeviceStatus: vi.fn(async () => ({})), setDeviceState: vi.fn(async () => ({ success: true })), getDevice: vi.fn(async () => undefined) }
44
+ const created = await createDevice({ id: d.id, type, deviceType: d.type, name: d.name, log } as any, { log, devices: platform.config.devices, _client } as any, false)
45
+ list.push({ created, d, type })
46
+ }
47
+ log.warn.mockClear()
48
+ await platform.registerHAPAccessories(list)
49
+ expect(log.warn).not.toHaveBeenCalled()
50
+ return [...platform.accessories.values()].map((a: any) => PlatformAccessory.serialize(a))
51
+ }
52
+
53
+ describe('hAP accessory cache across restarts (#1424)', () => {
54
+ const devices = [
55
+ { id: 'EA552551B763', type: 'Curtain3', name: 'Curtain3-1' },
56
+ { id: 'D83BDA1173CA', type: 'Plug', name: 'Plug-1' },
57
+ ]
58
+ const hasInfo = (cache: any[]) => cache.every(a => a.services.some((s: any) => s.UUID === INFO))
59
+
60
+ it('survives restarts', async () => {
61
+ let cache = await startOnce(undefined, devices)
62
+ expect(cache).toHaveLength(2)
63
+ for (let i = 0; i < 3; i++) cache = await startOnce(cache, devices)
64
+ expect(hasInfo(cache)).toBe(true)
65
+ })
66
+
67
+ // HAP restores a service whose class name was not stored as a plain `Service`. The plugin
68
+ // used to match services by class name, removed AccessoryInformation, and the next start crashed.
69
+ it('survives restarts with a cache whose services have no constructorName', async () => {
70
+ let cache = await startOnce(undefined, devices)
71
+ for (let i = 0; i < 3; i++) {
72
+ cache = await startOnce(cache, devices, json => json.services.forEach((s: any) => delete s.constructorName))
73
+ }
74
+ expect(hasInfo(cache)).toBe(true)
75
+ })
76
+
77
+ it('repairs a cache that is already missing AccessoryInformation, so the bridge can start', async () => {
78
+ const dir = mkdtempSync(join(tmpdir(), 'switchbot-cache-'))
79
+ const good = await startOnce(undefined, devices)
80
+ const broken = good.map(a => ({ ...a, services: a.services.filter((s: any) => s.UUID !== INFO) }))
81
+ const otherPlugin = { ...broken[0], plugin: 'homebridge-other', UUID: uuid.generate('other') }
82
+ const file = join(dir, 'cachedAccessories.0EF8A6716C95')
83
+ writeFileSync(file, JSON.stringify([...broken, otherPlugin]))
84
+ expect(() => PlatformAccessory.deserialize(broken[0])).toThrow('getCharacteristic')
85
+
86
+ const messages: string[] = []
87
+ const api: any = { hap, user: { cachedAccessoryPath: () => dir } }
88
+ const results = repairCachedAccessories(api, m => messages.push(m))
89
+
90
+ expect(results).toEqual([{ file, repaired: ['Curtain3-1', 'Plug-1'] }])
91
+ expect(messages[0]).toContain('Repaired 2 cached accessories')
92
+ expect(readdirSync(dir)).toContain('cachedAccessories.0EF8A6716C95.switchbot-repair.bak')
93
+ const repaired = JSON.parse(readFileSync(file, 'utf8'))
94
+ expect(repaired[2]).toEqual(otherPlugin) // other plugins' entries are left alone
95
+ let cache = await startOnce(repaired.slice(0, 2), devices)
96
+ cache = await startOnce(cache, devices)
97
+ expect(hasInfo(cache)).toBe(true)
98
+ // A second run finds nothing to do and does not rewrite the file.
99
+ expect(repairCachedAccessories(api)).toEqual([])
100
+ })
101
+
102
+ it('ignores missing directories, backups and unreadable files', () => {
103
+ const dir = mkdtempSync(join(tmpdir(), 'switchbot-cache-'))
104
+ writeFileSync(join(dir, '.cachedAccessories.bak'), JSON.stringify([{ plugin: PLUGIN_NAME, services: [] }]))
105
+ writeFileSync(join(dir, 'cachedAccessories'), '{not json')
106
+ const messages: string[] = []
107
+ expect(repairCachedAccessories({ hap, user: { cachedAccessoryPath: () => dir } } as any, m => messages.push(m))).toEqual([])
108
+ expect(messages).toHaveLength(1)
109
+ expect(repairCachedAccessories({ hap, user: { cachedAccessoryPath: () => join(dir, 'missing') } } as any)).toEqual([])
110
+ expect(repairCachedAccessories({} as any)).toEqual([])
111
+ })
112
+ })
@@ -29,6 +29,32 @@ describe('utils', () => {
29
29
  expect(result).not.toBe(input)
30
30
  })
31
31
 
32
+ // The config from #1424: credentials and options in the v4 layout, read by a v5 plugin that
33
+ // only looked at openApiToken/openApiSecret/enableBLE, so the OpenAPI was never configured.
34
+ it('normalizeConfig reads v4 credentials and options when the v5 keys are missing', () => {
35
+ const devices = [{ deviceId: 'EA552551B763', configDeviceType: 'Curtain3' }]
36
+ const result = normalizeConfig({
37
+ platform: 'SwitchBot',
38
+ credentials: { token: 'TOKEN', secret: 'SECRET', notice: 'Keep your Token & Secret a secret!' },
39
+ options: { BLE: false, discoverBLE: false, refreshRate: 180, logging: 'standard', devices },
40
+ } as any)
41
+ expect(result).toMatchObject({ openApiToken: 'TOKEN', openApiSecret: 'SECRET', enableBLE: false, openApiRefreshRate: 180, devices })
42
+ })
43
+
44
+ it('normalizeConfig prefers the v5 keys over v4 ones', () => {
45
+ const devices = [{ deviceId: 'A' }]
46
+ const result = normalizeConfig({
47
+ openApiToken: 'NEW',
48
+ openApiSecret: 'NEWSECRET',
49
+ enableBLE: true,
50
+ openApiRefreshRate: 600,
51
+ devices,
52
+ credentials: { token: 'OLD', secret: 'OLDSECRET' },
53
+ options: { BLE: false, refreshRate: 60, devices: [{ deviceId: 'B' }] },
54
+ } as any)
55
+ expect(result).toMatchObject({ openApiToken: 'NEW', openApiSecret: 'NEWSECRET', enableBLE: true, openApiRefreshRate: 600, devices })
56
+ })
57
+
32
58
  it('createPlatformProxy instantiates MatterPlatform if available and enabled', () => {
33
59
  const HAP = vi.fn()
34
60
  const Matter = vi.fn()
@@ -1,100 +0,0 @@
1
- #!/bin/env node
2
-
3
- /**
4
- * This scripts queries the npm registry to pull out the latest version for a given tag.
5
- */
6
-
7
- import assert from 'node:assert'
8
- import child_process from 'node:child_process'
9
- import fs from 'node:fs'
10
- import process from 'node:process'
11
-
12
- const BRANCH_VERSION_PATTERN = /^([A-Z]+)-(\d+\.\d+\.\d+)$/i
13
-
14
- // Load the contents of the package.json file
15
- const packageJSON = JSON.parse(fs.readFileSync('package.json', 'utf8'))
16
-
17
- const refArgument = process.argv[2]
18
- const tagArgument = process.argv[3] || 'latest'
19
-
20
- if (!refArgument) {
21
- console.error('ref argument is missing')
22
- console.error('Usage: npm-version-script-esm.js <ref> [tag]')
23
- process.exit(1)
24
- }
25
-
26
- /**
27
- * Queries the NPM registry for the latest version for the provided base version and tag.
28
- * If the tag is latest, then the base version is returned if it exists. For other tags, the latest
29
- * version found for that base version and tag is returned.
30
- * @param baseVersion The base version to query for, e.g. 2.0.0
31
- * @param tag The tag to query for, e.g. beta or latest
32
- * @returns {string} Returns the version, or '' if not found
33
- */
34
- function getTagVersionFromNpm(baseVersion, tag) {
35
- try {
36
- return JSON.parse(child_process.execSync(`npm info ${packageJSON.name} versions --json`).toString('utf8').trim())
37
- .filter(v => tag === 'latest' ? v === baseVersion : v.startsWith(`${baseVersion}-${tag}.`)) // find all published versions for this base version and tag
38
- .reduce((_, current) => current, '') // choose the last as they're sorted in ascending order, or '' if there are none
39
- } catch (e) {
40
- console.error(`Failed to query the npm registry for the latest version for tag: ${tag}`, e)
41
- // throw e;
42
- return ''
43
- }
44
- }
45
-
46
- function desiredTargetVersion(ref) {
47
- // ref is a GitHub action ref string
48
- if (ref.startsWith('refs/pull/')) {
49
- throw new Error('The version script was executed inside a PR!')
50
- }
51
-
52
- assert(ref.startsWith('refs/heads/'))
53
- const branchName = ref.slice('refs/heads/'.length)
54
-
55
- const results = branchName.match(BRANCH_VERSION_PATTERN)
56
- if (results !== null) {
57
- if (results[1] !== tagArgument) {
58
- console.warn(`The base branch name (${results[1]}) differs from the tag name ${tagArgument}`)
59
- }
60
-
61
- return results[2]
62
- }
63
-
64
- throw new Error(`Malformed branch name for ref: ${ref}. Can't derive the base version. Use a branch name like: beta-x.x.x or alpha-x.x.x`)
65
- }
66
-
67
- // derive the base version from the branch ref
68
- const baseVersion = desiredTargetVersion(refArgument)
69
-
70
- // query the npm registry for the latest version of the provided tag name
71
- const latestReleasedVersion = getTagVersionFromNpm(baseVersion, tagArgument) // e.g. 0.7.0-beta.12
72
-
73
- let publishTag
74
-
75
- if (latestReleasedVersion) {
76
- console.warn(`Latest published version for ${baseVersion} with tag ${tagArgument} is ${latestReleasedVersion}`)
77
- publishTag = latestReleasedVersion // set this released beta or alpha to be incremented
78
- } else {
79
- console.warn(`No published versions for ${baseVersion} with tag ${tagArgument}`)
80
- publishTag = baseVersion // start off with a new beta or alpha version
81
- }
82
-
83
- if (packageJSON.version !== publishTag) {
84
- // report the change for CI
85
- console.warn(`Changing version in package.json from ${packageJSON.version} to ${publishTag}`)
86
-
87
- // save the package.json
88
- // JSON.stringify does not end with a newline, but npm writes both of these
89
- // files with one. Leaving it off makes the file fail an eol-last lint rule,
90
- // which breaks any repo whose prepublishOnly lints the whole tree.
91
- packageJSON.version = publishTag
92
- fs.writeFileSync('package.json', `${JSON.stringify(packageJSON, null, 2)}\n`)
93
-
94
- // perform the same change to the package-lock.json
95
- const packageLockJSON = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'))
96
- packageLockJSON.version = publishTag
97
- fs.writeFileSync('package-lock.json', `${JSON.stringify(packageLockJSON, null, 2)}\n`)
98
- } else {
99
- console.warn(`Leaving version in package.json at ${packageJSON.version}`)
100
- }