# App Store Platform-Aligned Upload Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Rebuild `appstore` around the UnionTech app store's real submission model so one release can upload multiple `deb` or Linglong packages with per-package system-line and baseline targeting.

**Architecture:** Replace the current flat `apps + releases` workbook contract with a normalized `apps + releases + packages + targets` manifest, add a capability-cache sync step for store-native system-line and baseline metadata, and move grouped release validation/payload assembly into dedicated modules. Keep the current login/session/upload foundation, but split package inspection, capability sync, manifest parsing, validation, payload building, CLI orchestration, and examples into separate focused files.

**Tech Stack:** Python 3, `unittest`, `openpyxl`, `requests`, existing `pyppeteer` login flow, Debian metadata parsing via `dpkg-deb`, local Linglong metadata parsing, xlsx example generation.

---

## File Structure

- Modify: `appstore/models.py`
  Defines normalized workbook records, inspected package metadata, capability cache data, validated submission groups, and report rows.
- Modify: `appstore/manifest.py`
  Parses the new `apps / releases / packages / targets` workbook schema and rejects the legacy release-sheet shape.
- Modify: `appstore/deb.py`
  Keeps Debian inspection authoritative and adapts its return type to the new shared package metadata model.
- Create: `appstore/linglong.py`
  Parses `.uab` and `layer` package metadata from Linglong artifacts.
- Create: `appstore/inspectors.py`
  Dispatches package inspection by `package_family` and `package_format`.
- Create: `appstore/capabilities.py`
  Stores cache models, normalizes store capability payloads, reads and writes cache files, and validates raw store ids.
- Modify: `appstore/appstore_client.py`
  Adds authenticated capability-fetch helpers and package-info detection helpers while keeping login and upload behavior.
- Create: `appstore/submission.py`
  Groups workbook rows into releases, validates package family and targeting rules, and builds grouped payloads with multi-package `app_origin_pkgs`.
- Modify: `appstore/upload_batch.py`
  Converts the CLI into `sync-capabilities`, `validate`, `upload`, and `generate-template` subcommands and routes through the new grouped pipeline.
- Modify: `appstore/examples/generate_template.py`
  Generates the normalized workbook and sample rows for the new schema.
- Modify: `appstore/README.md`
  Documents the new workbook sheets, capability sync flow, validate/upload commands, and the store rules around Debian versus Linglong releases.
- Modify: `appstore/tests/test_manifest.py`
  Replaces legacy schema tests with normalized workbook coverage.
- Modify: `appstore/tests/test_deb.py`
  Adapts Debian inspector tests to the shared package metadata model.
- Create: `appstore/tests/test_linglong.py`
  Covers `.uab` and `layer` metadata parsing and unsupported-format failures.
- Create: `appstore/tests/test_capabilities.py`
  Covers capability normalization, cache reads and writes, and raw store-id validation.
- Create: `appstore/tests/test_submission.py`
  Covers release grouping, mixed-family rejection, target validation, and multi-package payload output.
- Modify: `appstore/tests/test_upload_batch.py`
  Rebuilds CLI and orchestration tests around the new subcommands and grouped release execution.
- Modify: `appstore/tests/test_examples.py`
  Validates the new template workbook shape and generated sample assets.

### Task 1: Normalize Workbook Records And Manifest Parsing

**Files:**
- Modify: `appstore/models.py`
- Modify: `appstore/manifest.py`
- Test: `appstore/tests/test_manifest.py`

- [ ] **Step 1: Write the failing manifest tests for the new schema**

```python
class ManifestLoaderTests(unittest.TestCase):
    def test_load_manifest_reads_apps_releases_packages_and_targets(self) -> None:
        with TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            (root / "assets").mkdir()
            (root / "packages").mkdir()
            for name in ("icon.png", "shot-1.png", "shot-2.png", "shot-3.png"):
                (root / "assets" / name).write_bytes(b"asset")
            (root / "packages" / "demo_1.0.0_amd64.deb").write_bytes(b"deb-bytes")

            workbook = Workbook()
            apps = workbook.active
            apps.title = "apps"
            apps.append(
                [
                    "app_key",
                    "app_name_zh",
                    "pkg_name",
                    "category_id",
                    "website",
                    "short_desc_zh",
                    "full_desc_zh",
                    "icon_path",
                    "screenshot_1",
                    "screenshot_2",
                    "screenshot_3",
                ]
            )
            apps.append(
                [
                    "demo",
                    "演示应用",
                    "demo",
                    1,
                    "https://example.invalid/demo",
                    "简短介绍",
                    "详细介绍",
                    "assets/icon.png",
                    "assets/shot-1.png",
                    "assets/shot-2.png",
                    "assets/shot-3.png",
                ]
            )

            releases = workbook.create_sheet("releases")
            releases.append(["enabled", "app_key", "release_key", "release_name", "region", "note"])
            releases.append(["Y", "demo", "rel-1", "主线发布", "1", ""])

            packages = workbook.create_sheet("packages")
            packages.append(
                [
                    "enabled",
                    "app_key",
                    "release_key",
                    "package_key",
                    "package_family",
                    "package_format",
                    "file_path",
                    "declared_arch",
                    "pkg_channel",
                    "note",
                ]
            )
            packages.append(["Y", "demo", "rel-1", "pkg-amd64", "deb", "deb", "packages/demo_1.0.0_amd64.deb", "amd64", "", ""])

            targets = workbook.create_sheet("targets")
            targets.append(
                [
                    "enabled",
                    "app_key",
                    "release_key",
                    "package_key",
                    "sup_sys_code",
                    "baseline_id",
                    "unsupport_baseline_ids",
                    "target_note",
                ]
            )
            targets.append(["Y", "demo", "rel-1", "pkg-amd64", "11", "2300", "2301,2302", ""])

            workbook_path = root / "manifest.xlsx"
            workbook.save(workbook_path)

            manifest = load_manifest(workbook_path)

            self.assertEqual(set(manifest.apps), {"demo"})
            self.assertEqual(set(manifest.releases), {("demo", "rel-1")})
            self.assertEqual(len(manifest.packages[("demo", "rel-1")]), 1)
            self.assertEqual(len(manifest.targets[("demo", "rel-1", "pkg-amd64")]), 1)

    def test_load_manifest_rejects_legacy_release_columns(self) -> None:
        with TemporaryDirectory() as tmpdir:
            workbook = Workbook()
            apps = workbook.active
            apps.title = "apps"
            apps.append(["app_key", "app_name_zh", "pkg_name", "category_id", "website", "short_desc_zh", "full_desc_zh", "icon_path", "screenshot_1", "screenshot_2", "screenshot_3"])
            apps.append(["demo", "演示应用", "demo", 1, "https://example.invalid/demo", "简短介绍", "详细介绍", "icon.png", "shot-1.png", "shot-2.png", "shot-3.png"])
            releases = workbook.create_sheet("releases")
            releases.append(["enabled", "app_key", "deb_path", "system_platform", "arch"])
            workbook.create_sheet("packages").append(["enabled", "app_key", "release_key", "package_key", "package_family", "package_format", "file_path"])
            workbook.create_sheet("targets").append(["enabled", "app_key", "release_key", "package_key", "sup_sys_code"])
            workbook_path = Path(tmpdir) / "legacy.xlsx"
            workbook.save(workbook_path)
            with self.assertRaisesRegex(ManifestError, "legacy releases schema unsupported"):
                load_manifest(workbook_path)
```

