feat: add initial AppStore catalog

This commit is contained in:
2026-07-25 22:13:35 -04:00
commit 4326878282
12 changed files with 1158 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+11
View File
@@ -0,0 +1,11 @@
# Home Assistant connection
url=https://home-assistant.example.com
token=replace-with-a-long-lived-access-token
# One card per entity, up to 12
entity=weather.home
entity=climate.home
entity=person.example
entity=lock.front_door
entity=cover.garage_door
entity=light.living_room
+184
View File
@@ -0,0 +1,184 @@
-- DESCRIPTION: Home Assistant status cards
local CONFIG_PATH = "/plugins/HomeAssistant/config.txt"
local config = { entities = {} }
local cards = {}
local screen = nil
local connecting = false
local connected = false
local needsDraw = true
local function trim(value)
return value:match("^%s*(.-)%s*$")
end
local function loadConfig()
local content = fs.readFile(CONFIG_PATH)
if not content then return "Missing " .. CONFIG_PATH end
local lineNumber = 0
for rawLine in (content:gsub("\r", "") .. "\n"):gmatch("(.-)\n") do
lineNumber = lineNumber + 1
local line = trim(rawLine)
if line ~= "" and line:sub(1, 1) ~= "#" then
local key, value = line:match("^([%w_]+)%s*=%s*(.-)%s*$")
if not key or value == "" then return "Invalid config line " .. lineNumber end
if key == "url" then
config.url = value:gsub("/+$", "")
elseif key == "token" then
config.token = value
elseif key == "entity" then
if not value:match("^[%w_]+%.[%w_]+$") then return "Invalid entity on line " .. lineNumber end
config.entities[#config.entities + 1] = value
else
return "Unknown config key: " .. key
end
end
end
if not config.url then return "Config is missing url" end
if not config.token then return "Config is missing token" end
if #config.entities == 0 then return "Config has no entities" end
if #config.entities > 12 then return "Config supports up to 12 entities" end
end
local function jsonString(value)
local escapes = { ["\\"] = "\\\\", ["\""] = "\\\"", ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t" }
return '"' .. value:gsub('[\\"\n\r\t]', escapes) .. '"'
end
local function templateRequest()
local lines = {}
for _, entity in ipairs(config.entities) do
lines[#lines + 1] = "{{ (state_attr('" .. entity .. "','friendly_name') or '" .. entity .. "') | replace('|','/') }}|" ..
entity .. "|{{ states('" .. entity .. "') | replace('|','/') }}|{{ (state_attr('" .. entity ..
"','unit_of_measurement') or '') | replace('|','/') }}"
end
return '{"template":' .. jsonString(table.concat(lines, "\n")) .. "}"
end
local function titleCase(value)
return value:gsub("_", " "):gsub("(%a)([%w']*)", function(first, rest)
return first:upper() .. rest
end)
end
local function parseCards(body)
local result = {}
for line in (body .. "\n"):gmatch("(.-)\n") do
local title, entity, value, unit = line:match("^([^|]*)|([^|]*)|([^|]*)|(.*)$")
if title then
result[#result + 1] = {
title = title,
entity = entity,
value = titleCase(value),
detail = unit
}
end
end
return result
end
local function fetchCards()
screen = "Loading Home Assistant..."
needsDraw = true
local body, status = net.post(config.url .. "/api/template", templateRequest(), {
Authorization = "Bearer " .. config.token,
["Content-Type"] = "application/json"
})
if body and status == 200 then
cards = parseCards(body)
if #cards == #config.entities then
screen = nil
else
screen = "Invalid Home Assistant response"
end
else
screen = "Home Assistant error " .. tostring(status)
end
needsDraw = true
end
local function drawCardText(font, x, y, width, text, style)
local textWidth = gui.getTextWidth(font, text, style)
gui.drawText(font, x + math.floor((width - textWidth) / 2), y, text, COLOR_BLACK, style)
end
local function drawCard(card, x, y, width, height)
gui.drawRoundedRect(x, y, width, height, 2, 10, COLOR_BLACK)
gui.drawText(FONT_UI_10, x + 12, y + 10, card.title, COLOR_BLACK, STYLE_BOLD)
drawCardText(FONT_NOTOSANS_16, x, y + math.floor(height / 2) - 8, width, card.value, STYLE_BOLD)
if card.detail ~= "" then
drawCardText(FONT_SMALL, x, y + height - 28, width, card.detail, STYLE_REGULAR)
end
end
local function render()
local width, height = gui.width(), gui.height()
gui.clear()
gui.drawText(FONT_NOTOSANS_16, 20, 18, "Home Assistant", COLOR_BLACK, STYLE_BOLD)
gui.drawLine(20, 58, width - 20, 58, 2, COLOR_BLACK)
if screen then
gui.drawCenteredText(FONT_UI_12, math.floor(height / 2), screen, COLOR_BLACK, STYLE_REGULAR)
else
local columns = width >= 700 and 4 or 2
local rows = math.ceil(#cards / columns)
local gap = 10
local margin = 16
local top = 72
local bottom = 58
local cardWidth = math.floor((width - margin * 2 - gap * (columns - 1)) / columns)
local cardHeight = math.floor((height - top - bottom - gap * (rows - 1)) / rows)
for index, card in ipairs(cards) do
local column = (index - 1) % columns
local row = math.floor((index - 1) / columns)
drawCard(card, margin + column * (cardWidth + gap), top + row * (cardHeight + gap), cardWidth, cardHeight)
end
end
gui.drawButtonHints("<<", connected and "Refresh" or "", "", "")
gui.refresh(REFRESH_HALF)
needsDraw = false
end
function init()
local configError = loadConfig()
if configError then
screen = configError
return
end
screen = "Connecting to Wi-Fi..."
connecting = true
net.wifiConnect()
end
function draw()
if input.wasPressed("back") then
if connecting or connected then net.wifiDisconnect() end
sys.exit()
return
end
if connecting then
local wifi = net.wifiStatus()
if wifi == "connected" then
connecting = false
connected = true
fetchCards()
elseif wifi == "failed" then
connecting = false
screen = "Wi-Fi connection failed"
needsDraw = true
end
elseif connected and input.wasPressed("confirm") then
fetchCards()
end
if needsDraw then render() end
end
+3
View File
@@ -0,0 +1,3 @@
XTEINK-MANIFEST|1
config.txt|280|db80ede9c74ccd83b76f1bb64ca7193cc6477de905bad6d153730ec66768bfa7
main.lua|6144|d7f0b42895c3fb5fef02c8932019276f97392e79a8b9db538165aaa2699de4c3
+58
View File
@@ -0,0 +1,58 @@
# Xteink Apps
App repository for CrossPoint Reader's Lua AppStore.
Catalog URL:
```text
https://gitea.va.reichard.io/evan/xteink-apps/raw/branch/main/catalog.txt
```
## Layout
```text
catalog.txt
<AppId>/
manifest.txt
main.lua
...
scripts/update-manifests.py
```
`catalog.txt` is line-oriented:
```text
XTEINK-CATALOG|1
<AppId>|<description>|<manifest path>|<manifest bytes>|<manifest SHA-256>
```
Each generated `manifest.txt` contains every installable file in that app:
```text
XTEINK-MANIFEST|1
<relative path>|<bytes>|<SHA-256>
```
App IDs and file paths cannot contain `|`. App IDs use only letters, numbers, `_`, and `-`. Descriptions come from the `-- DESCRIPTION:` line in each `main.lua`.
## Adding or changing an app
1. Add or edit an app directory containing `main.lua`.
2. Regenerate metadata:
```bash
scripts/update-manifests.py
```
3. Verify generated files are current:
```bash
scripts/update-manifests.py --check
```
Commit app payloads, manifests, and `catalog.txt` together.
## Included apps
- **HomeAssistant** — Edit its installed `config.txt` with your URL, long-lived access token, and entity IDs. The repository contains placeholders only.
- **Wordle** — Includes its five-letter candidate list in `words.txt`.
+249
View File
@@ -0,0 +1,249 @@
-- DESCRIPTION: Guess the five-letter word in six tries
local WORDS_PATH = "/plugins/Wordle/words.txt"
local WORD_LENGTH = 5
local MAX_GUESSES = 6
local keyboard = {
{ "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P" },
{ "A", "S", "D", "F", "G", "H", "J", "K", "L" },
{ "OK", "Z", "X", "C", "V", "B", "N", "M", "DEL" }
}
local answer = nil
local current = ""
local guesses = {}
local results = {}
local keyStates = {}
local cursorRow = 1
local cursorColumn = 1
local message = ""
local gameOver = false
local firstDraw = true
local needsDraw = true
-- Word Source - Replace only this function to fetch answers from another source.
local function getRandomWord()
local size = fs.fileSize(WORDS_PATH)
if not size then return nil, "Missing " .. WORDS_PATH end
if size == 0 then return nil, "Word list is empty" end
for _ = 1, 8 do
local word = fs.readLineAt(WORDS_PATH, math.random(0, size - 1)) or fs.readLineAt(WORDS_PATH, 0)
if word then
word = word:match("^%s*(.-)%s*$")
if #word == WORD_LENGTH and word:match("^%a+$") then return word:upper() end
end
end
return nil, "Word list has no five-letter words"
end
local function startGame()
local errorMessage
answer, errorMessage = getRandomWord()
current = ""
guesses = {}
results = {}
keyStates = {}
cursorRow = 1
cursorColumn = 1
gameOver = not answer
message = errorMessage or "Pick a letter"
firstDraw = true
needsDraw = true
end
local function evaluateGuess(guess)
local status = {}
local remaining = {}
for index = 1, WORD_LENGTH do
local letter = answer:sub(index, index)
if guess:sub(index, index) == letter then
status[index] = 2
else
remaining[letter] = (remaining[letter] or 0) + 1
end
end
for index = 1, WORD_LENGTH do
if not status[index] then
local letter = guess:sub(index, index)
if (remaining[letter] or 0) > 0 then
status[index] = 1
remaining[letter] = remaining[letter] - 1
else
status[index] = 0
end
end
end
return status
end
local function submitGuess()
if #current ~= WORD_LENGTH then
message = "Enter five letters"
return
end
local status = evaluateGuess(current)
guesses[#guesses + 1] = current
results[#results + 1] = status
for index = 1, WORD_LENGTH do
local letter = current:sub(index, index)
keyStates[letter] = math.max(keyStates[letter] or 0, status[index])
end
if current == answer then
message = "Solved! Press OK for a new word"
gameOver = true
elseif #guesses == MAX_GUESSES then
message = answer .. " - Press OK for a new word"
gameOver = true
else
message = tostring(MAX_GUESSES - #guesses) .. " guesses left"
end
current = ""
end
local function useKey(key)
if key == "OK" then
submitGuess()
elseif key == "DEL" then
current = current:sub(1, -2)
message = "Pick a letter"
elseif #current < WORD_LENGTH then
current = current .. key
message = "Pick a letter"
end
needsDraw = true
end
local function centeredText(font, x, y, width, text, color, style)
local textWidth = gui.getTextWidth(font, text, style)
gui.drawText(font, x + math.floor((width - textWidth) / 2), y, text, color, style)
end
local function drawTile(letter, status, x, y, size)
local fill = COLOR_WHITE
local textColor = COLOR_BLACK
if status == 2 then
fill = COLOR_BLACK
textColor = COLOR_WHITE
elseif status == 1 then
fill = COLOR_LIGHT_GRAY
elseif status == 0 then
fill = COLOR_DARK_GRAY
textColor = COLOR_WHITE
end
if status ~= nil then gui.fillRoundedRect(x, y, size, size, 5, fill) end
gui.drawRoundedRect(x, y, size, size, 2, 5, COLOR_BLACK)
if letter ~= "" then
centeredText(FONT_NOTOSANS_16, x, y + math.floor(size / 2) - 10, size, letter, textColor, STYLE_BOLD)
end
end
local function drawGrid(width, height)
local gap = 5
local size = math.min(62, math.floor((width - 60 - gap * (WORD_LENGTH - 1)) / WORD_LENGTH),
math.floor((height * 0.53 - gap * (MAX_GUESSES - 1)) / MAX_GUESSES))
local gridWidth = size * WORD_LENGTH + gap * (WORD_LENGTH - 1)
local startX = math.floor((width - gridWidth) / 2)
local startY = 72
for row = 1, MAX_GUESSES do
local word = guesses[row] or (row == #guesses + 1 and current or "")
local status = results[row]
for column = 1, WORD_LENGTH do
drawTile(word:sub(column, column), status and status[column] or nil,
startX + (column - 1) * (size + gap), startY + (row - 1) * (size + gap), size)
end
end
return startY + MAX_GUESSES * size + (MAX_GUESSES - 1) * gap
end
local function drawKeyboard(width, height, top)
local gap = 4
local margin = 12
local keyWidth = math.floor((width - margin * 2 - gap * 9) / 10)
local keyHeight = math.min(48, math.floor((height - top - 70 - gap * 2) / 3))
for rowIndex, row in ipairs(keyboard) do
local rowWidth = #row * keyWidth + (#row - 1) * gap
local startX = math.floor((width - rowWidth) / 2)
local y = top + (rowIndex - 1) * (keyHeight + gap)
for columnIndex, key in ipairs(row) do
local x = startX + (columnIndex - 1) * (keyWidth + gap)
local status = keyStates[key]
local fill = status == 2 and COLOR_BLACK or status == 1 and COLOR_LIGHT_GRAY or
status == 0 and COLOR_DARK_GRAY or COLOR_WHITE
local textColor = (status == 2 or status == 0) and COLOR_WHITE or COLOR_BLACK
gui.fillRoundedRect(x, y, keyWidth, keyHeight, 4, fill)
gui.drawRoundedRect(x, y, keyWidth, keyHeight, 1, 4, COLOR_BLACK)
if rowIndex == cursorRow and columnIndex == cursorColumn then
gui.drawRoundedRect(x + 2, y + 2, keyWidth - 4, keyHeight - 4, 3, 3, textColor)
end
centeredText(FONT_SMALL, x, y + math.floor(keyHeight / 2) - 7, keyWidth, key, textColor, STYLE_BOLD)
end
end
end
local function render()
local width, height = gui.width(), gui.height()
gui.clear()
gui.drawCenteredText(FONT_NOTOSANS_16, 16, "WORDLE", COLOR_BLACK, STYLE_BOLD)
gui.drawCenteredText(FONT_SMALL, 48, message, COLOR_BLACK, STYLE_REGULAR)
if answer then
local gridBottom = drawGrid(width, height)
drawKeyboard(width, height, gridBottom + 14)
end
gui.drawButtonHints("<<", "Select", "<", ">")
gui.refresh(firstDraw and REFRESH_HALF or REFRESH_FAST)
firstDraw = false
needsDraw = false
end
function init()
startGame()
end
function draw()
if input.wasPressed("back") then
sys.exit()
return
end
if gameOver and answer then
if input.wasPressed("confirm") then startGame() end
elseif answer then
if input.wasPressed("left") then
cursorColumn = cursorColumn == 1 and #keyboard[cursorRow] or cursorColumn - 1
needsDraw = true
elseif input.wasPressed("right") then
cursorColumn = cursorColumn == #keyboard[cursorRow] and 1 or cursorColumn + 1
needsDraw = true
elseif input.wasPressed("up") then
cursorRow = cursorRow == 1 and #keyboard or cursorRow - 1
cursorColumn = math.min(cursorColumn, #keyboard[cursorRow])
needsDraw = true
elseif input.wasPressed("down") then
cursorRow = cursorRow == #keyboard and 1 or cursorRow + 1
cursorColumn = math.min(cursorColumn, #keyboard[cursorRow])
needsDraw = true
elseif input.wasPressed("confirm") then
useKey(keyboard[cursorRow][cursorColumn])
end
end
if needsDraw then render() end
end
+3
View File
@@ -0,0 +1,3 @@
XTEINK-MANIFEST|1
main.lua|8027|2337650ccbbce8b7f2a75c8025e3fe70ee794c81e9af956d9be94f2126e7cc90
words.txt|2814|057281f29400005492e9c891aeda0fbe9a3d3e6060304b71e7083ac02d9b3692
+469
View File
@@ -0,0 +1,469 @@
about
above
abuse
actor
acute
admit
adopt
adult
after
again
agent
agree
ahead
alarm
album
alert
alien
align
alike
alive
allow
alone
along
alter
among
angel
anger
angle
angry
apart
apple
apply
arena
argue
arise
array
aside
asset
audio
audit
avoid
award
aware
badly
baker
bases
basic
beach
began
begin
being
below
bench
birth
black
blame
blind
block
blood
board
brain
brand
bread
break
breed
brief
bring
broad
broke
brown
build
buyer
cable
carry
catch
cause
chain
chair
chart
chase
cheap
check
chest
chief
child
china
chose
civil
claim
class
clean
clear
clerk
click
clock
close
coach
coast
could
count
court
cover
craft
crash
cream
crime
cross
crowd
crown
curve
cycle
daily
dance
dealt
death
debut
delay
depth
doing
doubt
dozen
draft
drama
drawn
dream
dress
drill
drink
drive
drove
dying
eager
early
earth
eight
elite
empty
enemy
enjoy
enter
entry
equal
error
event
every
exact
exist
extra
faith
false
fault
fiber
field
fifth
fifty
fight
final
first
fixed
flash
fleet
floor
fluid
focus
force
forth
forty
forum
found
frame
frank
fraud
fresh
front
fruit
fully
funny
giant
given
glass
globe
going
grace
grade
grand
grant
grass
great
green
gross
group
grown
guard
guess
guest
guide
happy
heart
heavy
hence
horse
hotel
house
human
ideal
image
index
inner
input
issue
joint
judge
known
label
large
laser
later
laugh
layer
learn
lease
least
leave
legal
level
light
limit
local
logic
loose
lower
lucky
lunch
major
maker
march
match
maybe
mayor
media
metal
might
minor
model
money
month
moral
motor
mount
mouse
mouth
movie
music
needs
never
newly
night
noise
north
noted
novel
nurse
occur
ocean
offer
often
order
other
ought
paint
panel
paper
party
peace
phase
phone
photo
piece
pilot
pitch
place
plain
plane
plant
plate
point
pound
power
press
price
pride
prime
print
prior
prize
proof
proud
prove
queen
quick
quiet
quite
radio
raise
range
rapid
ratio
reach
ready
refer
right
rival
river
robot
rough
round
route
royal
rural
scale
scene
scope
score
sense
serve
seven
shall
shape
share
sharp
sheet
shelf
shell
shift
shirt
shock
shoot
short
shown
sight
since
sixth
sixty
sized
skill
sleep
slide
small
smart
smile
solid
solve
sorry
sound
south
space
spare
speak
speed
spend
spent
split
spoke
sport
staff
stage
stake
stand
start
state
steam
steel
stick
still
stock
stone
stood
store
storm
story
strip
study
stuff
style
sugar
suite
super
sweet
table
taken
taste
taxes
teach
teeth
thank
their
theme
there
these
thick
thing
think
third
those
three
throw
tight
times
tired
title
today
topic
total
touch
tough
tower
track
trade
train
treat
trend
trial
tried
tries
truck
truly
trust
truth
twice
under
union
unity
until
upper
upset
urban
usage
usual
valid
value
video
virus
visit
vital
voice
waste
watch
water
wheel
where
which
while
white
whole
whose
woman
women
world
worry
worse
worst
worth
would
write
wrong
wrote
yield
young
youth
+3
View File
@@ -0,0 +1,3 @@
XTEINK-CATALOG|1
HomeAssistant|Home Assistant status cards|HomeAssistant/manifest.txt|177|27aa1f0edc78a5aeded413caf0508788fa597ee8f76a5b8515ec0c40c955d5be
Wordle|Guess the five-letter word in six tries|Wordle/manifest.txt|177|9977b49ffdbd67a254701b4fa38407fc9128a017673e3ce73560dea08a6a125e
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784796856,
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+15
View File
@@ -0,0 +1,15 @@
{
description = "Xteink AppStore repository";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system: {
devShells.default = nixpkgs.legacyPackages.${system}.mkShell {
packages = with nixpkgs.legacyPackages.${system}; [ git uv ];
};
});
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import argparse
import hashlib
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
DESCRIPTION_PATTERN = re.compile(r"^--\s*DESCRIPTION:\s*(.+?)\s*$")
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def payload_files(app_dir: Path):
for path in sorted(app_dir.rglob("*")):
if path.is_symlink():
raise ValueError(f"symlinks are not supported: {path.relative_to(ROOT)}")
if path.is_file() and path.name != "manifest.txt":
yield path
def description(app_dir: Path) -> str:
with (app_dir / "main.lua").open(encoding="utf-8") as source:
for line in source:
match = DESCRIPTION_PATTERN.match(line)
if match:
value = match.group(1)
if "|" in value:
raise ValueError(f"description contains '|': {app_dir.name}")
return value
raise ValueError(f"missing DESCRIPTION in {app_dir.name}/main.lua")
def build_manifest(app_dir: Path) -> bytes:
lines = ["XTEINK-MANIFEST|1"]
for path in payload_files(app_dir):
relative = path.relative_to(app_dir).as_posix()
if "|" in relative:
raise ValueError(f"path contains '|': {relative}")
data = path.read_bytes()
lines.append(f"{relative}|{len(data)}|{sha256(data)}")
return ("\n".join(lines) + "\n").encode()
def generated_files():
apps = [path for path in ROOT.iterdir() if path.is_dir() and (path / "main.lua").is_file()]
apps.sort(key=lambda path: path.name.casefold())
manifests = {}
catalog = ["XTEINK-CATALOG|1"]
for app_dir in apps:
app_id = app_dir.name
if not ID_PATTERN.fullmatch(app_id):
raise ValueError(f"invalid app id: {app_id}")
manifest = build_manifest(app_dir)
manifest_path = f"{app_id}/manifest.txt"
manifests[ROOT / manifest_path] = manifest
catalog.append(
f"{app_id}|{description(app_dir)}|{manifest_path}|{len(manifest)}|{sha256(manifest)}"
)
manifests[ROOT / "catalog.txt"] = ("\n".join(catalog) + "\n").encode()
return manifests
def main() -> int:
parser = argparse.ArgumentParser(description="Regenerate app manifests and catalog.txt")
parser.add_argument("--check", action="store_true", help="fail instead of updating stale files")
args = parser.parse_args()
stale = []
for path, expected in generated_files().items():
if not path.exists() or path.read_bytes() != expected:
stale.append(path)
if not args.check:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_bytes(expected)
temporary.replace(path)
print(f"updated {path.relative_to(ROOT)}")
if args.check and stale:
for path in stale:
print(f"stale: {path.relative_to(ROOT)}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except ValueError as error:
print(error, file=sys.stderr)
raise SystemExit(1)