ic-casals 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
access_code.py ADDED
@@ -0,0 +1,50 @@
1
+ """Invite access codes — pure, standard library only.
2
+
3
+ A commander slot may be declared before anyone knows the principal that
4
+ will hold it: the sheet (``environments.<env>.principals``) or a runtime
5
+ ``set_commander`` call lists ``sha256:<hex>`` — the checksum of a secret
6
+ code — in place of a principal. Whoever presents the code to
7
+ ``claim_commander`` becomes that commander. The checksum is the only thing
8
+ ever stored; the plaintext code lives with the person it was handed to.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+
15
+ CODE_CHECKSUM_PREFIX = "sha256:"
16
+ _HEX = frozenset("0123456789abcdef")
17
+
18
+
19
+ def is_code_checksum(value) -> bool:
20
+ """True when ``value`` is written in the ``sha256:<hex>`` slot form
21
+ (regardless of whether the hex part is well-formed)."""
22
+ return isinstance(value, str) and value.strip().lower().startswith(CODE_CHECKSUM_PREFIX)
23
+
24
+
25
+ def normalize_code_checksum(value: str) -> str:
26
+ """Canonical ``sha256:<64 lowercase hex>``; raises ValueError otherwise."""
27
+ v = (value or "").strip().lower()
28
+ if not v.startswith(CODE_CHECKSUM_PREFIX):
29
+ raise ValueError(f"invalid access-code checksum {value!r}: expected {CODE_CHECKSUM_PREFIX}<64 hex>")
30
+ digest = v[len(CODE_CHECKSUM_PREFIX):]
31
+ if len(digest) != 64 or not set(digest) <= _HEX:
32
+ raise ValueError(f"invalid access-code checksum {value!r}: expected {CODE_CHECKSUM_PREFIX}<64 hex>")
33
+ return CODE_CHECKSUM_PREFIX + digest
34
+
35
+
36
+ def code_checksum(code: str) -> str:
37
+ """``sha256:<hex>`` of a code exactly as typed (surrounding whitespace stripped)."""
38
+ return CODE_CHECKSUM_PREFIX + hashlib.sha256((code or "").strip().encode("utf-8")).hexdigest()
39
+
40
+
41
+ def checksums_equal(a: str, b: str) -> bool:
42
+ """Constant-time comparison of two checksum strings."""
43
+ a_b = (a or "").encode("utf-8")
44
+ b_b = (b or "").encode("utf-8")
45
+ if len(a_b) != len(b_b):
46
+ return False
47
+ acc = 0
48
+ for x, y in zip(a_b, b_b):
49
+ acc |= x ^ y
50
+ return acc == 0
auth.py ADDED
@@ -0,0 +1,89 @@
1
+ """Permission helpers — pure, no IC-runtime dependencies.
2
+
3
+ The PERMISSIONS table and the three pure functions here form the
4
+ 'permission layer' that main.py wraps with its IC-runtime checks
5
+ (_require_admin, _require_commander, …). Keeping this layer pure lets
6
+ it be unit-tested without Basilisk or a running replica.
7
+ """
8
+
9
+ # ── Commander permissions ──────────────────────────────────────────────────
10
+ #
11
+ # A commander (a principal appointed over a section or stand) may be granted
12
+ # a subset of these capabilities. The grant is stored as a comma-separated
13
+ # string on the Section/Stand row; an empty string or "*" means "all"
14
+ # (full control), which preserves the historical "commander can do
15
+ # everything" behaviour.
16
+ #
17
+ # Each tuple is (key, human label, group) — the label/group drive the UI.
18
+
19
+ PERMISSIONS = [
20
+ ("canister.create", "Create canisters", "Canisters"),
21
+ ("canister.deploy", "Deploy / upgrade canisters", "Canisters"),
22
+ ("canister.delete", "Delete canisters", "Canisters"),
23
+ ("canister.rename", "Rename canisters", "Canisters"),
24
+ ("canister.tag", "Set canister tags", "Canisters"),
25
+ ("canister.snapshot", "Create snapshots", "Canisters"),
26
+ ("canister.revert", "Revert to snapshot", "Canisters"),
27
+ ("canister.lifecycle", "Start / stop canisters", "Canisters"),
28
+ ("canister.topup", "Top up cycles", "Canisters"),
29
+ ("canister.shell", "Run shell / exec code", "Canisters"),
30
+ ("stand.create", "Create stands", "Stand"),
31
+ ("stand.rename", "Rename stand", "Stand"),
32
+ ("stand.delete", "Delete stand", "Stand"),
33
+ ("commander.assign", "Appoint sub-commanders", "Governance"),
34
+ ("subnet.whitelist", "Manage subnet whitelist", "Platform"),
35
+ ("wasm.upload", "Upload files (WASMs, bundles) to the store", "Platform"),
36
+ ("wasm.authorize", "Authorize / revoke catalog WASMs", "Platform"),
37
+ ]
38
+ PERMISSION_KEYS = [p[0] for p in PERMISSIONS]
39
+
40
+
41
+ def _parse_permissions(stored: str) -> list:
42
+ """Resolve a stored permission string into the list of granted keys.
43
+
44
+ Empty or "*" => full access (every known permission). Otherwise the
45
+ comma-separated subset, filtered to known keys only.
46
+ """
47
+ s = (stored or "").strip()
48
+ if s == "" or s == "*":
49
+ return list(PERMISSION_KEYS)
50
+ granted = [k.strip() for k in s.split(",") if k.strip()]
51
+ return [k for k in granted if k in PERMISSION_KEYS]
52
+
53
+
54
+ def _normalize_permissions(perms) -> str:
55
+ """Turn an incoming permissions value (list or str) into the stored form.
56
+
57
+ "<group>.*" expands to every key of that group. A set covering every
58
+ permission is collapsed to "*". Unknown keys are dropped. None => ""
59
+ (full access, unchanged).
60
+ """
61
+ if perms is None:
62
+ return ""
63
+ if isinstance(perms, str):
64
+ keys = [k.strip() for k in perms.split(",") if k.strip()]
65
+ else:
66
+ keys = [str(k).strip() for k in perms if str(k).strip()]
67
+ if "*" in keys:
68
+ return "*"
69
+ for glob in [k for k in keys if k.endswith(".*")]: # "canister.*" → every canister.<x> key
70
+ keys += [k for k in PERMISSION_KEYS if k.startswith(glob[:-1])]
71
+ keys = [k for k in PERMISSION_KEYS if k in keys]
72
+ if set(keys) >= set(PERMISSION_KEYS):
73
+ return "*"
74
+ return ",".join(keys)
75
+
76
+
77
+ def _has_permission(stored: str, permission: str) -> bool:
78
+ """Return True if `stored` grants `permission` (empty permission always True)."""
79
+ if not permission:
80
+ return True
81
+ granted = _parse_permissions(stored)
82
+ if permission in granted:
83
+ return True
84
+ # Governance commanders (can appoint sub-commanders) may manage the
85
+ # platform-wide subnet whitelist even on legacy permission rows that
86
+ # pre-date the ``subnet.whitelist`` key.
87
+ if permission == "subnet.whitelist" and "commander.assign" in granted:
88
+ return True
89
+ return False
casals_cli/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Casals CLI v2 — declarative orchestra bootstrap and reconciliation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ # Keep in lockstep with [project].version in pyproject.toml (publish-pypi.yml checks).
9
+ __version__ = "0.2.0"
10
+
11
+ _ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
12
+ _SRC = os.path.join(_ROOT, "src")
13
+ if _SRC not in sys.path:
14
+ sys.path.insert(0, _SRC)
15
+ if _ROOT not in sys.path:
16
+ sys.path.insert(0, _ROOT)
17
+
18
+ __all__ = ["main", "__version__"]
casals_cli/bindings.py ADDED
@@ -0,0 +1,159 @@
1
+ """Runtime bindings: sheet name → canister id per environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+
11
+ from sheetv2 import CONDUCTOR_NAMES
12
+
13
+
14
+
15
+ def bindings_dir() -> str:
16
+ """`$CASALS_HOME` (tests, CI) or `~/.casals`."""
17
+ return os.environ.get("CASALS_HOME") or os.path.expanduser("~/.casals")
18
+
19
+
20
+ @dataclass
21
+ class Bindings:
22
+ sheet_name: str
23
+ env: str
24
+ network_url: str
25
+ deployer: str
26
+ conductor: dict[str, str] = field(default_factory=dict)
27
+ created_at: str = ""
28
+ backend_id: str = ""
29
+ icp_project_dir: str = ""
30
+ conductor_module_hashes: dict[str, str] = field(default_factory=dict)
31
+ asset_dist_hashes: dict[str, str] = field(default_factory=dict)
32
+
33
+ @property
34
+ def casals_backend_id(self) -> str:
35
+ return self.backend_id or self.conductor.get(CONDUCTOR_NAMES["backend"], "")
36
+
37
+ def path(self) -> str:
38
+ safe = self.sheet_name.replace("/", "_")
39
+ return os.path.join(bindings_dir(), f"{safe}.{self.env}.json")
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ return {
43
+ "sheet_name": self.sheet_name,
44
+ "env": self.env,
45
+ "network_url": self.network_url,
46
+ "deployer": self.deployer,
47
+ "conductor": dict(self.conductor),
48
+ "backend_id": self.backend_id or self.casals_backend_id,
49
+ "created_at": self.created_at or datetime.now(timezone.utc).isoformat(),
50
+ "icp_project_dir": self.icp_project_dir,
51
+ "conductor_module_hashes": dict(self.conductor_module_hashes),
52
+ "asset_dist_hashes": dict(self.asset_dist_hashes),
53
+ }
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: dict) -> "Bindings":
57
+ conductor = data.get("conductor") or {}
58
+ backend = (data.get("backend_id") or conductor.get(CONDUCTOR_NAMES["backend"]) or "").strip()
59
+ return cls(
60
+ sheet_name=str(data.get("sheet_name") or ""),
61
+ env=str(data.get("env") or ""),
62
+ network_url=str(data.get("network_url") or ""),
63
+ deployer=str(data.get("deployer") or ""),
64
+ conductor={k: str(v) for k, v in conductor.items()},
65
+ created_at=str(data.get("created_at") or ""),
66
+ backend_id=backend,
67
+ icp_project_dir=str(data.get("icp_project_dir") or ""),
68
+ conductor_module_hashes={
69
+ k: str(v) for k, v in (data.get("conductor_module_hashes") or {}).items()
70
+ },
71
+ asset_dist_hashes={
72
+ k: str(v) for k, v in (data.get("asset_dist_hashes") or {}).items()
73
+ },
74
+ )
75
+
76
+ def save(self) -> None:
77
+ os.makedirs(bindings_dir(), exist_ok=True)
78
+ if not self.created_at:
79
+ self.created_at = datetime.now(timezone.utc).isoformat()
80
+ with open(self.path(), "w", encoding="utf-8") as f:
81
+ json.dump(self.to_dict(), f, indent=2)
82
+ f.write("\n")
83
+
84
+ def remove(self) -> None:
85
+ path = self.path()
86
+ if os.path.exists(path):
87
+ os.remove(path)
88
+
89
+
90
+ def load_bindings(sheet_name: str, env: str) -> Bindings | None:
91
+ b = Bindings(sheet_name=sheet_name, env=env, network_url="", deployer="")
92
+ path = b.path()
93
+ if not os.path.isfile(path):
94
+ return None
95
+ with open(path, encoding="utf-8") as f:
96
+ return Bindings.from_dict(json.load(f))
97
+
98
+
99
+ def resolve_backend_id(bindings: Bindings | None, conductor_override: str | None) -> str:
100
+ if conductor_override:
101
+ return conductor_override.strip()
102
+ if bindings and bindings.casals_backend_id:
103
+ return bindings.casals_backend_id
104
+ return ""
105
+
106
+
107
+ def live_bindings(ic, sheet_name: str, env: str, conductor_override: str | None = None) -> tuple[str, dict[str, str]]:
108
+ """(backend id, name → canister id) for an orchestra: local bindings file + the
109
+ conductor's `get_bindings` (stand canisters are only known to the conductor)."""
110
+ local = load_bindings(sheet_name, env)
111
+ backend = resolve_backend_id(local, conductor_override)
112
+ if not backend:
113
+ raise RuntimeError("no conductor; run casals up or pass --conductor")
114
+ ids = dict(local.conductor) if local else {}
115
+ res = ic.query(backend, "get_bindings")
116
+ if isinstance(res, dict) and res.get("bindings"):
117
+ ids.update(res["bindings"])
118
+ ids[CONDUCTOR_NAMES["backend"]] = backend
119
+ return backend, ids
120
+
121
+
122
+ def find_bindings_for_env(env: str, conductor: str | None = None) -> Bindings | None:
123
+ """Locate a bindings file for ``env``, optionally matching ``conductor``."""
124
+ if not os.path.isdir(bindings_dir()):
125
+ return None
126
+ matches: list[Bindings] = []
127
+ suffix = f".{env}.json"
128
+ for fname in os.listdir(bindings_dir()):
129
+ if not fname.endswith(suffix):
130
+ continue
131
+ path = os.path.join(bindings_dir(), fname)
132
+ try:
133
+ with open(path, encoding="utf-8") as f:
134
+ b = Bindings.from_dict(json.load(f))
135
+ except (OSError, json.JSONDecodeError, TypeError):
136
+ continue
137
+ if conductor:
138
+ cid = conductor.strip()
139
+ if b.backend_id != cid and b.casals_backend_id != cid:
140
+ continue
141
+ matches.append(b)
142
+ if not matches:
143
+ return None
144
+ if len(matches) == 1:
145
+ return matches[0]
146
+ if conductor:
147
+ return matches[0]
148
+ raise RuntimeError(
149
+ f"multiple bindings for env {env!r}; pass --conductor or run from a named sheet"
150
+ )
151
+
152
+
153
+ def live_stands(tree: dict) -> dict[str, dict]:
154
+ """stand name → {section, members, built} from the conductor's `get_tree`
155
+ (`built` absent = an older conductor: treat the stand as finished)."""
156
+ return {st.get("name", ""): {"section": sec.get("name", ""), "members": st.get("members") or [],
157
+ "built": bool(st.get("built", True))}
158
+ for sec in (tree.get("sections") or []) if isinstance(sec, dict)
159
+ for st in sec.get("stands") or [] if isinstance(st, dict)}
casals_cli/bundle.py ADDED
@@ -0,0 +1,291 @@
1
+ """Asset bundles: a frontend's built ``dist/`` as one hashed, versioned artifact.
2
+
3
+ The spec is ``docs/BUNDLES.md``. In short:
4
+
5
+ * A bundle is a set of files (``path -> bytes``); ``path`` is relative, uses ``/``,
6
+ never starts with ``/`` or contains ``..``. ``index.html`` must be at the root.
7
+ * Every consumer — the sheet pin, the store, the conductor, the browser — agrees
8
+ on one **bundle hash**: sha256 over the text ``"<sha256> <path>\\n"`` per
9
+ file, sorted by path (the ``sha256sum`` format, so ``sha256sum -c`` can
10
+ check an unpacked bundle against its manifest). Only content files count;
11
+ the manifest itself is excluded.
12
+ * The transport is a canonical ``.tgz``: GNU tar, entries sorted by path,
13
+ mtime 0, uid/gid 0, mode 0644, no directory entries, gzip with no
14
+ timestamp or name — so the same files give the same bytes on every machine.
15
+ It carries the manifest at ``.casals-bundle.json`` (a dotfile, so it cannot
16
+ collide with a PWA's ``manifest.json``).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import gzip
22
+ import hashlib
23
+ import io
24
+ import json
25
+ import os
26
+ import tarfile
27
+ from typing import Iterable
28
+
29
+ from sheetv2 import BUNDLE_MANIFEST, bundle_hash as _shared_bundle_hash
30
+
31
+ FORMAT = "casals-bundle/1"
32
+ MANIFEST_NAME = BUNDLE_MANIFEST
33
+ INDEX = "index.html"
34
+
35
+
36
+ class BundleError(ValueError):
37
+ pass
38
+
39
+
40
+ # ── hashing ──────────────────────────────────────────────────────────────────
41
+
42
+
43
+ def sha256_hex(data: bytes) -> str:
44
+ return hashlib.sha256(data).hexdigest()
45
+
46
+
47
+ def file_hashes(files: dict[str, bytes]) -> dict[str, str]:
48
+ return {p: sha256_hex(b) for p, b in files.items() if p != MANIFEST_NAME}
49
+
50
+
51
+ def manifest_text(hashes: dict[str, str]) -> str:
52
+ """The ``sha256sum`` listing the bundle hash is taken over. Sorted by path,
53
+ one ``<sha256> <path>`` line each, trailing newline; the manifest file
54
+ itself never appears in it."""
55
+ return "".join(f"{hashes[p]} {p}\n" for p in sorted(hashes) if p != MANIFEST_NAME)
56
+
57
+
58
+ def bundle_hash(hashes: dict[str, str]) -> str:
59
+ """sha256 of :func:`manifest_text`. The one number every consumer compares;
60
+ the definition lives in ``sheetv2`` so the conductor uses the same one."""
61
+ return _shared_bundle_hash(hashes)
62
+
63
+
64
+ def manifest(hashes: dict[str, str], sizes: dict[str, int] | None = None) -> dict:
65
+ hashes = {p: h for p, h in hashes.items() if p != MANIFEST_NAME}
66
+ return {
67
+ "format": FORMAT,
68
+ "bundle_sha256": bundle_hash(hashes),
69
+ "files": {
70
+ p: ({"sha256": hashes[p], "size": sizes[p]} if sizes and p in sizes else {"sha256": hashes[p]})
71
+ for p in sorted(hashes)
72
+ },
73
+ }
74
+
75
+
76
+ # ── validation ───────────────────────────────────────────────────────────────
77
+
78
+
79
+ def check_path(path: str) -> str:
80
+ p = path.replace(os.sep, "/")
81
+ if not p or p.startswith("/") or p.startswith("./") or "\\" in p:
82
+ raise BundleError(f"bundle path must be relative and use '/': {path!r}")
83
+ parts = p.split("/")
84
+ if any(part in ("", ".", "..") for part in parts):
85
+ raise BundleError(f"bundle path may not contain '', '.' or '..' segments: {path!r}")
86
+ return p
87
+
88
+
89
+ def validate(files: dict[str, bytes]) -> None:
90
+ """A bundle is non-empty, has ``index.html`` at its root and only safe paths."""
91
+ content = {p: b for p, b in files.items() if p != MANIFEST_NAME}
92
+ if not content:
93
+ raise BundleError("bundle is empty")
94
+ for p in content:
95
+ check_path(p)
96
+ if INDEX not in content:
97
+ raise BundleError(f"bundle has no {INDEX} at its root")
98
+
99
+
100
+ # ── directories ──────────────────────────────────────────────────────────────
101
+
102
+
103
+ def read_dir(root: str) -> dict[str, bytes]:
104
+ """Every regular file under ``root`` (symlinks followed), keyed by its
105
+ forward-slash relative path, sorted. A manifest already on disk is skipped:
106
+ it is regenerated."""
107
+ if not os.path.isdir(root):
108
+ raise BundleError(f"not a directory: {root}")
109
+ out: dict[str, bytes] = {}
110
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=True):
111
+ dirnames.sort()
112
+ for fn in sorted(filenames):
113
+ full = os.path.join(dirpath, fn)
114
+ rel = check_path(os.path.relpath(full, root))
115
+ if rel == MANIFEST_NAME:
116
+ continue
117
+ with open(full, "rb") as f:
118
+ out[rel] = f.read()
119
+ return dict(sorted(out.items()))
120
+
121
+
122
+ # ── tarballs ─────────────────────────────────────────────────────────────────
123
+
124
+
125
+ def _tarinfo(name: str, size: int) -> tarfile.TarInfo:
126
+ ti = tarfile.TarInfo(name)
127
+ ti.size = size
128
+ ti.mtime = 0
129
+ ti.mode = 0o644
130
+ ti.uid = ti.gid = 0
131
+ ti.uname = ti.gname = ""
132
+ ti.type = tarfile.REGTYPE
133
+ return ti
134
+
135
+
136
+ def write_tgz(files: dict[str, bytes], include_manifest: bool = True) -> tuple[bytes, dict]:
137
+ """Canonical ``.tgz`` bytes for ``files`` plus the manifest it carries.
138
+ Deterministic: same files, same bytes."""
139
+ validate(files)
140
+ content = {p: b for p, b in files.items() if p != MANIFEST_NAME}
141
+ hashes = file_hashes(content)
142
+ man = manifest(hashes, {p: len(b) for p, b in content.items()})
143
+ raw = io.BytesIO()
144
+ with tarfile.open(fileobj=raw, mode="w", format=tarfile.GNU_FORMAT) as tar:
145
+ entries = dict(sorted(content.items()))
146
+ if include_manifest:
147
+ entries[MANIFEST_NAME] = (json.dumps(man, indent=2, sort_keys=True) + "\n").encode("utf-8")
148
+ for name in sorted(entries):
149
+ data = entries[name]
150
+ tar.addfile(_tarinfo(name, len(data)), io.BytesIO(data))
151
+ gz = io.BytesIO()
152
+ with gzip.GzipFile(filename="", mode="wb", fileobj=gz, mtime=0, compresslevel=9) as g:
153
+ g.write(raw.getvalue())
154
+ return gz.getvalue(), man
155
+
156
+
157
+ def read_tgz(data: bytes) -> tuple[dict[str, bytes], dict | None]:
158
+ """Files of a ``.tgz`` (or plain ``.tar``) bundle and its manifest if it has
159
+ one. Rejects unsafe paths and, when a manifest is present, any file whose
160
+ hash disagrees with it — a tampered or mis-packed bundle fails here, not
161
+ in a canister."""
162
+ if data[:2] == b"\x1f\x8b":
163
+ data = gzip.decompress(data)
164
+ files: dict[str, bytes] = {}
165
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:") as tar:
166
+ for member in tar:
167
+ if member.isdir():
168
+ continue
169
+ if not member.isfile():
170
+ raise BundleError(f"bundle contains a non-file entry: {member.name!r}")
171
+ name = member.name[2:] if member.name.startswith("./") else member.name
172
+ name = check_path(name) if name != MANIFEST_NAME else name
173
+ f = tar.extractfile(member)
174
+ files[name] = f.read() if f else b""
175
+ man = None
176
+ if MANIFEST_NAME in files:
177
+ try:
178
+ man = json.loads(files.pop(MANIFEST_NAME).decode("utf-8"))
179
+ except ValueError as exc:
180
+ raise BundleError(f"{MANIFEST_NAME} is not JSON: {exc}") from exc
181
+ check_manifest(files, man)
182
+ validate(files)
183
+ return dict(sorted(files.items())), man
184
+
185
+
186
+ def check_manifest(files: dict[str, bytes], man: dict) -> None:
187
+ if not isinstance(man, dict) or man.get("format") != FORMAT:
188
+ raise BundleError(f"unsupported bundle manifest format: {man.get('format') if isinstance(man, dict) else man!r}")
189
+ listed = {p: (m or {}).get("sha256", "") for p, m in (man.get("files") or {}).items()}
190
+ actual = file_hashes(files)
191
+ if listed != actual:
192
+ missing = sorted(set(listed) - set(actual))
193
+ extra = sorted(set(actual) - set(listed))
194
+ changed = sorted(p for p in set(listed) & set(actual) if listed[p] != actual[p])
195
+ raise BundleError(
196
+ "bundle does not match its manifest"
197
+ + (f"; missing {missing}" if missing else "")
198
+ + (f"; unlisted {extra}" if extra else "")
199
+ + (f"; changed {changed}" if changed else "")
200
+ )
201
+ if man.get("bundle_sha256") != bundle_hash(actual):
202
+ raise BundleError("manifest bundle_sha256 does not match its file list")
203
+
204
+
205
+ # ── sources ──────────────────────────────────────────────────────────────────
206
+
207
+
208
+ def is_bundle_file(path_or_url: str) -> bool:
209
+ p = path_or_url.split("?", 1)[0].lower()
210
+ return p.endswith((".tgz", ".tar.gz", ".tar"))
211
+
212
+
213
+ def read_bundle(path: str) -> dict[str, bytes]:
214
+ """A bundle from disk: a directory or a ``.tgz``/``.tar``. Validated."""
215
+ if os.path.isdir(path):
216
+ files = read_dir(path)
217
+ validate(files)
218
+ return files
219
+ if not os.path.isfile(path):
220
+ raise BundleError(f"bundle not found: {path}")
221
+ with open(path, "rb") as f:
222
+ files, _ = read_tgz(f.read())
223
+ return files
224
+
225
+
226
+ def sha256sum_line(digest: str, filename: str) -> str:
227
+ return f"{digest} {filename}\n"
228
+
229
+
230
+ def summary(files: dict[str, bytes]) -> dict:
231
+ hashes = file_hashes(files)
232
+ return {
233
+ "bundle_sha256": bundle_hash(hashes),
234
+ "files": len(hashes),
235
+ "bytes": sum(len(b) for p, b in files.items() if p != MANIFEST_NAME),
236
+ }
237
+
238
+
239
+ def diff(hashes_a: dict[str, str], hashes_b: dict[str, str]) -> dict[str, list[str]]:
240
+ """Paths to upload (new or changed in ``b``) and to delete (only in ``a``)
241
+ when moving a namespace or canister from bundle ``a`` to bundle ``b``."""
242
+ a = {p: h for p, h in hashes_a.items() if p != MANIFEST_NAME}
243
+ b = {p: h for p, h in hashes_b.items() if p != MANIFEST_NAME}
244
+ return {
245
+ "upload": sorted(p for p, h in b.items() if a.get(p) != h),
246
+ "delete": sorted(p for p in a if p not in b),
247
+ }
248
+
249
+
250
+ def iter_lines(hashes: dict[str, str]) -> Iterable[str]:
251
+ yield from manifest_text(hashes).splitlines()
252
+
253
+
254
+ def main(argv: list[str] | None = None) -> int:
255
+ """``python3 -m casals_cli.bundle <dist> -o <out.tgz>`` — the ``casals bundle``
256
+ command without the rest of the CLI's dependencies (stdlib only), for
257
+ product release workflows that clone Casals and pack their frontend."""
258
+ import argparse
259
+ import sys
260
+
261
+ ap = argparse.ArgumentParser(prog="casals_cli.bundle", description="pack a built frontend into a canonical hashed .tgz (docs/BUNDLES.md)")
262
+ ap.add_argument("source", help="dist directory (or, with --verify, a directory or .tgz)")
263
+ ap.add_argument("-o", "--output", default="bundle.tgz")
264
+ ap.add_argument("--manifest", default=None, help="also write the manifest JSON here")
265
+ ap.add_argument("--verify", action="store_true", help="only read and validate; print the bundle hash")
266
+ args = ap.parse_args(argv)
267
+ try:
268
+ if args.verify:
269
+ info = summary(read_bundle(args.source))
270
+ print(f"{args.source}: {info['files']} file(s), {info['bytes']} bytes, bundle sha256 {info['bundle_sha256']}")
271
+ return 0
272
+ files = read_dir(args.source) if os.path.isdir(args.source) else read_bundle(args.source)
273
+ data, man = write_tgz(files)
274
+ except BundleError as exc:
275
+ print(f"error: {exc}", file=sys.stderr)
276
+ return 1
277
+ with open(args.output, "wb") as f:
278
+ f.write(data)
279
+ with open(args.output + ".sha256", "w", encoding="utf-8") as f:
280
+ f.write(sha256sum_line(sha256_hex(data), os.path.basename(args.output)))
281
+ if args.manifest:
282
+ with open(args.manifest, "w", encoding="utf-8") as f:
283
+ json.dump(man, f, indent=2, sort_keys=True)
284
+ f.write("\n")
285
+ print(f"{args.output}: {len(man['files'])} file(s), {len(data)} bytes gzipped")
286
+ print(f" bundle sha256 {man['bundle_sha256']} (pin this in registry.publish)")
287
+ return 0
288
+
289
+
290
+ if __name__ == "__main__": # pragma: no cover
291
+ raise SystemExit(main())