- [ ] **Step 2: Run the manifest tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_manifest -v`

Expected: FAIL because `models.py` and `manifest.py` do not yet expose `packages`, `targets`, normalized release keys, or legacy-schema rejection.

- [ ] **Step 3: Implement normalized records and manifest loading**

```python
@dataclass(frozen=True)
class ReleaseRecord:
    row_id: int
    app_key: str
    release_key: str
    release_name: str
    region: str = ""
    note: str = ""


@dataclass(frozen=True)
class PackageRecord:
    row_id: int
    app_key: str
    release_key: str
    package_key: str
    package_family: str
    package_format: str
    file_path: Path
    declared_arch: str = ""
    pkg_channel: str = ""
    note: str = ""


@dataclass(frozen=True)
class TargetRecord:
    row_id: int
    app_key: str
    release_key: str
    package_key: str
    sup_sys_code: str
    baseline_id: str = ""
    unsupport_baseline_ids: tuple[str, ...] = ()
    target_note: str = ""


@dataclass(frozen=True)
class LoadedManifest:
    workbook_path: Path
    apps: dict[str, AppRecord]
    releases: dict[tuple[str, str], ReleaseRecord]
    packages: dict[tuple[str, str], tuple[PackageRecord, ...]]
    targets: dict[tuple[str, str, str], tuple[TargetRecord, ...]]
```

```python
def load_manifest(workbook_path: Path | str) -> LoadedManifest:
    workbook = load_workbook(workbook_path)
    for required_sheet in ("apps", "releases", "packages", "targets"):
        if required_sheet not in workbook.sheetnames:
            raise ManifestError(f"missing {required_sheet} sheet")

    release_headers = _sheet_headers(workbook["releases"])
    if {"deb_path", "system_platform", "arch"} & release_headers:
        raise ManifestError("legacy releases schema unsupported; use releases/packages/targets sheets")

    releases: dict[tuple[str, str], ReleaseRecord] = {}
    packages: dict[tuple[str, str], list[PackageRecord]] = {}
    targets: dict[tuple[str, str, str], list[TargetRecord]] = {}

    for row_id, row in _sheet_rows(workbook["releases"]):
        if not _normalize_enabled(row.get("enabled")):
            continue
        record = ReleaseRecord(
            row_id=row_id,
            app_key=str(_require(row, "app_key", row_id)),
            release_key=str(_require(row, "release_key", row_id)),
            release_name=str(_require(row, "release_name", row_id)),
            region="" if row.get("region") is None else str(row.get("region")),
            note="" if row.get("note") is None else str(row.get("note")),
        )
        key = (record.app_key, record.release_key)
        if key in releases:
            raise ManifestError(f"duplicate release_key in row {row_id}: {record.release_key}")
        releases[key] = record

    for row_id, row in _sheet_rows(workbook["packages"]):
        if not _normalize_enabled(row.get("enabled")):
            continue
        package = PackageRecord(
            row_id=row_id,
            app_key=str(_require(row, "app_key", row_id)),
            release_key=str(_require(row, "release_key", row_id)),
            package_key=str(_require(row, "package_key", row_id)),
            package_family=str(_require(row, "package_family", row_id)).strip().lower(),
            package_format=str(_require(row, "package_format", row_id)).strip().lower(),
            file_path=_resolve_path(Path(workbook_path), _require(row, "file_path", row_id)),
            declared_arch="" if row.get("declared_arch") is None else str(row.get("declared_arch")),
            pkg_channel="" if row.get("pkg_channel") is None else str(row.get("pkg_channel")),
            note="" if row.get("note") is None else str(row.get("note")),
        )
        release_key = (package.app_key, package.release_key)
        if release_key not in releases:
            raise ManifestError(f"unknown release in packages row {row_id}: {package.release_key}")
        packages.setdefault(release_key, []).append(package)

    for row_id, row in _sheet_rows(workbook["targets"]):
        if not _normalize_enabled(row.get("enabled")):
            continue
        target = TargetRecord(
            row_id=row_id,
            app_key=str(_require(row, "app_key", row_id)),
            release_key=str(_require(row, "release_key", row_id)),
            package_key=str(_require(row, "package_key", row_id)),
            sup_sys_code=str(_require(row, "sup_sys_code", row_id)),
            baseline_id="" if row.get("baseline_id") is None else str(row.get("baseline_id")),
            unsupport_baseline_ids=tuple(
                token.strip() for token in str(row.get("unsupport_baseline_ids", "")).split(",") if token.strip()
            ),
            target_note="" if row.get("target_note") is None else str(row.get("target_note")),
        )
        targets.setdefault((target.app_key, target.release_key, target.package_key), []).append(target)

    return LoadedManifest(
        workbook_path=Path(workbook_path),
        apps=apps,
        releases=releases,
        packages={key: tuple(value) for key, value in packages.items()},
        targets={key: tuple(value) for key, value in targets.items()},
    )
