# App Store Batch Upload Tool 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:** Build a repo-local `appstore/` batch uploader that reads an `xlsx` manifest, logs into the UnionTech app store once, uploads app assets and Debian packages through the backend API, and writes per-row result reports.

**Architecture:** Keep the tool as a small Python package under `appstore/` with clear boundaries: manifest parsing and validation, Debian package metadata extraction, store-specific API and payload mapping, and batch orchestration/reporting. Use `pyppeteer` only to establish a real authenticated browser session, then reuse exported cookies and token-like storage values inside a `requests.Session` for all batch API calls.

**Tech Stack:** Python 3, `openpyxl`, `requests`, `pyppeteer`, stdlib `unittest`, `dpkg-deb`, Git.

---

## File Structure

- Create: `appstore/__init__.py`
  Purpose: package marker and shared version export if needed later.
- Create: `appstore/models.py`
  Purpose: dataclasses for app rows, release rows, parsed `.deb` metadata, uploaded files, and per-row results.
- Create: `appstore/manifest.py`
  Purpose: read `xlsx`, validate sheet and column contracts, resolve relative paths, and build normalized model objects.
- Create: `appstore/deb.py`
  Purpose: parse package metadata with `dpkg-deb`, compute checksums, and return normalized package info.
- Create: `appstore/platforms.py`
  Purpose: map workbook `system_platform` and `arch` values to the store payload codes and labels.
- Create: `appstore/appstore_client.py`
  Purpose: browser login, cookie export, requests session creation, app lookup, file upload, and payload submission helpers.
- Create: `appstore/upload_batch.py`
  Purpose: CLI entrypoint, row-by-row orchestration, continue-on-row-failure policy, and report writing.
- Create: `appstore/examples/generate_template.py`
  Purpose: generate the committed `template.xlsx` workbook with the required `apps` and `releases` sheets.
- Create: `appstore/examples/template.xlsx`
  Purpose: operator-facing workbook template.
- Create: `appstore/examples/assets/icon.png`
  Purpose: placeholder example icon so the generated workbook passes local validation.
- Create: `appstore/examples/assets/shot-1.png`
  Purpose: placeholder example screenshot so the generated workbook passes local validation.
- Create: `appstore/examples/assets/shot-2.png`
  Purpose: placeholder example screenshot so the generated workbook passes local validation.
- Create: `appstore/examples/assets/shot-3.png`
  Purpose: placeholder example screenshot so the generated workbook passes local validation.
- Create: `appstore/examples/packages/labelnova_1.0.4-1_amd64.deb`
  Purpose: placeholder example package so the generated workbook passes local validation.
- Create: `appstore/README.md`
  Purpose: document setup, workbook format, CLI usage, and rerun workflow.
- Create: `appstore/requirements.txt`
  Purpose: pin runtime dependencies used by the uploader.
- Create: `appstore/tests/__init__.py`
  Purpose: make unittest discovery predictable.
- Create: `appstore/tests/test_manifest.py`
  Purpose: cover workbook parsing, path resolution, duplicate keys, and disabled-row behavior.
- Create: `appstore/tests/test_deb.py`
  Purpose: cover `dpkg-deb` parsing and checksum extraction.
- Create: `appstore/tests/test_client.py`
  Purpose: cover app matching, payload building, session creation, and upload flow with mocks.
- Create: `appstore/tests/test_upload_batch.py`
  Purpose: cover batch control flow, auth-fatal behavior, row-failure continuation, and report writing.
- Create: `appstore/tests/test_examples.py`
  Purpose: cover workbook template generation.

Repository note:

- `debian/changelog` is already modified before this work starts. Do not stage or commit it as part of any task below.

### Task 1: Lock The Workbook Contract With Failing Tests

**Files:**
- Create: `appstore/requirements.txt`
- Create: `appstore/tests/__init__.py`
- Create: `appstore/tests/test_manifest.py`
- Test: `appstore/tests/test_manifest.py`

- [ ] **Step 1: Add the runtime dependency list and test-package marker**

Create `appstore/requirements.txt` with:

```text
openpyxl>=3.1,<4
requests>=2.31,<3
pyppeteer>=2,<3
```

Create `appstore/tests/__init__.py` with:

```python
"""Tests for the appstore batch upload tool."""
```

- [ ] **Step 2: Write the failing manifest tests**

Create `appstore/tests/test_manifest.py` with:

```python
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory

from openpyxl import Workbook

from appstore.manifest import ManifestError, load_manifest


class ManifestLoaderTests(unittest.TestCase):
    def _write_valid_workbook(self, root: Path) -> Path:
        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",
                "keywords_zh",
                "app_id_override",
            ]
        )
        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",
                "deb_path",
                "system_platform",
                "arch",
                "region",
                "baseline",
                "note",
            ]
        )
        releases.append(["Y", "demo", "packages/demo_1.0.0_amd64.deb", "Deepin_23", "amd64", "", "", ""])
        releases.append(["N", "demo", "packages/missing-disabled.deb", "Deepin_23", "amd64", "", "", ""])

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

    def test_load_manifest_resolves_relative_paths_and_skips_disabled_rows(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")

            manifest = load_manifest(self._write_valid_workbook(root))

            self.assertEqual(set(manifest.apps.keys()), {"demo"})
            self.assertEqual(len(manifest.releases), 1)
            self.assertEqual(
                manifest.apps["demo"].icon_path,
                root / "assets" / "icon.png",
            )
            self.assertEqual(
                manifest.releases[0].deb_path,
                root / "packages" / "demo_1.0.0_amd64.deb",
            )

    def test_load_manifest_rejects_duplicate_app_keys(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_path = self._write_valid_workbook(root)
            workbook = Workbook()
            workbook = None
            from openpyxl import load_workbook

            loaded = load_workbook(workbook_path)
            apps = loaded["apps"]
            apps.append(
                [
                    "demo",
                    "重复应用",
                    "demo",
                    1,
                    "https://example.invalid/dup",
                    "重复",
                    "重复",
                    "assets/icon.png",
                    "assets/shot-1.png",
                    "assets/shot-2.png",
                    "assets/shot-3.png",
                    "",
                    "",
                ]
            )
            loaded.save(workbook_path)

            with self.assertRaises(ManifestError):
                load_manifest(workbook_path)


if __name__ == "__main__":
    unittest.main()
```

