Files
spencermountain-spacetime/scripts/tz/update.js
T

213 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// CLI orchestration: read zoneinfo, normalize records, report differences, then write.
import { execFileSync } from 'node:child_process'
import {
existsSync,
readFileSync,
readdirSync,
renameSync,
statSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import data from '../../zonefile/iana.js'
import aliases from '../../zonefile/aliases.js'
import { parseIntervals, normalizeZone } from './lib.js'
import { printHeader, printReport, printResult, printError, printHelp } from './report.js'
const root = fileURLToPath(new URL('../../', import.meta.url))
// Directory-only legacy keys may be retained with --allow-unsupported.
// Missing or malformed files remain fatal because their timezone state is unknown.
export class UnsupportedZoneError extends Error {}
export const parseArgs = (args) => {
const options = {
year: new Date().getUTCFullYear(),
zoneinfoDir: '/usr/share/zoneinfo',
check: false,
verbose: false,
allowUnsupported: false
}
for (let i = 0; i < args.length; i += 1) {
const arg = args[i]
if (arg === '--check') {
options.check = true
} else if (arg === '--verbose') {
options.verbose = true
} else if (arg === '--allow-unsupported') {
options.allowUnsupported = true
} else if (arg === '--help') {
options.help = true
} else if (['--year', '--zoneinfo-dir', '--output'].includes(arg)) {
const value = args[++i]
if (!value || value.startsWith('--')) {
throw new Error('Missing value for ' + arg)
}
if (arg === '--year') {
if (!/^\d{4}$/.test(value) || Number(value) < 1900 || Number(value) > 9998) {
throw new Error('Year must be 1900–9998')
}
options.year = Number(value)
} else {
options[arg === '--output' ? 'output' : 'zoneinfoDir'] = resolve(value)
}
} else {
throw new Error('Unknown option: ' + arg)
}
}
options.output ||= join(root, `zonefile.${options.year}.js`)
return options
}
// Library keys are lowercase; zoneinfo paths are case-sensitive on many systems.
// Apply known aliases, then resolve every component using its actual spelling.
export const resolveZone = (directory, name) => {
name = aliases[name] || name
let path = directory
for (const component of name.split('/')) {
if (!component || component === '.' || component === '..') {
throw new Error('Invalid zone name')
}
const matches = readdirSync(path).filter(
(entry) => entry.toLowerCase() === component.toLowerCase()
)
if (matches.length !== 1) {
throw new Error('Missing or ambiguous zone: ' + name)
}
path = join(path, matches[0])
}
const stats = statSync(path)
// zdump can silently report UTC for a directory. Reject it before invocation.
if (stats.isDirectory()) {
throw Object.assign(new UnsupportedZoneError('Zone name resolves to a directory, not a timezone: ' + name), {
details: [
`Resolved path: ${path}`,
`Available entries: ${readdirSync(path).sort().join(', ')}`,
'Choose a specific timezone or define an explicit library alias; a directory has no offset or transitions.'
]
})
}
if (!stats.isFile() || readFileSync(path).subarray(0, 4).toString() !== 'TZif') {
throw new Error('Not a compiled TZif file: ' + name)
}
return path
}
// macOS provides +VERSION; common Linux distributions embed it in tzdata.zi.
// File modification times are not reliable tzdata release identifiers.
const sourceVersion = (directory) => {
for (const name of ['+VERSION', 'tzdata.zi']) {
const path = join(directory, name)
if (existsSync(path)) {
const text = readFileSync(path, 'utf8')
const version = name === '+VERSION' ? text.trim() : /^# version (\S+)/m.exec(text)?.[1]
if (version) {
return version
}
}
}
return 'unknown'
}
// Inject the reader so callers can supply fixtures without invoking zdump.
// Build a fresh result and retain diagnostic context without mutating source data.
export const updateZones = (zones, year, readZone) => {
const result = {}
const changes = []
const unsupported = []
for (const [name, previous] of Object.entries(zones)) {
// Acquisition and parsing errors must fail, even with --allow-unsupported.
let intervals
try {
intervals = parseIntervals(readZone(name))
} catch (error) {
if (error instanceof UnsupportedZoneError) {
unsupported.push({ name, reason: error.message, previous, details: error.details })
result[name] = { ...previous }
continue
}
throw new Error(`${name}: ${error.message}`)
}
try {
result[name] = normalizeZone(intervals, previous, name, year)
} catch (error) {
unsupported.push({ name, reason: error.message, previous, intervals, details: error.details })
result[name] = { ...previous }
continue
}
if (JSON.stringify(previous) !== JSON.stringify(result[name])) {
changes.push({ name, before: previous, after: result[name] })
}
}
return { result, changes, unsupported }
}
export const main = (args = process.argv.slice(2)) => {
const options = parseArgs(args)
if (options.help) {
printHelp()
return
}
const { year, zoneinfoDir } = options
const version = sourceVersion(zoneinfoDir)
const total = Object.keys(data).length
printHeader({ ...options, version, total })
const { result, changes, unsupported } = updateZones(data, year, (name) => {
const path = resolveZone(zoneinfoDir, name)
// Bound extraction to one UTC year; argument arrays avoid shell interpretation.
// Interval output includes an initial offset even for zones with no transitions.
return execFileSync('zdump', ['-i', '-c', `${year},${year + 1}`, path], {
encoding: 'utf8',
timeout: 10000,
maxBuffer: 1024 * 1024,
env: { ...process.env, LC_ALL: 'C' },
stdio: ['ignore', 'pipe', 'pipe']
})
})
printReport({ changes, unsupported, total, verbose: options.verbose })
// Check mode and the unsupported-zone gate must run before any file write.
if (options.check) {
if (changes.length > 0 || unsupported.length > 0) {
process.exitCode = 1
}
printResult(
changes.length > 0 || unsupported.length > 0 ? 'Check needs attention. No files written.' : 'Check passed. No files written.',
Boolean(changes.length > 0 || unsupported.length > 0)
)
return
}
if (unsupported.length > 0 && !options.allowUnsupported) {
throw new Error(
'No output written. Resolve unsupported zones or explicitly preserve them with --allow-unsupported.'
)
}
const source = JSON.stringify({
year,
tzdata: version,
unsupported: unsupported.map((o) => o.name)
})
const text = `// Generated by scripts/tz/update.js: ${source}\nexport const tzdbVersion = ${JSON.stringify(version)}\nexport default ${JSON.stringify(result, null, 2)}\n`
// Rename within the destination directory so readers never see a partial file.
// Only remove a temporary file created by this run (exclusive creation may fail).
const temporary = join(dirname(options.output), `.zonefile-${process.pid}.tmp`)
let created = false
try {
writeFileSync(temporary, text, { encoding: 'utf8', flag: 'wx' })
created = true
renameSync(temporary, options.output)
} finally {
if (created && existsSync(temporary)) {
unlinkSync(temporary)
}
}
printResult(`Wrote ${options.output}`)
}
try {
main()
} catch (error) {
printError(error.message)
process.exitCode = 1
}