```

- [ ] **Step 4: Run the manifest tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_manifest -v`

Expected: PASS with the new workbook schema loaded and the legacy schema rejected explicitly.

- [ ] **Step 5: Commit**

```bash
git add appstore/models.py appstore/manifest.py appstore/tests/test_manifest.py
git commit -m "feat: normalize appstore workbook schema"
```

### Task 2: Add Shared Package Inspection For Debian And Linglong

**Files:**
- Modify: `appstore/models.py`
- Modify: `appstore/deb.py`
- Create: `appstore/linglong.py`
- Create: `appstore/inspectors.py`
- Modify: `appstore/tests/test_deb.py`
- Test: `appstore/tests/test_linglong.py`

- [ ] **Step 1: Write the failing inspector tests**

```python
class LinglongInspectorTests(unittest.TestCase):
    def test_read_uab_package_info_reads_name_version_and_arch(self) -> None:
        metadata = {
            "layers": [
                {
                    "info": {
                        "kind": "app",
                        "id": "org.demo.App",
                        "version": "1.2.3",
                        "arch": ["x86_64"],
                    }
                }
            ]
        }
        with patch("appstore.linglong._read_linglong_metadata", return_value=metadata):
            info = read_uab_package_info(Path("/tmp/demo.uab"))
        self.assertEqual(info.pkg_name, "org.demo.App")
        self.assertEqual(info.pkg_version, "1.2.3")
        self.assertEqual(info.pkg_arch, "x86_64")
        self.assertEqual(info.package_family, "linglong")
        self.assertEqual(info.package_format, "uab")

    def test_read_layer_package_info_rejects_missing_app_layer(self) -> None:
        metadata = {"layers": [{"info": {"kind": "runtime"}}]}
        with patch("appstore.linglong._read_linglong_metadata", return_value=metadata):
            with self.assertRaisesRegex(LinglongParseError, "application layer"):
                read_layer_package_info(Path("/tmp/demo.layer"))
```

```python
class PackageInspectorDispatchTests(unittest.TestCase):
    def test_inspect_package_dispatches_by_family_and_format(self) -> None:
        package = PackageRecord(
            row_id=3,
            app_key="demo",
            release_key="rel-1",
            package_key="pkg-uab",
            package_family="linglong",
            package_format="uab",
            file_path=Path("/tmp/demo.uab"),
        )
        with patch("appstore.inspectors.read_uab_package_info") as mock_read:
            inspect_package(package)
        mock_read.assert_called_once_with(Path("/tmp/demo.uab"))
```

- [ ] **Step 2: Run the inspector tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_deb appstore.tests.test_linglong -v`

Expected: FAIL because Linglong parsing and generic inspector dispatch do not exist yet.

- [ ] **Step 3: Implement shared package metadata and inspectors**

```python
@dataclass(frozen=True)
class PackageInfo:
    pkg_name: str
    pkg_version: str
    pkg_arch: str
    pkg_size: int
    sha256: str
    file_path: Path
    package_family: str
    package_format: str
```

```python
class LinglongParseError(RuntimeError):
    pass


def _extract_app_layer(metadata: dict) -> dict:
    layers = metadata.get("layers") or []
    app_layers = [layer for layer in layers if ((layer.get("info") or {}).get("kind") == "app")]
    if len(app_layers) != 1:
        raise LinglongParseError("expected exactly one application layer")
    return app_layers[0]["info"]


def read_uab_package_info(file_path: Path | str) -> PackageInfo:
    path = Path(file_path)
    metadata = _read_linglong_metadata(path)
    info = _extract_app_layer(metadata)
    size, sha256 = _hash_file(path)
    return PackageInfo(
        pkg_name=str(info["id"]),
        pkg_version=str(info["version"]),
        pkg_arch=str((info.get("arch") or [""])[0]),
        pkg_size=size,
        sha256=sha256,
        file_path=path,
        package_family="linglong",
        package_format="uab",
    )


def read_layer_package_info(file_path: Path | str) -> PackageInfo:
    path = Path(file_path)
    metadata = _read_linglong_metadata(path)
    info = _extract_app_layer(metadata)
    size, sha256 = _hash_file(path)
    return PackageInfo(
        pkg_name=str(info["id"]),
        pkg_version=str(info["version"]),
        pkg_arch=str((info.get("arch") or [""])[0]),
        pkg_size=size,
        sha256=sha256,
        file_path=path,
        package_family="linglong",
        package_format="layer",
    )
```

```python
def inspect_package(package: PackageRecord) -> PackageInfo:
    if package.package_family == "deb" and package.package_format == "deb":
        return read_deb_package_info(package.file_path)
    if package.package_family == "linglong" and package.package_format == "uab":
        return read_uab_package_info(package.file_path)
    if package.package_family == "linglong" and package.package_format == "layer":
        return read_layer_package_info(package.file_path)
    raise ValueError(f"unsupported package family/format: {package.package_family}/{package.package_format}")
```

- [ ] **Step 4: Run the inspector tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_deb appstore.tests.test_linglong -v`

Expected: PASS with Debian inspection unchanged in behavior and Linglong inspection returning normalized `PackageInfo`.

- [ ] **Step 5: Commit**

```bash
git add appstore/models.py appstore/deb.py appstore/linglong.py appstore/inspectors.py appstore/tests/test_deb.py appstore/tests/test_linglong.py
git commit -m "feat: inspect deb and linglong package metadata"
```

### Task 3: Add Capability Sync And Cache Models

**Files:**
- Modify: `appstore/models.py`
- Modify: `appstore/appstore_client.py`
- Create: `appstore/capabilities.py`
- Test: `appstore/tests/test_capabilities.py`
- Modify: `appstore/tests/test_client.py`