- [ ] **Step 3: Install dependencies and run the test to verify it fails**

Run:

```bash
python3 -m pip install -r appstore/requirements.txt
python3 -m unittest appstore.tests.test_manifest -v
```

Expected: FAIL with `ModuleNotFoundError` for `appstore.manifest` or missing `load_manifest` symbols.

- [ ] **Step 4: Keep only the failing-test scaffold in git**

Run:

```bash
git add appstore/requirements.txt appstore/tests/__init__.py appstore/tests/test_manifest.py
git commit -m "test: define app store workbook manifest contract"
```

Expected: commit succeeds without staging `debian/changelog`.

### Task 2: Implement Manifest Models And Workbook Loading

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

- [ ] **Step 1: Add the normalized manifest models**

Create `appstore/models.py` with:

```python
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class AppRecord:
    app_key: str
    app_name_zh: str
    pkg_name: str
    category_id: int
    website: str
    short_desc_zh: str
    full_desc_zh: str
    icon_path: Path
    screenshot_paths: tuple[Path, Path, Path]
    keywords_zh: str = ""
    app_id_override: str = ""


@dataclass(frozen=True)
class ReleaseRecord:
    row_id: int
    app_key: str
    deb_path: Path
    system_platform: str
    arch: str
    region: str = ""
    baseline: str = ""
    note: str = ""


@dataclass(frozen=True)
class LoadedManifest:
    workbook_path: Path
    apps: dict[str, AppRecord]
    releases: list[ReleaseRecord]
```

Create `appstore/__init__.py` with:

```python
"""App store batch upload tool package."""
```

- [ ] **Step 2: Implement the workbook loader**

Create `appstore/manifest.py` with:

```python
from pathlib import Path

from openpyxl import load_workbook

from appstore.models import AppRecord, LoadedManifest, ReleaseRecord

REQUIRED_APP_COLUMNS = (
    "app_key",
    "app_name_zh",
    "pkg_name",
    "category_id",
    "website",
    "short_desc_zh",
    "full_desc_zh",
    "icon_path",
    "screenshot_1",
    "screenshot_2",
    "screenshot_3",
)
REQUIRED_RELEASE_COLUMNS = ("enabled", "app_key", "deb_path", "system_platform", "arch")


class ManifestError(ValueError):
    pass


def _normalize_enabled(value: object) -> bool:
    return str(value or "").strip().lower() in {"y", "yes", "1", "true"}


def _resolve_path(workbook_path: Path, raw_value: object) -> Path:
    candidate = (workbook_path.parent / str(raw_value).strip()).resolve()
    if not candidate.is_file():
        raise ManifestError(f"missing file: {candidate}")
    return candidate


def _sheet_rows(sheet) -> list[dict[str, object]]:
    rows = list(sheet.iter_rows(values_only=True))
    if not rows:
        return []
    headers = [str(cell or "").strip() for cell in rows[0]]
    return [dict(zip(headers, row, strict=False)) for row in rows[1:] if any(cell not in (None, "") for cell in row)]


def load_manifest(workbook_path: str | Path) -> LoadedManifest:
    workbook_path = Path(workbook_path).resolve()
    workbook = load_workbook(workbook_path)
    try:
        apps_sheet = workbook["apps"]
        releases_sheet = workbook["releases"]
    except KeyError as exc:
        raise ManifestError(f"missing sheet: {exc}") from exc

    app_rows = _sheet_rows(apps_sheet)
    release_rows = _sheet_rows(releases_sheet)

    apps: dict[str, AppRecord] = {}
    for row in app_rows:
        for column in REQUIRED_APP_COLUMNS:
            if not str(row.get(column, "")).strip():
                raise ManifestError(f"missing apps.{column}")
        app_key = str(row["app_key"]).strip()
        if app_key in apps:
            raise ManifestError(f"duplicate app_key: {app_key}")
        apps[app_key] = AppRecord(
            app_key=app_key,
            app_name_zh=str(row["app_name_zh"]).strip(),
            pkg_name=str(row["pkg_name"]).strip(),
            category_id=int(row["category_id"]),
            website=str(row["website"]).strip(),
            short_desc_zh=str(row["short_desc_zh"]).strip(),
            full_desc_zh=str(row["full_desc_zh"]).strip(),
            icon_path=_resolve_path(workbook_path, row["icon_path"]),
            screenshot_paths=(
                _resolve_path(workbook_path, row["screenshot_1"]),
                _resolve_path(workbook_path, row["screenshot_2"]),
                _resolve_path(workbook_path, row["screenshot_3"]),
            ),
            keywords_zh=str(row.get("keywords_zh", "") or "").strip(),
            app_id_override=str(row.get("app_id_override", "") or "").strip(),
        )

    releases: list[ReleaseRecord] = []
    for row_number, row in enumerate(release_rows, start=2):
        if not _normalize_enabled(row.get("enabled")):
            continue
        for column in REQUIRED_RELEASE_COLUMNS:
            if not str(row.get(column, "")).strip():
                raise ManifestError(f"missing releases.{column} at row {row_number}")
        app_key = str(row["app_key"]).strip()
        if app_key not in apps:
            raise ManifestError(f"unknown app_key {app_key} at row {row_number}")
        releases.append(
            ReleaseRecord(
                row_id=row_number,
                app_key=app_key,
                deb_path=_resolve_path(workbook_path, row["deb_path"]),
                system_platform=str(row["system_platform"]).strip(),
                arch=str(row["arch"]).strip(),
                region=str(row.get("region", "") or "").strip(),
                baseline=str(row.get("baseline", "") or "").strip(),
                note=str(row.get("note", "") or "").strip(),
            )
        )

    return LoadedManifest(workbook_path=workbook_path, apps=apps, releases=releases)
```

- [ ] **Step 3: Remove the stray workbook-noise line from the test**

Delete these lines from `appstore/tests/test_manifest.py` because they are scaffolding noise, not behavior:

```python
            workbook = Workbook()
            workbook = None
```

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

Run:

```bash
python3 -m unittest appstore.tests.test_manifest -v
```

Expected: PASS with two passing tests.

- [ ] **Step 5: Commit**

Run:

```bash
git add appstore/__init__.py appstore/models.py appstore/manifest.py appstore/tests/test_manifest.py
git commit -m "feat: load app store workbook manifests"
```

### Task 3: Lock And Implement Debian Package Metadata Extraction

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

- [ ] **Step 1: Write the failing `.deb` parsing tests**

Create `appstore/tests/test_deb.py` with:

```python
import hashlib
import subprocess
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import Mock, patch

from appstore.deb import DebParseError, read_deb_package_info


class DebMetadataTests(unittest.TestCase):
    @patch("appstore.deb.subprocess.run")
    def test_read_deb_package_info_uses_dpkg_deb_output_and_sha256(self, run_mock: Mock) -> None:
        with TemporaryDirectory() as tmpdir:
            deb_path = Path(tmpdir) / "demo_1.0.0_amd64.deb"
            deb_bytes = b"demo package bytes"
            deb_path.write_bytes(deb_bytes)
            run_mock.return_value = Mock(stdout="demo\n1.0.0-1\namd64\n", returncode=0)

            info = read_deb_package_info(deb_path)

            self.assertEqual(info.pkg_name, "demo")
            self.assertEqual(info.pkg_version, "1.0.0-1")
            self.assertEqual(info.pkg_arch, "amd64")
            self.assertEqual(info.pkg_size, len(deb_bytes))
            self.assertEqual(info.sha256, hashlib.sha256(deb_bytes).hexdigest())
            run_mock.assert_called_once_with(
                ["dpkg-deb", "-f", str(deb_path), "Package", "Version", "Architecture"],
                capture_output=True,
                check=True,
                text=True,
            )

    @patch("appstore.deb.subprocess.run")
    def test_read_deb_package_info_wraps_dpkg_failures(self, run_mock: Mock) -> None:
        with TemporaryDirectory() as tmpdir:
            deb_path = Path(tmpdir) / "broken.deb"
            deb_path.write_bytes(b"broken")
            run_mock.side_effect = subprocess.CalledProcessError(2, ["dpkg-deb"], stderr="bad archive")

            with self.assertRaises(DebParseError):
                read_deb_package_info(deb_path)


if __name__ == "__main__":
    unittest.main()
```

- [ ] **Step 2: Run the test to verify it fails**

Run:

```bash
python3 -m unittest appstore.tests.test_deb -v
```

Expected: FAIL with `ModuleNotFoundError` for `appstore.deb`.

- [ ] **Step 3: Add the package metadata model and implementation**

Append this dataclass to `appstore/models.py`:

```python
@dataclass(frozen=True)
class DebPackageInfo:
    pkg_name: str
    pkg_version: str
    pkg_arch: str
    pkg_size: int
    sha256: str
    deb_path: Path
```

Create `appstore/deb.py` with:

```python
import hashlib
import subprocess
from pathlib import Path

from appstore.models import DebPackageInfo


class DebParseError(RuntimeError):
    pass


def read_deb_package_info(deb_path: str | Path) -> DebPackageInfo:
    deb_path = Path(deb_path).resolve()
    try:
        result = subprocess.run(
            ["dpkg-deb", "-f", str(deb_path), "Package", "Version", "Architecture"],
            capture_output=True,
            check=True,
            text=True,
        )
    except subprocess.CalledProcessError as exc:
        raise DebParseError(exc.stderr or str(exc)) from exc

    package, version, architecture = [line.strip() for line in result.stdout.splitlines()[:3]]
    deb_bytes = deb_path.read_bytes()
    return DebPackageInfo(
        pkg_name=package,
        pkg_version=version,
        pkg_arch=architecture,
        pkg_size=len(deb_bytes),
        sha256=hashlib.sha256(deb_bytes).hexdigest(),
        deb_path=deb_path,
    )
```

- [ ] **Step 4: Run the `.deb` tests to verify green**

Run:

```bash
python3 -m unittest appstore.tests.test_deb -v
```

Expected: PASS with two passing tests.

- [ ] **Step 5: Commit**

Run:

```bash
git add appstore/models.py appstore/deb.py appstore/tests/test_deb.py
git commit -m "feat: parse deb package metadata for uploads"
```

### Task 4: Lock App Matching And Payload Mapping

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

- [ ] **Step 1: Write the failing client decision tests**

Create `appstore/tests/test_client.py` with:

```python
import unittest
from pathlib import Path

from appstore.appstore_client import AppLookupAmbiguousError, build_submit_payload, choose_target_app_id
from appstore.models import AppRecord, DebPackageInfo, ReleaseRecord, UploadedFileRef


class ClientDecisionTests(unittest.TestCase):
    def setUp(self) -> None:
        self.app = AppRecord(
            app_key="labelnova",
            app_name_zh="LabelNova",
            pkg_name="labelnova",
            category_id=1,
            website="https://mm.md/p/",
            short_desc_zh="简短介绍",
            full_desc_zh="详细介绍",
            icon_path=Path("/tmp/icon.png"),
            screenshot_paths=(Path("/tmp/1.png"), Path("/tmp/2.png"), Path("/tmp/3.png")),
            keywords_zh="标签,条码,打印",
            app_id_override="",
        )
        self.release = ReleaseRecord(
            row_id=2,
            app_key="labelnova",
            deb_path=Path("/tmp/labelnova_1.0.4-1_amd64.deb"),
            system_platform="Deepin_23",
            arch="amd64",
            region="1",
            baseline="",
            note="",
        )
        self.package = DebPackageInfo(
            pkg_name="labelnova",
            pkg_version="1.0.4-1",
            pkg_arch="amd64",
            pkg_size=2865688,
            sha256="50cfc999f2edef83d7ca2a765175342c83155aaad87de11f34cad5ddabe9a078",
            deb_path=self.release.deb_path,
        )
        self.uploads = {
            "icon": UploadedFileRef(kind="icon", file_save_key="icon-key", size=512, file_hash="abc"),
            "screenshots": (
                UploadedFileRef(kind="image", file_save_key="shot-1", size=111, file_hash="1"),
                UploadedFileRef(kind="image", file_save_key="shot-2", size=222, file_hash="2"),
                UploadedFileRef(kind="image", file_save_key="shot-3", size=333, file_hash="3"),
            ),
            "package": UploadedFileRef(kind="temppkg", file_save_key="pkg-key", size=2865688, file_hash="md5"),
        }

    def test_choose_target_app_id_prefers_override(self) -> None:
        app_id = choose_target_app_id([{"app_id": 42}], override="1096227")
        self.assertEqual(app_id, "1096227")

    def test_choose_target_app_id_rejects_ambiguous_matches(self) -> None:
        with self.assertRaises(AppLookupAmbiguousError):
            choose_target_app_id([{"app_id": 1}, {"app_id": 2}], override="")

    def test_build_submit_payload_uses_store_codes_and_deb_metadata(self) -> None:
        payload = build_submit_payload(
            app=self.app,
            release=self.release,
            package_info=self.package,
            uploads=self.uploads,
            target_app_id="1096227",
        )

        self.assertEqual(payload["operate_type"], 52)
        self.assertEqual(payload["app_id"], "1096227")
        self.assertEqual(payload["app_info"]["app_basic_info"]["category_id"], 1)
        self.assertEqual(payload["app_info"]["app_fit_info"]["system_platform"], [{"code": 11}])
        self.assertEqual(payload["app_info"]["app_fit_info"]["arch"], [{"code": "4"}])
        self.assertEqual(payload["app_info"]["app_origin_pkgs"][0]["pkg_version"], "1.0.4-1")
        self.assertEqual(payload["app_info"]["app_origin_pkgs"][0]["pkg_arch"], "4")
        self.assertEqual(payload["app_info"]["app_origin_pkgs"][0]["file_save_key"], "pkg-key")


if __name__ == "__main__":
    unittest.main()
```

