213 lines
7.5 KiB
JavaScript
213 lines
7.5 KiB
JavaScript
// 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 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
|
||
}
|