- [ ] **Step 1: Write the failing capability tests**

```python
class CapabilityCacheTests(unittest.TestCase):
    def test_write_and_load_capability_cache_round_trip(self) -> None:
        cache = CapabilityCache(
            generated_at="2026-04-22T12:00:00+08:00",
            deb_system_lines={"11": SystemLine(code="11", label="communityV23", family="deb")},
            linglong_system_lines={"21": SystemLine(code="21", label="communityV25", family="linglong")},
            baseline_options={
                "deb:11": (BaselineOption(system_line_code="11", baseline_id="2300", minor_version="23.0.0"),),
                "linglong:21": (BaselineOption(system_line_code="21", baseline_id="2500", minor_version="25.0.0"),),
            },
        )
        with TemporaryDirectory() as tmpdir:
            cache_dir = Path(tmpdir)
            write_capability_cache(cache_dir, cache)
            loaded = load_capability_cache(cache_dir / "latest.json")
        self.assertEqual(loaded.baseline_options["linglong:21"][0].baseline_id, "2500")

    def test_normalize_linglong_system_lines_uses_raw_store_codes(self) -> None:
        payload = {
            "datas": [
                {"dictLabel": "communityV23", "dictValue": "11"},
                {"dictLabel": "professionalV25", "dictValue": "22"},
            ],
            "status": 200,
        }
        cache = normalize_linglong_system_lines(payload)
        self.assertEqual(sorted(cache.keys()), ["11", "22"])
```

```python
class ClientCapabilityTests(unittest.TestCase):
    def test_fetch_linglong_system_lines_calls_store_dictionary_endpoint(self) -> None:
        session = FakeSession(
            responses=[
                _FakeResponse(json_data={"datas": [{"dictLabel": "communityV23", "dictValue": "11"}], "status": 200}),
            ]
        )
        client = AppStoreClient(session=session)
        rows = client.fetch_linglong_system_lines()
        self.assertEqual(rows[0]["dictValue"], "11")
        self.assertIn("/system/dict/data/type/linglong_app_sup_sys", session.calls[0]["url"])
```

- [ ] **Step 2: Run the capability tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_capabilities appstore.tests.test_client -v`

Expected: FAIL because cache models, capability readers, and capability-fetch client methods do not exist yet.

- [ ] **Step 3: Implement capability cache and fetch helpers**

```python
@dataclass(frozen=True)
class SystemLine:
    code: str
    label: str
    family: str


@dataclass(frozen=True)
class BaselineOption:
    system_line_code: str
    baseline_id: str
    minor_version: str


@dataclass(frozen=True)
class CapabilityCache:
    generated_at: str
    deb_system_lines: dict[str, SystemLine]
    linglong_system_lines: dict[str, SystemLine]
    baseline_options: dict[str, tuple[BaselineOption, ...]]
```

```python
class AppStoreClient:
    def fetch_linglong_system_lines(self) -> list[dict]:
        response = self.session.get(
            f"{BASE}/system/dict/data/type/linglong_app_sup_sys",
            timeout=REQUEST_TIMEOUT,
        )
        payload = self._ensure_ok(response, "fetch_linglong_system_lines")
        return list(payload.get("datas", []))

    def fetch_adapt_info(self) -> dict:
        response = self.session.get(
            f"{BASE}/store-dev-app/adapt-info/",
            timeout=REQUEST_TIMEOUT,
        )
        payload = self._ensure_ok(response, "fetch_adapt_info")
        return payload
```

```python
def normalize_linglong_system_lines(payload: dict) -> dict[str, SystemLine]:
    return {
        str(row["dictValue"]): SystemLine(
            code=str(row["dictValue"]),
            label=str(row["dictLabel"]),
            family="linglong",
        )
        for row in payload.get("datas", [])
    }


def normalize_deb_system_lines(adapt_info: dict) -> dict[str, SystemLine]:
    rows = adapt_info.get("datas", {}).get("systemPlatformList", [])
    return {
        str(row["code"]): SystemLine(
            code=str(row["code"]),
            label=str(row["name"]),
            family="deb",
        )
        for row in rows
    }


def normalize_baseline_options(adapt_info: dict) -> dict[str, tuple[BaselineOption, ...]]:
    options: dict[str, list[BaselineOption]] = {}
    for row in adapt_info.get("datas", {}).get("shopVersionList", []):
        family = "linglong" if int(row.get("pkgInstallMode", 1)) == 2 else "deb"
        key = f"{family}:{row['system_platform']}"
        options.setdefault(key, []).append(
            BaselineOption(
                system_line_code=str(row["system_platform"]),
                baseline_id=str(row["id"]),
                minor_version=str(row["minor_version"]),
            )
        )
    return {key: tuple(value) for key, value in options.items()}


def write_capability_cache(cache_dir: Path, cache: CapabilityCache) -> Path:
    cache_dir.mkdir(parents=True, exist_ok=True)
    timestamp_path = cache_dir / f"{cache.generated_at.replace(':', '-')}.json"
    latest_path = cache_dir / "latest.json"
    payload = asdict(cache)
    timestamp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    latest_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    return latest_path


def sync_capabilities_to_cache(client: AppStoreClient, cache_dir: Path) -> Path:
    linglong_rows = client.fetch_linglong_system_lines()
    adapt_info = client.fetch_adapt_info()
    cache = CapabilityCache(
        generated_at=datetime.now().astimezone().isoformat(timespec="seconds"),
        deb_system_lines=normalize_deb_system_lines(adapt_info),
        linglong_system_lines=normalize_linglong_system_lines({"datas": linglong_rows, "status": 200}),
        baseline_options=normalize_baseline_options(adapt_info),
    )
    return write_capability_cache(cache_dir, cache)
```

- [ ] **Step 4: Run the capability tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_capabilities appstore.tests.test_client -v`

Expected: PASS with authenticated capability fetches and stable cache serialization.

- [ ] **Step 5: Commit**