- [ ] **Step 2: Run the client test to verify it fails**

Run:

```bash
python3 -m unittest appstore.tests.test_client -v
```

Expected: FAIL because `appstore.appstore_client`, `UploadedFileRef`, and the payload helpers do not exist yet.

- [ ] **Step 3: Add the store-code mapping and payload helpers**

Append this dataclass to `appstore/models.py`:

```python
@dataclass(frozen=True)
class UploadedFileRef:
    kind: str
    file_save_key: str
    size: int
    file_hash: str
```

Create `appstore/platforms.py` with:

```python
from dataclasses import dataclass


@dataclass(frozen=True)
class StorePlatform:
    manifest_value: str
    code: int
    pkg_type: int
    system_label: str
    sup_sys: str


@dataclass(frozen=True)
class StoreArch:
    manifest_value: str
    code: str
    label: str


SYSTEM_PLATFORMS = {
    "deepin_23": StorePlatform("Deepin_23", 11, 11, " 社区版V23", "11"),
}

ARCHITECTURES = {
    "amd64": StoreArch("amd64", "4", "X86"),
    "x86_64": StoreArch("amd64", "4", "X86"),
}


def resolve_store_platform(value: str) -> StorePlatform:
    return SYSTEM_PLATFORMS[value.strip().lower()]


def resolve_store_arch(value: str) -> StoreArch:
    return ARCHITECTURES[value.strip().lower()]
```

Create `appstore/appstore_client.py` with:

```python
from datetime import datetime

from appstore.models import AppRecord, DebPackageInfo, ReleaseRecord, UploadedFileRef
from appstore.platforms import resolve_store_arch, resolve_store_platform


class AppLookupAmbiguousError(RuntimeError):
    pass


def choose_target_app_id(matches: list[dict], override: str) -> str:
    if override:
        return override
    if not matches:
        return ""
    if len(matches) > 1:
        raise AppLookupAmbiguousError("multiple app matches found; set app_id_override")
    return str(matches[0]["app_id"])


def _upload_time_label() -> str:
    now = datetime.now()
    return f"{now.year}-{now.month}-{now.day}  {now.hour}:{now.minute}:{now.second}  "


def build_submit_payload(
    *,
    app: AppRecord,
    release: ReleaseRecord,
    package_info: DebPackageInfo,
    uploads: dict,
    target_app_id: str,
) -> dict:
    platform = resolve_store_platform(release.system_platform)
    arch = resolve_store_arch(release.arch)
    screenshots = uploads["screenshots"]
    package_upload = uploads["package"]
    icon_upload = uploads["icon"]
    payload = {
        "operate_type": 52,
        "app_info": {
            "app_lan_infos": [
                {
                    "lan": "zh_CN",
                    "label": "中文（简体）",
                    "lanStr": "中文（简体）",
                    "name": app.app_name_zh,
                    "brief_info": app.short_desc_zh,
                    "desc_info": app.full_desc_zh,
                    "dev_name": "",
                    "icon_save_key": icon_upload.file_save_key,
                    "appScreenShotList": [
                        {"screen_shot_key": shot.file_save_key, "image_mode": 1, "size": shot.size, "sort": index}
                        for index, shot in enumerate(screenshots)
                    ],
                }
            ],
            "app_basic_info": {
                "default_lan": "zh_CN",
                "pkg_mode": 0,
                "pkgInstallMode": 1,
                "inAppPayment": 0,
                "category_id": app.category_id,
                "website": app.website,
                "region": release.region or "1",
            },
            "app_fit_info": {
                "system_mode": [{"code": 1}],
                "baseline": [],
                "unsupportBaseline": [],
                "system_platform": [{"code": platform.code}],
                "region": [{"code": int(release.region or "1")}],
                "arch": [{"code": arch.code}],
                "cpu_clip": [],
                "motherboard": [],
                "supWayland": 0,
            },
            "app_origin_pkgs": [
                {
                    "pkg_name": package_info.pkg_name,
                    "pkg_version": package_info.pkg_version,
                    "pkg_arch": arch.code,
                    "pkgArch": arch.label,
                    "pkg_mode": 0,
                    "pkgChannel": None,
                    "pkgType": platform.pkg_type,
                    "progressPercent": 100,
                    "sums": 0,
                    "index": 0,
                    "pkg_size": package_info.pkg_size,
                    "sha256": package_info.sha256,
                    "upload_time": _upload_time_label(),
                    "file_save_key": package_upload.file_save_key,
                    "system_platform": [platform.code],
                    "systemStr": platform.system_label,
                    "supSys": platform.sup_sys,
                    "baseline": [],
                    "unsupportBaseline": [],
                    "supBlineVer": "",
                    "unsupportBlineVers": "",
                }
            ],
        },
    }
    if target_app_id:
        payload["app_id"] = target_app_id
    return payload
```

