Upstream spencermountain/spacetime state of the touched files before fe90cae667
This commit is contained in:
1 parent
081c965030
commit
d47824c8f3
6 files changed
+2406
No files matched your search
@@ -0,0 +1,212 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user