```bash
git add appstore/models.py appstore/appstore_client.py appstore/capabilities.py appstore/tests/test_capabilities.py appstore/tests/test_client.py
git commit -m "feat: sync and cache appstore capabilities"
```

### Task 4: Group Releases, Validate Targets, And Build Multi-Package Payloads

**Files:**
- Create: `appstore/submission.py`
- Modify: `appstore/appstore_client.py`
- Test: `appstore/tests/test_submission.py`

- [ ] **Step 1: Write the failing submission tests**

```python
class SubmissionBuilderTests(unittest.TestCase):
    def test_validate_release_group_rejects_mixed_package_families(self) -> None:
        app = AppRecord(
            app_key="demo",
            app_name_zh="演示应用",
            pkg_name="demo",
            category_id=1,
            website="https://example.invalid/demo",
            short_desc_zh="简短介绍",
            full_desc_zh="详细介绍",
            icon_path=Path("/tmp/icon.png"),
            screenshot_paths=(Path("/tmp/shot-1.png"), Path("/tmp/shot-2.png"), Path("/tmp/shot-3.png")),
        )
        release = ReleaseRecord(row_id=2, app_key="demo", release_key="rel-1", release_name="主线发布")
        packages = (
            PackageRecord(3, "demo", "rel-1", "pkg-deb", "deb", "deb", Path("/tmp/demo.deb")),
            PackageRecord(4, "demo", "rel-1", "pkg-uab", "linglong", "uab", Path("/tmp/demo.uab")),
        )
        targets_by_package = {
            "pkg-deb": (TargetRecord(5, "demo", "rel-1", "pkg-deb", "11", "2300"),),
            "pkg-uab": (TargetRecord(6, "demo", "rel-1", "pkg-uab", "21", "2500"),),
        }
        inspected_by_package = {
            "pkg-deb": PackageInfo("demo", "1.0.0", "amd64", 10, "aaa", Path("/tmp/demo.deb"), "deb", "deb"),
            "pkg-uab": PackageInfo("demo", "1.0.0", "x86_64", 10, "bbb", Path("/tmp/demo.uab"), "linglong", "uab"),
        }
        capability_cache = CapabilityCache(
            generated_at="2026-04-22T12:00:00+08:00",
            deb_system_lines={"11": SystemLine("11", "communityV23", "deb")},
            linglong_system_lines={"21": SystemLine("21", "communityV25", "linglong")},
            baseline_options={
                "deb:11": (BaselineOption("11", "2300", "23.0.0"),),
                "linglong:21": (BaselineOption("21", "2500", "25.0.0"),),
            },
        )
        with self.assertRaisesRegex(ValidationError, "cannot mix deb and linglong"):
            validate_release_group(app, release, packages, targets_by_package, inspected_by_package, capability_cache)

    def test_build_release_payload_emits_multiple_app_origin_pkgs(self) -> None:
        app = AppRecord(
            app_key="demo",
            app_name_zh="演示应用",
            pkg_name="demo",
            category_id=1,
            website="https://example.invalid/demo",
            short_desc_zh="简短介绍",
            full_desc_zh="详细介绍",
            icon_path=Path("/tmp/icon.png"),
            screenshot_paths=(Path("/tmp/shot-1.png"), Path("/tmp/shot-2.png"), Path("/tmp/shot-3.png")),
        )
        release = ReleaseRecord(row_id=2, app_key="demo", release_key="rel-1", release_name="主线发布")
        validated_release = ValidatedRelease(
            app=app,
            release=release,
            package_family="deb",
            packages=(
                ValidatedPackage(
                    package=PackageRecord(3, "demo", "rel-1", "pkg-amd64", "deb", "deb", Path("/tmp/demo-amd64.deb")),
                    package_info=PackageInfo("demo", "1.0.0", "amd64", 10, "aaa", Path("/tmp/demo-amd64.deb"), "deb", "deb"),
                    targets=(
                        TargetRecord(5, "demo", "rel-1", "pkg-amd64", "11", "2300"),
                        TargetRecord(6, "demo", "rel-1", "pkg-amd64", "12", "2301"),
                    ),
                ),
                ValidatedPackage(
                    package=PackageRecord(4, "demo", "rel-1", "pkg-arm64", "deb", "deb", Path("/tmp/demo-arm64.deb")),
                    package_info=PackageInfo("demo", "1.0.1", "arm64", 10, "bbb", Path("/tmp/demo-arm64.deb"), "deb", "deb"),
                    targets=(TargetRecord(7, "demo", "rel-1", "pkg-arm64", "21", "2500"),),
                ),
            ),
        )
        uploads_by_package = {
            "pkg-amd64": UploadedFileRef("temppkg", "pkg-key-1", 10, "aaa"),
            "pkg-arm64": UploadedFileRef("temppkg", "pkg-key-2", 10, "bbb"),
        }
        app_uploads = {
            "icon": UploadedFileRef("icon", "icon-key", 10, ""),
            "screenshots": (
                UploadedFileRef("image", "shot-1", 10, ""),
                UploadedFileRef("image", "shot-2", 10, ""),
                UploadedFileRef("image", "shot-3", 10, ""),
            ),
        }
        payload = build_release_payload(validated_release, uploads_by_package, app_uploads, target_app_id="42")
        self.assertEqual(payload["app_id"], "42")
        self.assertEqual(len(payload["app_info"]["app_origin_pkgs"]), 2)
        self.assertEqual(payload["app_info"]["app_origin_pkgs"][0]["supSys"], "11,12")
        self.assertEqual(payload["app_info"]["app_origin_pkgs"][1]["supBlineVer"], "2500")
```

- [ ] **Step 2: Run the submission tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_submission -v`

Expected: FAIL because grouped release validation and multi-package payload assembly do not exist yet.

- [ ] **Step 3: Implement grouped validation and payload construction**

```python
@dataclass(frozen=True)
class ValidatedPackage:
    package: PackageRecord
    package_info: PackageInfo
    targets: tuple[TargetRecord, ...]


@dataclass(frozen=True)
class ValidatedRelease:
    app: AppRecord
    release: ReleaseRecord
    package_family: str
    packages: tuple[ValidatedPackage, ...]