- [ ] **Step 4: Run the client test to verify green**

Run:

```bash
python3 -m unittest appstore.tests.test_client -v
```

Expected: PASS with three passing tests.

- [ ] **Step 5: Commit**

Run:

```bash
git add appstore/models.py appstore/platforms.py appstore/appstore_client.py appstore/tests/test_client.py
git commit -m "feat: map store payloads for batch upload"
```

### Task 5: Lock Session And Upload API Behavior

**Files:**
- Modify: `appstore/appstore_client.py`
- Modify: `appstore/tests/test_client.py`
- Test: `appstore/tests/test_client.py`

- [ ] **Step 1: Add the failing session and upload tests**

Append these tests to `appstore/tests/test_client.py`:

```python
from unittest.mock import Mock, patch

from appstore.appstore_client import AppStoreClient, build_requests_session


class ClientSessionTests(unittest.TestCase):
    def test_build_requests_session_copies_cookies_and_token_headers(self) -> None:
        session = build_requests_session(
            cookies=[{"name": "sid", "value": "cookie-value", "domain": "appstore-dev.uniontech.com"}],
            local_storage={"accessToken": "token-value"},
            session_storage={},
        )

        self.assertEqual(session.headers["Authorization"], "token-value")
        self.assertEqual(session.headers["token"], "token-value")
        self.assertEqual(session.cookies.get("sid"), "cookie-value")

    @patch("appstore.appstore_client.requests.put")
    def test_upload_file_completes_begin_put_end_cycle(self, put_mock: Mock) -> None:
        fake_session = Mock()
        fake_session.post.side_effect = [
            Mock(
                json=lambda: {
                    "status": 200,
                    "datas": {
                        "id": 9,
                        "upload_id": "u-1",
                        "file_save_key": "save-key",
                        "upload_infos": [{"upload_url": "https://oss.invalid/upload"}],
                    },
                }
            ),
            Mock(json=lambda: {"status": 200}),
        ]
        put_mock.return_value = Mock(status_code=200, raise_for_status=lambda: None)
        client = AppStoreClient(session=fake_session)

        ref = client.upload_file_bytes(filename="demo.deb", data=b"payload", upload_type="temppkg")

        self.assertEqual(ref.file_save_key, "save-key")
        self.assertEqual(ref.size, len(b"payload"))
        self.assertEqual(fake_session.post.call_count, 2)
        put_mock.assert_called_once()
```

- [ ] **Step 2: Run the client tests to verify they fail for the new API boundaries**

Run:

```bash
python3 -m unittest appstore.tests.test_client -v
```

Expected: FAIL because `build_requests_session`, `AppStoreClient`, and `upload_file_bytes()` are not implemented.

- [ ] **Step 3: Implement the authenticated session and upload client**

Append these helpers to `appstore/appstore_client.py`:

```python
import asyncio
import hashlib
import os

import requests
from pyppeteer import launch

from appstore.models import UploadedFileRef

ORIGIN = "https://appstore-dev.uniontech.com"
BASE = f"{ORIGIN}/devprod-api"


class AuthenticationError(RuntimeError):
    pass


def build_requests_session(*, cookies: list[dict], local_storage: dict, session_storage: dict) -> requests.Session:
    session = requests.Session()
    session.headers.update(
        {
            "Origin": ORIGIN,
            "Referer": f"{ORIGIN}/#/management-detial?type=2",
            "User-Agent": "Mozilla/5.0",
        }
    )
    for cookie in cookies:
        session.cookies.set(cookie["name"], cookie["value"], domain=cookie.get("domain"))

    token = ""
    for storage in (local_storage, session_storage):
        for key, value in storage.items():
            if "token" in key.lower() and value:
                token = value
                break
        if token:
            break
    if token:
        session.headers["Authorization"] = token
        session.headers["token"] = token
    return session


class AppStoreClient:
    def __init__(self, session: requests.Session | None = None) -> None:
        self.session = session or requests.Session()

    @staticmethod
    def _ensure_ok(response, action: str) -> dict:
        response.raise_for_status()
        payload = response.json()
        if payload.get("status") != 200:
            raise RuntimeError(f"{action} failed: {payload}")
        return payload

    async def _login_and_export_state(self, username: str, password: str) -> tuple[list[dict], dict, dict]:
        browser = await launch(headless=True, args=["--no-sandbox"])
        page = await browser.newPage()
        await page.goto(f"{ORIGIN}/#/index", {"waitUntil": "networkidle2", "timeout": 120000})
        await page.waitForSelector('input[type="text"]', {"timeout": 120000})
        inputs = await page.querySelectorAll("input")
        await inputs[0].type(username, {"delay": 30})
        await inputs[1].type(password, {"delay": 30})
        await page.click("button")
        await page.waitForNavigation({"waitUntil": "networkidle2", "timeout": 120000})
        cookies = await page.cookies()
        local_storage = await page.evaluate(
            """() => {
              const out = {};
              for (let i = 0; i < localStorage.length; i += 1) {
                const key = localStorage.key(i);
                out[key] = localStorage.getItem(key);
              }
              return out;
            }"""
        )
        session_storage = await page.evaluate(
            """() => {
              const out = {};
              for (let i = 0; i < sessionStorage.length; i += 1) {
                const key = sessionStorage.key(i);
                out[key] = sessionStorage.getItem(key);
              }
              return out;
            }"""
        )
        await browser.close()
        return cookies, local_storage, session_storage

    def login(self, username: str, password: str) -> None:
        cookies, local_storage, session_storage = asyncio.run(self._login_and_export_state(username, password))
        self.session = build_requests_session(
            cookies=cookies,
            local_storage=local_storage,
            session_storage=session_storage,
        )
        self._ensure_ok(self.session.get(f"{BASE}/store-dev-auth/dev_info"), "dev_info")

    def list_apps(self, *, page_num: int = 1, page_size: int = 200) -> list[dict]:
        payload = self._ensure_ok(
            self.session.get(f"{BASE}/store-dev-app/app", params={"pageNum": page_num, "pageSize": page_size}),
            "list_apps",
        )
        datas = payload.get("datas") or {}
        if isinstance(datas, list):
            return datas
        for key in ("list", "records", "rows"):
            if isinstance(datas.get(key), list):
                return datas[key]
        return []

    def find_apps_by_pkg_name(self, pkg_name: str) -> list[dict]:
        pkg_name = pkg_name.strip()
        return [row for row in self.list_apps() if str(row.get("pkg_name", "")).strip() == pkg_name]

    def upload_file_bytes(self, *, filename: str, data: bytes, upload_type: str) -> UploadedFileRef:
        file_hash = hashlib.md5(data).hexdigest()
        begin_payload = {"uploadType": upload_type, "chunks": [{"hash": file_hash, "size": len(data)}]}
        begin = self._ensure_ok(self.session.post(f"{BASE}/store-file/upload/begin", json=begin_payload), "upload_begin")
        info = begin["datas"]
        requests.put(
            info["upload_infos"][0]["upload_url"],
            data=data,
            headers={"Content-Type": "application/octet-stream"},
            timeout=120,
        ).raise_for_status()
        end_payload = {
            "chunks": [file_hash],
            "hash": file_hash,
            "file_upload_record_id": info["id"],
            "size": len(data),
            "upload_id": info.get("upload_id"),
            "file_save_key": info["file_save_key"],
            "status": 2,
        }
        self._ensure_ok(self.session.post(f"{BASE}/store-file/upload/end", json=end_payload), "upload_end")
        return UploadedFileRef(kind=upload_type, file_save_key=info["file_save_key"], size=len(data), file_hash=file_hash)
```

