Files changed: - CHANGES.md - INSTALL.md - VERSION - docs/explanation/configuration-tiers.md - docs/how-to/README.md - docs/how-to/back-up-to-a-synced-folder.md - docs/reference/commands/README.md - docs/reference/commands/git.md - docs/reference/commands/instance-health.md - docs/reference/configuration.md - instructions/publish-cycle.md - instructions/setup-instance.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/backup.py - tools/chemenu/cli.py - tools/chemenu/cli_contract.py - tools/chemenu/commands/backup_cmd.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/git_publish.py - tools/chemenu/config_shapes.py - tools/chemenu/reference_doc.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_backup.py - tools/chemenu/tests/test_config_shapes.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
170 lines
8.1 KiB
Python
170 lines
8.1 KiB
Python
"""`wikitool backup init` and `backup run` - back an instance up into a synced folder.
|
|
|
|
The mechanism and what a run guarantees are `chemenu.backup`'s; this module only
|
|
turns its results and refusals into output and exit codes. `publish` runs the
|
|
same machine-data step after a push (`git_publish.publish_command`).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import typer
|
|
|
|
from chemenu import backup, cli_contract, config
|
|
from chemenu.commands._util import fail, success
|
|
from chemenu.errors import ChemenuError
|
|
|
|
app = typer.Typer(help="Back this instance up into a synced folder (OneDrive and the like).")
|
|
|
|
HOW_TO = "docs/how-to/back-up-to-a-synced-folder.md"
|
|
|
|
_SECTION_FAILURE = cli_contract.Failure(
|
|
cause=f"No `backup` section in `{config.LOCAL_SETTINGS_FILENAME}`, or a malformed one - a "
|
|
"target that is not absolute or lies inside the instance, a `machine_name` that cleans to "
|
|
"nothing or to `git`, an `extra` path that resolves outside the instance or into `.git/`",
|
|
reaction=f"Show the message to the user and stop. The section is theirs to write or fix by "
|
|
f"hand - `config set` refuses its fields; {HOW_TO} has the procedure",
|
|
)
|
|
|
|
|
|
@app.command("init")
|
|
@cli_contract.record(cli_contract.CommandRecord(
|
|
path="backup init",
|
|
summary="Create the backup target's bare repository and make it `origin` when there is none.",
|
|
synopsis=(cli_contract.Variant(usage="backup init"),),
|
|
properties=cli_contract.Properties(
|
|
effect=cli_contract.Effect.WRITE,
|
|
idempotent=cli_contract.Idempotent.YES,
|
|
atomic="Every precondition is checked first; a refusal changes nothing",
|
|
budget=cli_contract.Budget.COUNTED,
|
|
),
|
|
notes=(
|
|
"Reads the `backup` section of `.wikitool-local.json` and creates `<target>/` and "
|
|
"`<target>/git`, the latter as a bare repository (`git init --bare -b main`). An existing "
|
|
"bare repository there, or an empty directory, is used as it is.",
|
|
"Sets `origin` to `<target>/git` when the checkout has no `origin`. An `origin` that "
|
|
"already pushes there is left as it is; one that pushes anywhere else stops the command.",
|
|
"When `remotes.allowed_push_urls` exists and lacks the URL `origin` pushes to, prints the "
|
|
"`remotes` section with that URL added, for the user to copy into "
|
|
"`.wikitool-local.json`. It never writes that file: the field is a gate.",
|
|
"Pushes nothing: the next `publish` sends the history to the new `origin`.",
|
|
),
|
|
failures=(
|
|
_SECTION_FAILURE,
|
|
cli_contract.Failure(
|
|
cause="`<target>/git` exists and is not a bare repository; nothing was changed",
|
|
reaction="Show the message to the user - whether that folder moves, or the target "
|
|
"does, is their decision",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="`origin` already pushes to another URL; the message names both and nothing was "
|
|
"changed",
|
|
reaction="Show both URLs to the user and stop. Never repoint `origin` yourself - "
|
|
"`git remote set-url` is theirs to run",
|
|
),
|
|
),
|
|
examples=("tools/wikitool backup init",),
|
|
never=(
|
|
"Never add the printed URL to `remotes.allowed_push_urls` yourself - that field is a gate, "
|
|
"and editing it is the user's.",
|
|
),
|
|
see_also=(
|
|
"`wikitool backup run` - the machine-data half of the backup",
|
|
"`wikitool publish` - pushes to the bare repository and runs the machine-data backup "
|
|
"after it",
|
|
f"`{HOW_TO}` - the whole procedure, restoring included",
|
|
),
|
|
))
|
|
def init_command():
|
|
"""Create the backup target's bare repository and make it `origin` when there is none."""
|
|
try:
|
|
cfg = backup.require_config()
|
|
result = backup.init(cfg)
|
|
except ChemenuError as exc:
|
|
fail(str(exc))
|
|
if result.created_repo:
|
|
typer.echo(f"Created the bare repository {result.git_dir}.")
|
|
else:
|
|
typer.echo(f"{result.git_dir} is already a bare repository - left as it is.")
|
|
if result.set_origin:
|
|
typer.echo(f"Set origin to {result.git_dir}.")
|
|
if result.allowlist_entry:
|
|
typer.echo(
|
|
"remotes.allowed_push_urls does not list this push target, so `publish` will stop at "
|
|
"the Publish-Remote Gate. THE USER CANNOT SEE THIS OUTPUT - show them this section, to "
|
|
f"put into {config.LOCAL_SETTINGS_FILENAME} themselves:\n{result.allowlist_entry}"
|
|
)
|
|
success("Backup target ready; the next `publish` pushes to it.")
|
|
|
|
|
|
@app.command("run")
|
|
@cli_contract.record(cli_contract.CommandRecord(
|
|
path="backup run",
|
|
summary="Mirror this machine's files that are not in git into `<target>/<machine>/`.",
|
|
synopsis=(cli_contract.Variant(usage="backup run"),),
|
|
properties=cli_contract.Properties(
|
|
effect=cli_contract.Effect.WRITE,
|
|
idempotent=cli_contract.Idempotent.YES,
|
|
atomic="Per file - each is written to a temporary file and renamed, the manifest last; an "
|
|
"interrupted run leaves whole files and the previous manifest",
|
|
budget=cli_contract.Budget.COUNTED,
|
|
),
|
|
notes=(
|
|
"Mirrors `.wikitool-local.json`, `ENVIRONMENT.md` if present, every file in `incoming/` "
|
|
"but `.gitkeep`, and the `extra` paths of the `backup` section into "
|
|
"`<target>/<machine>/`, then writes the manifest `.chemenu-backup.json` there: time, "
|
|
"stack version, machine name, and per file its relative path, size and sha256.",
|
|
"Never mirrors `mcp-upload/`, `reports/`, `.wikitool-tools.json`, `tools/.venv/`, the "
|
|
"published skills or `.git/` - unless an `extra` path names one of the first ones. The "
|
|
"secrets file lies outside the instance and is never read.",
|
|
"Writes and deletes only under `<target>/<machine>/`. Deletes only a file the previous "
|
|
"manifest lists and the instance no longer has; a file there the manifest does not list "
|
|
"stays as it is. A file whose size and sha256 match the previous manifest is not "
|
|
"rewritten.",
|
|
"A symbolic link or junction inside a mirrored directory is skipped and named in the "
|
|
"output, never followed.",
|
|
"`publish` runs the same step after every push when `machine_data` is true.",
|
|
),
|
|
failures=(
|
|
_SECTION_FAILURE,
|
|
cli_contract.Failure(
|
|
cause="`machine_data` is false",
|
|
reaction="Show the message to the user; turning it on is their edit of "
|
|
"`.wikitool-local.json`",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="`<target>/<machine>/` is not empty and has no manifest, or its manifest cannot "
|
|
"be read; nothing was written",
|
|
reaction="Show the message to the user - the folder is not one this backup wrote. "
|
|
"They set another `machine_name` or empty it",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="The target is not a directory (the synced folder is missing or not mounted), "
|
|
"or a write failed",
|
|
reaction="Show the message to the user; once the cause is fixed, run `backup run` "
|
|
"again",
|
|
),
|
|
),
|
|
examples=("tools/wikitool backup run",),
|
|
never=(
|
|
"Never point `extra` at a path outside the instance to back something else up - the "
|
|
"command refuses it, and the secrets belong in no synced folder.",
|
|
),
|
|
see_also=(
|
|
"`wikitool backup init` - sets up the target's bare repository",
|
|
"`wikitool doctor` - the `backup` line reports the age of the last run",
|
|
f"`{HOW_TO}` - the whole procedure, restoring included",
|
|
),
|
|
))
|
|
def run_command():
|
|
"""Mirror this machine's files that are not in git into `<target>/<machine>/`."""
|
|
try:
|
|
cfg = backup.require_config()
|
|
if not cfg.machine_data:
|
|
fail(f"backup.machine_data is false in {config.LOCAL_SETTINGS_FILENAME} - there is "
|
|
"nothing to back up with `backup run`; the user sets it to true by hand")
|
|
result = backup.run(cfg)
|
|
except ChemenuError as exc:
|
|
fail(str(exc))
|
|
except OSError as exc:
|
|
fail(f"the machine-data backup failed: {exc}")
|
|
success(result.summary())
|