def validate_release_group(
    app: AppRecord,
    release: ReleaseRecord,
    packages: tuple[PackageRecord, ...],
    targets_by_package: dict[str, tuple[TargetRecord, ...]],
    inspected_by_package: dict[str, PackageInfo],
    capability_cache: CapabilityCache,
) -> ValidatedRelease:
    families = {package.package_family for package in packages}
    if len(families) != 1:
        raise ValidationError("release cannot mix deb and linglong packages")
    package_family = next(iter(families))

    validated_packages: list[ValidatedPackage] = []
    seen_arch_system_version: set[tuple[str, str, str]] = set()
    for package in packages:
        package_info = inspected_by_package[package.package_key]
        if package_info.pkg_name != app.pkg_name:
            raise ValidationError(f"package name mismatch for {package.package_key}: {package_info.pkg_name}")
        if package.declared_arch and package_info.pkg_arch != package.declared_arch:
            raise ValidationError(f"declared arch mismatch for {package.package_key}")
        targets = targets_by_package.get(package.package_key, ())
        if not targets:
            raise ValidationError(f"package has no targets: {package.package_key}")
        for target in targets:
            assert_target_supported(package_family, target, capability_cache)
            collision_key = (package_info.pkg_arch, target.sup_sys_code, package_info.pkg_version)
            if collision_key in seen_arch_system_version:
                raise ValidationError("same architecture/system/version collision in release")
            seen_arch_system_version.add(collision_key)
        validated_packages.append(ValidatedPackage(package=package, package_info=package_info, targets=targets))

    return ValidatedRelease(app=app, release=release, package_family=package_family, packages=tuple(validated_packages))
```

```python
ARCH_CODE_MAP = {
    "amd64": ("4", "X86"),
    "x86_64": ("4", "X86"),
    "arm64": ("3", "ARM"),
    "loong64": ("6", "loong"),
    "sw64": ("8", "sw64"),
}

PKG_TYPE_MAP = {
    ("deb", "deb"): 11,
    ("linglong", "uab"): 22,
    ("linglong", "layer"): 22,
}


def target_display_label(package_family: str, target: TargetRecord) -> str:
    if target.baseline_id:
        return f"{target.sup_sys_code}:{target.baseline_id}"
    return target.sup_sys_code


def build_release_payload(
    validated_release: ValidatedRelease,
    uploads_by_package: dict[str, UploadedFileRef],
    app_uploads: dict[str, UploadedFileRef | tuple[UploadedFileRef, ...]],
    target_app_id: str,
) -> dict:
    origin_pkgs = []
    for index, package in enumerate(validated_release.packages):
        sup_sys_codes = [target.sup_sys_code for target in package.targets]
        baseline_ids = [target.baseline_id for target in package.targets if target.baseline_id]
        unsupported_ids = [baseline_id for target in package.targets for baseline_id in target.unsupport_baseline_ids]
        pkg_arch_code, pkg_arch_label = ARCH_CODE_MAP[package.package_info.pkg_arch]
        system_labels = [target_display_label(validated_release.package_family, target) for target in package.targets]
        origin_pkgs.append(
            {
                "pkg_name": package.package_info.pkg_name,
                "pkg_version": package.package_info.pkg_version,
                "pkg_arch": pkg_arch_code,
                "pkgArch": pkg_arch_label,
                "pkgType": PKG_TYPE_MAP[(package.package.package_family, package.package.package_format)],
                "pkg_mode": 0,
                "pkgChannel": package.package.pkg_channel or None,
                "pkg_size": package.package_info.pkg_size,
                "sha256": package.package_info.sha256,
                "file_save_key": uploads_by_package[package.package.package_key].file_save_key,
                "progressPercent": 100,
                "index": index,
                "system_platform": sup_sys_codes,
                "supSys": ",".join(sup_sys_codes),
                "baseline": baseline_ids,
                "supBlineVer": ",".join(baseline_ids),
                "unsupportBaseline": unsupported_ids,
                "unsupportBlineVers": ",".join(unsupported_ids),
                "systemStr": " ".join(system_labels),
            }
        )
    payload = {
        "operate_type": 52,
        "app_info": {
            "app_lan_infos": [
                {
                    "lan": "zh_CN",
                    "label": "中文（简体）",
                    "lanStr": "中文（简体）",
                    "name": validated_release.app.app_name_zh,
                    "brief_info": validated_release.app.short_desc_zh,
                    "desc_info": validated_release.app.full_desc_zh,
                    "icon_save_key": app_uploads["icon"].file_save_key,
                    "appScreenShotList": [
                        {"screen_shot_key": shot.file_save_key, "image_mode": 1, "size": shot.size, "sort": sort}
                        for sort, shot in enumerate(app_uploads["screenshots"])
                    ],
                }
            ],
            "app_basic_info": {
                "default_lan": "zh_CN",
                "pkg_mode": 0,
                "pkgInstallMode": 1 if validated_release.package_family == "deb" else 2,
                "inAppPayment": 0,
                "category_id": validated_release.app.category_id,
                "website": validated_release.app.website,
                "region": validated_release.release.region or "1",
            },
            "app_fit_info": {
                "system_mode": [{"code": 1}],
                "baseline": [],
                "unsupportBaseline": [],
                "system_platform": [],
                "region": [{"code": int(validated_release.release.region or '1')}],
                "arch": [],
                "cpu_clip": [],
                "motherboard": [],
                "supWayland": 0,
            },
            "app_origin_pkgs": origin_pkgs,
        },
    }
    if target_app_id:
        payload["app_id"] = target_app_id
    return payload
```

- [ ] **Step 4: Run the submission tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_submission -v`

Expected: PASS with grouped multi-package payloads and store-rule validation failures where expected.

- [ ] **Step 5: Commit**

```bash
git add appstore/submission.py appstore/appstore_client.py appstore/tests/test_submission.py
git commit -m "feat: validate grouped releases and build multi-package payloads"
```