- [ ] **Step 4: Run the full client test suite to verify green**

Run:

```bash
python3 -m unittest appstore.tests.test_client -v
```

Expected: PASS with all client tests green.

- [ ] **Step 5: Commit**

Run:

```bash
git add appstore/appstore_client.py appstore/tests/test_client.py
git commit -m "feat: add authenticated app store upload client"
```

### Task 6: Lock And Implement Batch Execution And Reports

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

- [ ] **Step 1: Write the failing batch-control tests**

Create `appstore/tests/test_upload_batch.py` with:

```python
import json
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory

from appstore.models import AppRecord, DebPackageInfo, LoadedManifest, ReleaseRecord, RowResult, UploadedFileRef
from appstore.upload_batch import run_batch


class FakeClient:
    def __init__(self, *, auth_error: bool = False, fail_rows: set[int] | None = None) -> None:
        self.auth_error = auth_error
        self.fail_rows = fail_rows or set()
        self.logged_in = False
        self.processed_rows: list[int] = []

    def login(self, username: str, password: str) -> None:
        if self.auth_error:
            raise RuntimeError("bad credentials")
        self.logged_in = True

    def find_apps_by_pkg_name(self, pkg_name: str) -> list[dict]:
        return []

    def upload_file_bytes(self, *, filename: str, data: bytes, upload_type: str) -> UploadedFileRef:
        return UploadedFileRef(kind=upload_type, file_save_key=f"{upload_type}-key", size=len(data), file_hash="hash")

    def submit_payload(self, payload: dict) -> dict:
        row_id = payload["debug_row_id"]
        self.processed_rows.append(row_id)
        if row_id in self.fail_rows:
            raise RuntimeError(f"row {row_id} failed")
        return {"app_id": f"app-{row_id}"}


class BatchRunnerTests(unittest.TestCase):
    def _manifest(self, root: Path) -> LoadedManifest:
        app = AppRecord(
            app_key="demo",
            app_name_zh="演示",
            pkg_name="demo",
            category_id=1,
            website="https://example.invalid",
            short_desc_zh="短描述",
            full_desc_zh="长描述",
            icon_path=root / "icon.png",
            screenshot_paths=(root / "1.png", root / "2.png", root / "3.png"),
            keywords_zh="",
            app_id_override="",
        )
        release_a = ReleaseRecord(2, "demo", root / "a.deb", "Deepin_23", "amd64", "1", "", "")
        release_b = ReleaseRecord(3, "demo", root / "b.deb", "Deepin_23", "amd64", "1", "", "")
        return LoadedManifest(workbook_path=root / "manifest.xlsx", apps={"demo": app}, releases=[release_a, release_b])

    def test_run_batch_continues_after_row_failure(self) -> None:
        with TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            for name in ("icon.png", "1.png", "2.png", "3.png"):
                (root / name).write_bytes(b"asset")
            (root / "a.deb").write_bytes(b"a")
            (root / "b.deb").write_bytes(b"b")

            results = run_batch(
                manifest=self._manifest(root),
                client=FakeClient(fail_rows={2}),
                username="user",
                password="pass",
                output_dir=root / "output",
                package_reader=lambda path: DebPackageInfo("demo", f"1.0.{path.stem}", "amd64", path.stat().st_size, "sha", path),
                dry_run=False,
            )

            self.assertEqual([result.status for result in results], ["submit_failed", "submitted"])

    def test_run_batch_stops_when_login_fails(self) -> None:
        with TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            results = run_batch(
                manifest=self._manifest(root),
                client=FakeClient(auth_error=True),
                username="user",
                password="pass",
                output_dir=root / "output",
                package_reader=lambda path: DebPackageInfo("demo", "1.0.0", "amd64", 1, "sha", path),
                dry_run=False,
            )

            self.assertEqual(len(results), 2)
            self.assertTrue(all(result.status == "auth_failed" for result in results))

    def test_run_batch_dry_run_skips_login_and_honors_row_filter(self) -> None:
        with TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            client = FakeClient()
            results = run_batch(
                manifest=self._manifest(root),
                client=client,
                username="user",
                password="pass",
                output_dir=root / "output",
                package_reader=lambda path: DebPackageInfo("demo", "1.0.0", "amd64", 1, "sha", path),
                dry_run=True,
                row_filter={3},
            )

            self.assertFalse(client.logged_in)
            self.assertEqual([result.row_id for result in results], [3])
            self.assertEqual(results[0].status, "dry_run")
```

- [ ] **Step 2: Run the batch test to verify it fails**

Run:

```bash
python3 -m unittest appstore.tests.test_upload_batch -v
```

Expected: FAIL because `run_batch`, `RowResult`, and submission wiring are not implemented.