### Task 5: Refactor CLI Into Capability Sync, Validate, Upload, And Template Commands

**Files:**
- Modify: `appstore/upload_batch.py`
- Modify: `appstore/tests/test_upload_batch.py`

- [ ] **Step 1: Write the failing CLI orchestration tests**

```python
class UploadBatchCliTests(unittest.TestCase):
    def test_main_sync_capabilities_writes_cache_and_exits_zero(self) -> None:
        with patch("appstore.upload_batch.sync_capabilities_to_cache") as sync_mock:
            exit_code = main(["sync-capabilities", "--cache-dir", "/tmp/cache"])
        self.assertEqual(exit_code, 0)
        sync_mock.assert_called_once()

    def test_main_validate_reports_grouped_release_failures(self) -> None:
        with TemporaryDirectory() as tmpdir:
            output_dir = Path(tmpdir) / "report"
            exit_code = main(["validate", "appstore/examples/template.xlsx", "--output-dir", str(output_dir)])
            self.assertEqual(exit_code, 1)
            report = json.loads((output_dir / "report.json").read_text(encoding="utf-8"))
            self.assertEqual(report[0]["status"], "validate_failed")

    def test_main_upload_uses_grouped_release_submission(self) -> None:
        with patch("appstore.upload_batch.submit_grouped_release") as submit_mock:
            exit_code = main(["upload", "appstore/examples/template.xlsx", "--rows", "2-4"])
        self.assertEqual(exit_code, 0)
        submit_mock.assert_called()
```

- [ ] **Step 2: Run the CLI orchestration tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_upload_batch -v`

Expected: FAIL because the CLI still assumes a single-command, Debian-only path.

- [ ] **Step 3: Implement the new subcommand orchestration**

```python
def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="command", required=True)

    sync_parser = subparsers.add_parser("sync-capabilities")
    sync_parser.add_argument("--cache-dir", default="appstore/cache")

    validate_parser = subparsers.add_parser("validate")
    validate_parser.add_argument("workbook")
    validate_parser.add_argument("--cache", default="appstore/cache/latest.json")
    validate_parser.add_argument("--output-dir", default="")
    validate_parser.add_argument("--rows", default="")

    upload_parser = subparsers.add_parser("upload")
    upload_parser.add_argument("workbook")
    upload_parser.add_argument("--cache", default="appstore/cache/latest.json")
    upload_parser.add_argument("--output-dir", default="")
    upload_parser.add_argument("--rows", default="")
    upload_parser.add_argument("--dry-run", action="store_true")

    template_parser = subparsers.add_parser("generate-template")
    template_parser.add_argument("--output", default="appstore/examples/template.xlsx")
    return parser
```

```python
def validate_manifest_against_capabilities(
    manifest: LoadedManifest,
    capability_cache: CapabilityCache,
    package_inspector=inspect_package,
) -> list[RowResult]:
    results: list[RowResult] = []
    for release_key, release in manifest.releases.items():
        packages = manifest.packages.get(release_key, ())
        inspected = {package.package_key: package_inspector(package) for package in packages}
        targets = {
            package.package_key: manifest.targets.get((package.app_key, package.release_key, package.package_key), ())
            for package in packages
        }
        try:
            validate_release_group(
                manifest.apps[release.app_key],
                release,
                packages,
                targets,
                inspected,
                capability_cache,
            )
            for package in packages:
                results.append(
                    RowResult(
                        row_id=package.row_id,
                        app_key=package.app_key,
                        deb_path=package.file_path,
                        status="validated",
                        message="validated",
                        pkg_name=inspected[package.package_key].pkg_name,
                        pkg_version=inspected[package.package_key].pkg_version,
                    )
                )
        except Exception as exc:
            for package in packages:
                results.append(
                    RowResult(
                        row_id=package.row_id,
                        app_key=package.app_key,
                        deb_path=package.file_path,
                        status="validate_failed",
                        message=str(exc),
                        pkg_name=inspected.get(package.package_key).pkg_name if package.package_key in inspected else "",
                        pkg_version=inspected.get(package.package_key).pkg_version if package.package_key in inspected else "",
                    )
                )
    return results


def submit_grouped_release(
    client: AppStoreClient,
    validated_release: ValidatedRelease,
    app_uploads: dict[str, UploadedFileRef | tuple[UploadedFileRef, ...]],
) -> dict:
    uploads_by_package = {
        package.package.package_key: client.upload_file_bytes(
            filename=package.package.file_path.name,
            data=package.package.file_path.read_bytes(),
            upload_type="temppkg",
        )
        for package in validated_release.packages
    }
    payload = build_release_payload(validated_release, uploads_by_package, app_uploads, target_app_id="")
    return client.submit_payload(payload)