- [ ] **Step 3: Implement row results, batch orchestration, and reports**

Append this dataclass to `appstore/models.py`:

```python
@dataclass(frozen=True)
class RowResult:
    row_id: int
    app_key: str
    deb_path: Path
    status: str
    message: str
    app_id: str = ""
    pkg_name: str = ""
    pkg_version: str = ""
```

Create `appstore/upload_batch.py` with:

```python
import argparse
import json
from datetime import datetime
from pathlib import Path

from openpyxl import Workbook

from appstore.appstore_client import build_submit_payload, choose_target_app_id
from appstore.deb import read_deb_package_info
from appstore.manifest import load_manifest
from appstore.models import LoadedManifest, RowResult


def _timestamp_label() -> str:
    return datetime.now().strftime("%Y%m%d-%H%M%S")


def _write_reports(output_dir: Path, results: list[RowResult]) -> None:
    output_dir.mkdir(parents=True, exist_ok=True)
    json_path = output_dir / "report.json"
    json_path.write_text(
        json.dumps(
            [
                {
                    "row_id": result.row_id,
                    "app_key": result.app_key,
                    "deb_path": str(result.deb_path),
                    "status": result.status,
                    "message": result.message,
                    "app_id": result.app_id,
                    "pkg_name": result.pkg_name,
                    "pkg_version": result.pkg_version,
                }
                for result in results
            ],
            ensure_ascii=False,
            indent=2,
        ),
        encoding="utf-8",
    )
    workbook = Workbook()
    sheet = workbook.active
    sheet.title = "report"
    sheet.append(["row_id", "app_key", "deb_path", "status", "message", "app_id", "pkg_name", "pkg_version"])
    for result in results:
        sheet.append(
            [
                result.row_id,
                result.app_key,
                str(result.deb_path),
                result.status,
                result.message,
                result.app_id,
                result.pkg_name,
                result.pkg_version,
            ]
        )
    workbook.save(output_dir / "report.xlsx")


def _parse_row_filter(raw_value: str) -> set[int]:
    if not raw_value.strip():
        return set()
    return {int(part.strip()) for part in raw_value.split(",") if part.strip()}


def run_batch(*, manifest: LoadedManifest, client, username: str, password: str, output_dir: Path, package_reader=read_deb_package_info, dry_run: bool, row_filter: set[int] | None = None) -> list[RowResult]:
    selected_releases = [release for release in manifest.releases if not row_filter or release.row_id in row_filter]
    if dry_run:
        results = [
            RowResult(
                row_id=release.row_id,
                app_key=release.app_key,
                deb_path=release.deb_path,
                status="dry_run",
                message="validated only",
            )
            for release in selected_releases
        ]
        _write_reports(output_dir, results)
        return results

    try:
        client.login(username, password)
    except Exception as exc:
        results = [
            RowResult(release.row_id, release.app_key, release.deb_path, "auth_failed", str(exc))
            for release in selected_releases
        ]
        _write_reports(output_dir, results)
        return results

    results: list[RowResult] = []
    for release in selected_releases:
        app = manifest.apps[release.app_key]
        try:
            package_info = package_reader(release.deb_path)
            if package_info.pkg_name != app.pkg_name:
                raise RuntimeError(f"pkg_name mismatch: expected {app.pkg_name}, got {package_info.pkg_name}")
            app_id = choose_target_app_id(client.find_apps_by_pkg_name(app.pkg_name), app.app_id_override)
            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=path.name, data=path.read_bytes(), upload_type="image")
                    for path in app.screenshot_paths
                ),
                "package": client.upload_file_bytes(
                    filename=release.deb_path.name,
                    data=release.deb_path.read_bytes(),
                    upload_type="temppkg",
                ),
            }
            payload = build_submit_payload(
                app=app,
                release=release,
                package_info=package_info,
                uploads=uploads,
                target_app_id=app_id,
            )
            payload["debug_row_id"] = release.row_id
            response = client.submit_payload(payload)
            results.append(
                RowResult(
                    row_id=release.row_id,
                    app_key=release.app_key,
                    deb_path=release.deb_path,
                    status="submitted",
                    message="ok",
                    app_id=str(response.get("app_id", app_id)),
                    pkg_name=package_info.pkg_name,
                    pkg_version=package_info.pkg_version,
                )
            )
        except Exception as exc:
            results.append(
                RowResult(
                    row_id=release.row_id,
                    app_key=release.app_key,
                    deb_path=release.deb_path,
                    status="submit_failed",
                    message=str(exc),
                    pkg_name=app.pkg_name,
                )
            )
    _write_reports(output_dir, results)
    return results


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("workbook")
    parser.add_argument("--output-dir", default="")
    parser.add_argument("--rows", default="")
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args(argv)

    from appstore.appstore_client import AppStoreClient

    username = input("App store username: ").strip()
    password = input("App store password: ").strip()
    manifest = load_manifest(args.workbook)
    output_root = Path(args.output_dir) if args.output_dir else Path("appstore/output") / _timestamp_label()
    run_batch(
        manifest=manifest,
        client=AppStoreClient(),
        username=username,
        password=password,
        output_dir=output_root,
        dry_run=args.dry_run,
        row_filter=_parse_row_filter(args.rows),
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

- [ ] **Step 4: Run the batch tests to verify green**

Run:

```bash
python3 -m unittest appstore.tests.test_upload_batch -v
```

Expected: PASS with both batch behavior tests green, and `report.json` plus `report.xlsx` written under the temporary output directory.

- [ ] **Step 5: Commit**

Run:

```bash
git add appstore/models.py appstore/upload_batch.py appstore/tests/test_upload_batch.py
git commit -m "feat: orchestrate batch app store uploads"
```

### Task 7: Add CLI Polish, Example Workbook, And Documentation

**Files:**
- Create: `appstore/examples/generate_template.py`
- Create: `appstore/examples/assets/icon.png`
- Create: `appstore/examples/assets/shot-1.png`
- Create: `appstore/examples/assets/shot-2.png`
- Create: `appstore/examples/assets/shot-3.png`
- Create: `appstore/examples/packages/labelnova_1.0.4-1_amd64.deb`
- Create: `appstore/tests/test_examples.py`
- Modify: `appstore/upload_batch.py`
- Modify: `appstore/appstore_client.py`
- Create: `appstore/README.md`
- Create: `appstore/examples/template.xlsx`
- Test: `appstore/tests/test_examples.py`

- [ ] **Step 1: Write the failing example-workbook test**

Create `appstore/tests/test_examples.py` with:

```python
import subprocess
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory

from openpyxl import load_workbook


class ExampleWorkbookTests(unittest.TestCase):
    def test_generate_template_creates_required_sheets_and_headers(self) -> None:
        with TemporaryDirectory() as tmpdir:
            output_path = Path(tmpdir) / "template.xlsx"
            subprocess.run(
                ["python3", "appstore/examples/generate_template.py", str(output_path)],
                check=True,
            )
            workbook = load_workbook(output_path)

            self.assertEqual(workbook.sheetnames, ["apps", "releases"])
            self.assertTrue((output_path.parent / "assets" / "icon.png").is_file())
            self.assertTrue((output_path.parent / "packages" / "labelnova_1.0.4-1_amd64.deb").is_file())
            self.assertEqual(
                [cell.value for cell in workbook["apps"][1][:13]],
                [
                    "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",
                ],
            )
            self.assertEqual(
                [cell.value for cell in workbook["releases"][1][:8]],
                ["enabled", "app_key", "deb_path", "system_platform", "arch", "region", "baseline", "note"],
            )


if __name__ == "__main__":
    unittest.main()
```

- [ ] **Step 2: Run the example test to verify it fails**

Run:

```bash
python3 -m unittest appstore.tests.test_examples -v
```

Expected: FAIL because `appstore/examples/generate_template.py` does not exist.

- [ ] **Step 3: Implement the workbook generator and finalize CLI credential behavior**

Create `appstore/examples/generate_template.py` with:

```python
import sys
from pathlib import Path

from openpyxl import Workbook


APP_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",
]
RELEASE_HEADERS = ["enabled", "app_key", "deb_path", "system_platform", "arch", "region", "baseline", "note"]


def main(argv: list[str] | None = None) -> int:
    argv = argv or sys.argv[1:]
    output_path = Path(argv[0] if argv else "appstore/examples/template.xlsx")
    output_path.parent.mkdir(parents=True, exist_ok=True)
    assets_dir = output_path.parent / "assets"
    packages_dir = output_path.parent / "packages"
    assets_dir.mkdir(parents=True, exist_ok=True)
    packages_dir.mkdir(parents=True, exist_ok=True)
    for name in ("icon.png", "shot-1.png", "shot-2.png", "shot-3.png"):
        (assets_dir / name).write_bytes(b"example-asset")
    (packages_dir / "labelnova_1.0.4-1_amd64.deb").write_bytes(b"example-deb")
    workbook = Workbook()
    apps = workbook.active
    apps.title = "apps"
    apps.append(APP_HEADERS)
    apps.append(
        [
            "labelnova",
            "LabelNova",
            "labelnova",
            1,
            "https://mm.md/p/",
            "开源标签设计与打印工具",
            "支持文本、条码、二维码、图像与模板化标签设计。",
            "assets/icon.png",
            "assets/shot-1.png",
            "assets/shot-2.png",
            "assets/shot-3.png",
            "标签,条码,打印",
            "",
        ]
    )
    releases = workbook.create_sheet("releases")
    releases.append(RELEASE_HEADERS)
    releases.append(["Y", "labelnova", "packages/labelnova_1.0.4-1_amd64.deb", "Deepin_23", "amd64", "1", "", ""])
    workbook.save(output_path)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

Update `appstore/upload_batch.py` so credential resolution uses environment variables first and hidden interactive input second:

```python
import getpass
import os


def _resolve_credentials() -> tuple[str, str]:
    username = os.environ.get("APPSTORE_USERNAME", "").strip()
    password = os.environ.get("APPSTORE_PASSWORD", "").strip()
    if username and password:
        return username, password
    if not username:
        username = input("App store username: ").strip()
    if not password:
        password = getpass.getpass("App store password: ").strip()
    return username, password
```

Then replace the direct `input()` calls in `main()` with:

```python
    username, password = ("", "") if args.dry_run else _resolve_credentials()
```

Append `submit_payload()` to `AppStoreClient` in `appstore/appstore_client.py`:

```python
    def submit_payload(self, payload: dict) -> dict:
        return self._ensure_ok(self.session.post(f"{BASE}/store-dev-app/app", json=payload), "submit_app")
```

Create `appstore/README.md` with:

````markdown
# App Store Batch Upload Tool

## Setup

```bash
python3 -m pip install -r appstore/requirements.txt
python3 appstore/examples/generate_template.py
```

## Workbook

- Fill app-level data in `apps`
- Fill package rows in `releases`
- Paths are relative to the workbook location

## Credentials

Preferred:

```bash
export APPSTORE_USERNAME='your-account'
export APPSTORE_PASSWORD='your-password'
```

Fallback: the CLI prompts interactively.

## Dry Run

```bash
python3 -m appstore.upload_batch appstore/examples/template.xlsx --dry-run --rows 2
```

## Real Upload

```bash
python3 -m appstore.upload_batch path/to/manifest.xlsx
```

Reports are written under `appstore/output/<timestamp>/`.
````

- [ ] **Step 4: Generate the committed template workbook and run the full suite**

Run:

```bash
python3 appstore/examples/generate_template.py appstore/examples/template.xlsx
python3 -m unittest discover appstore/tests -v
python3 -m appstore.upload_batch appstore/examples/template.xlsx --dry-run --rows 2
```

Expected:

- all unittest suites PASS
- dry-run exits `0`
- `appstore/output/<timestamp>/report.json` and `report.xlsx` are created

- [ ] **Step 5: Commit**

Run:

```bash
git add appstore/examples/generate_template.py appstore/examples/template.xlsx appstore/examples/assets/icon.png appstore/examples/assets/shot-1.png appstore/examples/assets/shot-2.png appstore/examples/assets/shot-3.png appstore/examples/packages/labelnova_1.0.4-1_amd64.deb appstore/upload_batch.py appstore/appstore_client.py appstore/README.md appstore/tests/test_examples.py
git commit -m "feat: document and template app store batch uploads"
```