def run_upload_command(
    manifest: LoadedManifest,
    capability_cache: CapabilityCache,
    output_dir: Path,
    row_filter: set[int],
    dry_run: bool,
) -> int:
    validation_results = validate_manifest_against_capabilities(manifest, capability_cache)
    if any(result.status != "validated" for result in validation_results):
        _write_reports(output_dir, validation_results)
        return 1
    if dry_run:
        dry_run_results = [
            RowResult(**{**result.__dict__, "status": "dry_run", "message": "dry-run: validated locally"})
            for result in validation_results
        ]
        _write_reports(output_dir, dry_run_results)
        return 0
    username, password = _resolve_credentials(dry_run=False)
    client = AppStoreClient()
    client.login(username, password)
    grouped_results = []
    for release_key, release in manifest.releases.items():
        packages = manifest.packages.get(release_key, ())
        inspected = {package.package_key: inspect_package(package) for package in packages}
        targets = {
            package.package_key: manifest.targets.get((package.app_key, package.release_key, package.package_key), ())
            for package in packages
        }
        validated_release = validate_release_group(
            manifest.apps[release.app_key],
            release,
            packages,
            targets,
            inspected,
            capability_cache,
        )
        app = manifest.apps[release.app_key]
        app_uploads = {
            "icon": client.upload_file_bytes(
                filename=app.icon_path.name,
                data=app.icon_path.read_bytes(),
                upload_type="icon",
            ),
            "screenshots": tuple(
                client.upload_file_bytes(
                    filename=screenshot_path.name,
                    data=screenshot_path.read_bytes(),
                    upload_type="image",
                )
                for screenshot_path in app.screenshot_paths
            ),
        }
        response = submit_grouped_release(client, validated_release, app_uploads)
        for package in packages:
            grouped_results.append(
                RowResult(
                    row_id=package.row_id,
                    app_key=package.app_key,
                    deb_path=package.file_path,
                    status="submitted",
                    message="submitted",
                    app_id=str(response.get("app_id", "")),
                    pkg_name=inspected[package.package_key].pkg_name,
                    pkg_version=inspected[package.package_key].pkg_version,
                )
            )
    _write_reports(output_dir, grouped_results)
    return 0


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    if args.command == "sync-capabilities":
        username, password = _resolve_credentials(dry_run=False)
        client = AppStoreClient()
        client.login(username, password)
        sync_capabilities_to_cache(client=client, cache_dir=Path(args.cache_dir))
        return 0

    if args.command == "generate-template":
        generate_template(Path(args.output))
        return 0

    manifest = load_manifest(args.workbook)
    capability_cache = load_capability_cache(Path(args.cache))
    output_dir = Path(args.output_dir) if args.output_dir else Path("appstore/output") / _timestamp_label()

    if args.command == "validate":
        results = validate_manifest_against_capabilities(manifest=manifest, capability_cache=capability_cache)
        _write_reports(output_dir, results)
        return 0 if all(result.status == "validated" for result in results) else 1

    if args.command == "upload":
        return run_upload_command(
            manifest=manifest,
            capability_cache=capability_cache,
            output_dir=output_dir,
            row_filter=_parse_row_filter(args.rows),
            dry_run=args.dry_run,
        )

    raise RuntimeError(f"unsupported command: {args.command}")
```

- [ ] **Step 4: Run the CLI orchestration tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_upload_batch -v`

Expected: PASS with subcommands, grouped validation, and grouped upload submission.

- [ ] **Step 5: Commit**

```bash
git add appstore/upload_batch.py appstore/tests/test_upload_batch.py
git commit -m "feat: add grouped appstore cli subcommands"
```

### Task 6: Regenerate Examples And Rewrite Documentation For The New Schema

**Files:**
- Modify: `appstore/examples/generate_template.py`
- Modify: `appstore/README.md`
- Modify: `appstore/tests/test_examples.py`

- [ ] **Step 1: Write the failing example and README tests**

```python
class GenerateTemplateTests(unittest.TestCase):
    def test_generate_template_creates_four_sheet_workbook(self) -> None:
        with TemporaryDirectory() as tmpdir:
            workbook_path = Path(tmpdir) / "template.xlsx"
            subprocess.run([sys.executable, "appstore/examples/generate_template.py", str(workbook_path)], check=True)
            workbook = load_workbook(workbook_path)
            self.assertEqual(workbook.sheetnames, ["apps", "releases", "packages", "targets"])
            self.assertEqual([cell.value for cell in workbook["packages"][1]][:7], [
                "enabled",
                "app_key",
                "release_key",
                "package_key",
                "package_family",
                "package_format",
                "file_path",
            ])
```

```python
class ReadmeTests(unittest.TestCase):
    def test_readme_mentions_sync_validate_upload_and_generate_template(self) -> None:
        readme = Path("appstore/README.md").read_text(encoding="utf-8")
        self.assertIn("sync-capabilities", readme)
        self.assertIn("validate", readme)
        self.assertIn("upload", readme)
        self.assertIn("generate-template", readme)
        self.assertIn("packages", readme)
        self.assertIn("targets", readme)
```

- [ ] **Step 2: Run the example and README tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_examples -v`

Expected: FAIL because the template generator and README still describe the legacy schema.

- [ ] **Step 3: Implement the new template and documentation**

```python
APPS_HEADERS = (
    "app_key",
    "app_name_zh",
    "pkg_name",
    "category_id",
    "website",
    "short_desc_zh",
    "full_desc_zh",
    "icon_path",
    "screenshot_1",
    "screenshot_2",
    "screenshot_3",
    "keywords_zh",
    "app_id_override",
)

RELEASES_HEADERS = ("enabled", "app_key", "release_key", "release_name", "region", "note")
PACKAGES_HEADERS = (
    "enabled",
    "app_key",
    "release_key",
    "package_key",
    "package_family",
    "package_format",
    "file_path",
    "declared_arch",
    "pkg_channel",
    "note",
)
TARGETS_HEADERS = (
    "enabled",
    "app_key",
    "release_key",
    "package_key",
    "sup_sys_code",
    "baseline_id",
    "unsupport_baseline_ids",
    "target_note",
)
```

```markdown
The workbook has four sheets:

- `apps`: application metadata
- `releases`: one submission batch per app
- `packages`: one uploaded file per row
- `targets`: one package-target combination per row

Typical flow:

```bash
python3 -m appstore.upload_batch sync-capabilities
python3 -m appstore.upload_batch validate appstore/examples/template.xlsx
APPSTORE_USERNAME='your-username' APPSTORE_PASSWORD='your-password' \
python3 -m appstore.upload_batch upload appstore/examples/template.xlsx
```

Store rules enforced by the tool:

- one release cannot mix Debian and Linglong packages
- one package can target multiple systems
- system-line and baseline values must be raw store ids from the capability cache
```

- [ ] **Step 4: Run the example and README tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_examples -v`

Expected: PASS with the regenerated workbook, updated README, and example assets still present.

- [ ] **Step 5: Run the full appstore test suite and commit**

Run: `python3 -m unittest discover appstore/tests -v`
Expected: PASS with all manifest, inspector, capability, submission, CLI, and example tests green.

```bash
git add appstore/examples/generate_template.py appstore/README.md appstore/tests/test_examples.py appstore/examples/template.xlsx
git commit -m "feat: document platform-aligned appstore workflow"
```
