From 807b525ec9ce3cff0172e604a8e304dc9f41dadc Mon Sep 17 00:00:00 2001 From: LiHaohua Date: Tue, 28 Jul 2026 18:13:14 +0800 Subject: [PATCH] feat(czdev): add `czdev new` to scaffold from the project template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A git submodule cannot float: it stores a gitlink (an exact commit SHA) in the parent tree, so vendoring CardputerZero/Template as a submodule would freeze every developer on one template commit and require a commit here for every template update. Copy the branch tip at scaffold time instead, so no template commit is recorded anywhere. `czdev new ` shallow-clones the template's default branch, drops its history (scaffold, not fork) and renames the per-app placeholders: the CMake project name, the compiled-in APP_NAME, the launcher display name and the icon files. The icons matter beyond cosmetics — they install into the shared /usr/share/APPLaunch/share/images/, so two packages both shipping template.png conflict at dpkg level. The upstream template is now also marked as a GitHub template repository, so `gh repo create --template CardputerZero/Template` is an equivalent route; both are documented in the README and all three quickstarts. Co-authored-by: Cursor --- README.md | 47 ++++++++++- docs/QUICKSTART.md | 25 +++++- docs/QUICKSTART_JA.md | 29 ++++++- docs/QUICKSTART_ZH.md | 26 +++++- scripts/czdev/__main__.py | 21 ++++- scripts/czdev/new.py | 173 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 scripts/czdev/new.py diff --git a/README.md b/README.md index 906f4c2..0e9c07a 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ applications. Submit any public Git repository and get a ready-to-install `.deb` package — no local toolchain required — then publish it to the CardputerZero AppStore with the Python `czdev` CLI. -- **`czdev`** — a small, pure-**Python 3** CLI to authenticate with GitHub and - publish / unpublish `.deb` packages. No Rust / cargo toolchain needed. +- **`czdev`** — a small, pure-**Python 3** CLI to scaffold a new app, authenticate + with GitHub and publish / unpublish `.deb` packages. No Rust / cargo toolchain + needed. - **CI online build** — a GitHub Actions workflow that cross-compiles any repo to an aarch64 `.deb`. - **Examples** — a gallery of ready-to-build apps (C/LVGL, SDL2, Qt, Python, @@ -22,6 +23,7 @@ cd CardputerZero-AppBuilder ./czdev --help # works immediately with Python 3 ./czdev login # one-time GitHub device-flow login +./czdev new my-app # scaffold a project from the latest template # From your app's project directory (must contain app-builder.json with a # "store" section), after producing a .deb: @@ -42,6 +44,7 @@ PYTHONPATH=scripts python3 -m czdev --help | Command | What it does | |---|---| +| `czdev new NAME` | Scaffold a new app from the [project template](https://github.com/CardputerZero/Template). See [Starting a new app](#starting-a-new-app). | | `czdev login` | GitHub OAuth **device flow**; stores a token at `~/.czdev/credentials`. | | `czdev logout` | Remove the stored GitHub credentials. | | `czdev bump [--deb PATH]` | Print the next patch version for a package (reads the version from the `.deb`). Defaults to `./build/*.deb`. | @@ -113,6 +116,46 @@ PR. See [`docs/APP_BUILDER_JSON.md`](docs/APP_BUILDER_JSON.md) for the `store` section that supplies the AppStore listing (title, summary, screenshots, categories, …). +## Starting a new app + +New projects start from [**CardputerZero/Template**](https://github.com/CardputerZero/Template) +(LVGL + CMake, desktop SDL preview + device framebuffer build). Two equivalent +ways to get a copy — both always give you the template's **latest** state: + +```bash +# A. via czdev (clones the template's default branch, renames placeholders) +./czdev new my-app + +# B. via GitHub's template mechanism (creates a fresh repo under your account) +gh repo create my-app --template CardputerZero/Template --public --clone +``` + +`czdev new` additionally does the per-app renaming that the template needs: +the CMake project name, the compiled-in `APP_NAME`, the launcher display name, +and — importantly — the icon files. The template's icons install into the +**shared** `/usr/share/APPLaunch/share/images/`, so leaving them named +`template*.png` makes two template-derived packages conflict on install. + +```bash +./czdev new my-app --display-name "My App" # launcher name (default: "My App" from the slug) +./czdev new my-app --dir ~/projects/my-app # target directory (default: ./my-app) +./czdev new my-app --template me/MyTemplate # a different template repo, or a git URL +./czdev new my-app --ref dev # a different template branch +./czdev new my-app --no-git # don't create a git repo +``` + +`NAME` must be a valid Debian package name (lowercase, starts with a letter), +because it becomes the package name at publish time. + +> **Why not a git submodule?** A submodule stores a **gitlink** — an exact +> commit SHA — in the parent tree, so AppBuilder would freeze every developer +> on whichever template commit happened to be vendored, and each template +> update would need a commit here. `czdev new` copies the branch tip at +> scaffold time instead, so there is no pinned commit anywhere. The trade-off +> is that a scaffolded project is *not* linked to upstream: later template +> changes must be cherry-picked manually (which is the normal expectation for +> a scaffold — same as `cargo new`). + ## Getting a `.deb` You don't need a local ARM toolchain — building happens in CI. diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index f299bc0..b61285a 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -32,7 +32,24 @@ cd CardputerZero-AppBuilder `czdev login` prints a code and a URL — open the URL, enter the code, and authorize. The token is reused by later commands. -## 3. Get a `.deb` +## 3. Start a project (optional) + +If you don't have an app yet, scaffold one from the +[project template](https://github.com/CardputerZero/Template) (LVGL + CMake): + +```bash +./czdev new my-app # → ./my-app, copied from the template's latest main +# or let GitHub make the copy: +gh repo create my-app --template CardputerZero/Template --public --clone +``` + +`czdev new` renames the template's placeholders for you — CMake project name, +compiled-in `APP_NAME`, launcher display name, and the icon files (these install +into the *shared* `/usr/share/APPLaunch/share/images/`, so unique names are +required or two template-derived packages conflict). `NAME` must be a valid +Debian package name, since it becomes the package name at publish time. + +## 4. Get a `.deb` You don't build ARM binaries locally. Two ways to obtain a package: @@ -44,7 +61,7 @@ You don't build ARM binaries locally. Two ways to obtain a package: Your project must contain an `app-builder.json` (see [APP_BUILDER_JSON.md](APP_BUILDER_JSON.md)) for CI to discover and build it. -## 4. Add a `store` section for the listing +## 5. Add a `store` section for the listing `czdev publish` reads the AppStore listing from the `store` section of your `app-builder.json`. At minimum you need a title and one 320×170 screenshot: @@ -66,7 +83,7 @@ Your project must contain an `app-builder.json` (see } ``` -## 5. Bump and publish +## 6. Bump and publish Run from your app's project directory (the one with `app-builder.json`): @@ -85,7 +102,7 @@ and merges it; CI then rebuilds the APT index and your app goes live. If `--deb` is omitted, `czdev` searches `./build/*.deb`. -## 6. Unpublish +## 7. Unpublish ```bash ./czdev unpublish my_app --version 1.0.1 diff --git a/docs/QUICKSTART_JA.md b/docs/QUICKSTART_JA.md index 8dba0f2..1bc992c 100644 --- a/docs/QUICKSTART_JA.md +++ b/docs/QUICKSTART_JA.md @@ -32,7 +32,28 @@ cd CardputerZero-AppBuilder `czdev login` はコードと URL を表示します — URL を開いてコードを入力し、認可 してください。以降のコマンドはこのトークンを再利用します。 -## 3. `.deb` を入手する +## 3. 新しいプロジェクトを作る(任意) + +アプリがまだない場合は、[プロジェクトテンプレート](https://github.com/CardputerZero/Template) +(LVGL + CMake)から雛形を作成できます: + +```bash +./czdev new my-app # → ./my-app、テンプレート main の最新状態をコピー +# または GitHub 側でコピーを作る: +gh repo create my-app --template CardputerZero/Template --public --clone +``` + +`czdev new` はテンプレートのプレースホルダーを自動で置き換えます: CMake の +プロジェクト名、バイナリに埋め込まれる `APP_NAME`、ランチャー表示名、そして +アイコンのファイル名です。アイコンは**共有ディレクトリ** +`/usr/share/APPLaunch/share/images/` にインストールされるため、どれも +`template.png` のままだとテンプレート由来のパッケージ同士が dpkg のファイル +衝突を起こします。 + +`NAME` は公開時のパッケージ名になるので、有効な Debian パッケージ名 +(小文字で始まる)である必要があります。 + +## 4. `.deb` を入手する ARM バイナリをローカルでビルドする必要はありません。入手方法は 2 つ: @@ -45,7 +66,7 @@ ARM バイナリをローカルでビルドする必要はありません。入 CI が発見・ビルドできるよう、プロジェクトには `app-builder.json` ([APP_BUILDER_JSON.md](APP_BUILDER_JSON.md) 参照)が必要です。 -## 4. ストア情報として `store` セクションを追加 +## 5. ストア情報として `store` セクションを追加 `czdev publish` は `app-builder.json` の `store` セクションから AppStore の 掲載情報を読み取ります。最低限、タイトルと 320×170 のスクリーンショットが @@ -68,7 +89,7 @@ CI が発見・ビルドできるよう、プロジェクトには `app-builder. } ``` -## 5. bump と publish +## 6. bump と publish アプリのプロジェクトディレクトリ(`app-builder.json` がある場所)で実行します: @@ -87,7 +108,7 @@ CI が発見・ビルドできるよう、プロジェクトには `app-builder. `--deb` を省略すると、`czdev` は `./build/*.deb` を探します。 -## 6. 公開停止(unpublish) +## 7. 公開停止(unpublish) ```bash ./czdev unpublish my_app --version 1.0.1 diff --git a/docs/QUICKSTART_ZH.md b/docs/QUICKSTART_ZH.md index 0eab576..c978504 100644 --- a/docs/QUICKSTART_ZH.md +++ b/docs/QUICKSTART_ZH.md @@ -31,7 +31,25 @@ cd CardputerZero-AppBuilder `czdev login` 会打印一个验证码和一个网址——打开网址、输入验证码并授权即可。 后续命令会复用这个 token。 -## 3. 拿到 `.deb` +## 3. 新建项目(可选) + +如果还没有应用,可以从[项目模板](https://github.com/CardputerZero/Template) +(LVGL + CMake)脚手架一个: + +```bash +./czdev new my-app # → ./my-app,取模板 main 分支的最新状态 +# 或者让 GitHub 直接复制一份: +gh repo create my-app --template CardputerZero/Template --public --clone +``` + +`czdev new` 会顺带把模板里的占位符改成你的应用名:CMake 项目名、编译进二进制的 +`APP_NAME`、启动器显示名,以及图标文件名。图标这一项很关键——模板的图标会装到 +**共享目录** `/usr/share/APPLaunch/share/images/`,若都叫 `template.png`,两个基于 +模板的包装到同一台设备上会产生 dpkg 文件冲突。 + +`NAME` 必须是合法的 Debian 包名(小写字母开头),因为它就是将来发布用的包名。 + +## 4. 拿到 `.deb` 你不需要在本地编译 ARM 二进制。两种拿包方式: @@ -43,7 +61,7 @@ cd CardputerZero-AppBuilder 你的项目里要有 `app-builder.json`(见 [APP_BUILDER_JSON.md](APP_BUILDER_JSON.md)),CI 才能发现并构建它。 -## 4. 补上 `store` 段作为商店信息 +## 5. 补上 `store` 段作为商店信息 `czdev publish` 会从 `app-builder.json` 的 `store` 段读取 AppStore 展示信息。 至少需要一个标题和一张 320×170 的截图: @@ -65,7 +83,7 @@ cd CardputerZero-AppBuilder } ``` -## 5. bump 与 publish +## 6. bump 与 publish 在你应用的项目目录(含 `app-builder.json` 的那个)里运行: @@ -84,7 +102,7 @@ cd CardputerZero-AppBuilder 不带 `--deb` 时,`czdev` 会在 `./build/*.deb` 里查找。 -## 6. 下架 +## 7. 下架 ```bash ./czdev unpublish my_app --version 1.0.1 diff --git a/scripts/czdev/__main__.py b/scripts/czdev/__main__.py index 8b37020..7216ce6 100644 --- a/scripts/czdev/__main__.py +++ b/scripts/czdev/__main__.py @@ -11,6 +11,21 @@ def main(): ) subparsers = parser.add_subparsers(dest="command") + # new + new_parser = subparsers.add_parser( + "new", help="Scaffold a new app from the CardputerZero project template.") + new_parser.add_argument("name", help="App name (also the Debian package name)") + new_parser.add_argument("--dir", default=None, help="Target directory (default: ./)") + new_parser.add_argument("--template", default="CardputerZero/Template", + help="Template repo as owner/repo or a git URL " + "(default: CardputerZero/Template)") + new_parser.add_argument("--ref", default="main", + help="Template branch to copy the latest commit of (default: main)") + new_parser.add_argument("--display-name", default=None, + help="Launcher display name (default: derived from the app name)") + new_parser.add_argument("--no-git", action="store_true", + help="Do not create a git repository in the new project") + # login subparsers.add_parser("login", help="Authenticate with GitHub (device flow).") @@ -37,7 +52,11 @@ def main(): parser.print_help() sys.exit(0) - if args.command == "login": + if args.command == "new": + from .new import run + run(name=args.name, dir=args.dir, template=args.template, ref=args.ref, + display_name=args.display_name, no_git=args.no_git) + elif args.command == "login": from .auth import login login() elif args.command == "logout": diff --git a/scripts/czdev/new.py b/scripts/czdev/new.py new file mode 100644 index 0000000..cfb5266 --- /dev/null +++ b/scripts/czdev/new.py @@ -0,0 +1,173 @@ +"""Scaffold a new app from the CardputerZero project template. + +Deliberately *not* a git submodule: a submodule stores a gitlink (an exact +commit SHA) in the parent tree, which would freeze everyone on whatever +template commit was vendored. Instead we shallow-clone the template's default +branch at scaffold time, so `czdev new` always starts you on the latest +template without AppBuilder tracking a template commit at all. + +Equivalent one-liner if you'd rather let GitHub do the copy (the template is +marked as a GitHub template repository): + + gh repo create my-app --template CardputerZero/Template --public --clone +""" + +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + +DEFAULT_TEMPLATE = "CardputerZero/Template" +DEFAULT_REF = "main" + +# The template ships placeholder identifiers that must be renamed per app. +# `template_app` is the CMake project name (⇒ binary name, deb package name, +# /usr/share// asset root) and the APP_NAME compiled into the asset +# manager. The icons matter too: they install into the *shared* +# /usr/share/APPLaunch/share/images/, so two apps that both ship +# "template.png" would collide at dpkg level. +PLACEHOLDER = "template_app" +ICON_STEM = "template" +DISPLAY_PLACEHOLDER = "TemplateApp" + +# Files whose bytes must not be touched by the text substitution pass. +BINARY_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".ttf", ".otf", ".wav", + ".mp3", ".bin", ".ico", ".pdf", ".zip"} + +# Debian package names: lowercase alnum plus + - . and at least two chars. +# The scaffolded name becomes the package name, so enforce it up front rather +# than letting `czdev publish` reject it later. +NAME_RE = re.compile(r"^[a-z][a-z0-9+.-]+$") + + +def run(name: str, dir: Optional[str] = None, template: str = DEFAULT_TEMPLATE, + ref: str = DEFAULT_REF, display_name: Optional[str] = None, + no_git: bool = False): + if not NAME_RE.match(name): + print(f"invalid app name: {name!r}", file=sys.stderr) + print("must be a valid Debian package name: start with a lowercase " + "letter, then lowercase letters/digits/+/-/. (2+ chars)", file=sys.stderr) + sys.exit(1) + + target = Path(dir) if dir else Path(name) + if target.exists() and any(target.iterdir()): + print(f"target directory is not empty: {target}", file=sys.stderr) + sys.exit(1) + + require_git() + url = template if "://" in template else f"https://github.com/{template}" + display = display_name or derive_display_name(name) + + print(f"Creating {name} from {template}@{ref}") + print(f" → Cloning template... ", end="", flush=True) + try: + subprocess.run(["git", "clone", "--quiet", "--depth", "1", + "--branch", ref, url, str(target)], check=True) + except subprocess.CalledProcessError: + print("failed") + print(f"could not clone {url} (branch {ref})", file=sys.stderr) + sys.exit(1) + upstream_sha = git_output(target, ["rev-parse", "HEAD"])[:12] + print(f"done ({upstream_sha})") + + # Drop the template's history: this is a scaffold, not a fork. Without + # this the new project would carry the template's remote and commits. + shutil.rmtree(target / ".git", ignore_errors=True) + + print(" → Renaming placeholders... ", end="", flush=True) + renamed_files = rename_icons(target, name) + patched = substitute(target, name, display) + print(f"done ({patched} files patched, {renamed_files} icons renamed)") + + if not no_git: + print(" → Initializing git repository... ", end="", flush=True) + init_repo(target, name, template, ref, upstream_sha) + print("done") + + print() + print(f"✓ Created {target}/") + print() + print(" Next steps:") + print(f" cd {target}") + print(" cmake --preset # see README.md for presets") + print(" ./czdev publish --deb build/.deb") + print() + print(" The template was copied at its latest commit and is not linked to" + " upstream;") + print(" re-run `czdev new` for a fresh copy, or cherry-pick template" + " changes manually.") + + +def require_git(): + if shutil.which("git") is None: + print("git not found on PATH", file=sys.stderr) + sys.exit(1) + + +def git_output(cwd: Path, args: list) -> str: + return subprocess.run(["git"] + args, cwd=cwd, capture_output=True, + text=True, check=True).stdout.strip() + + +def derive_display_name(name: str) -> str: + """"my-cool-app" → "My Cool App" for launcher/desktop-entry display.""" + return " ".join(part.capitalize() for part in re.split(r"[-_.+]+", name) if part) + + +def rename_icons(root: Path, name: str) -> int: + """Rename assets/images/template*.png → *.png. + + These install into the shared /usr/share/APPLaunch/share/images/, so the + stem must be unique per app or packages conflict on install. + """ + images = root / "assets" / "images" + if not images.is_dir(): + return 0 + count = 0 + for path in sorted(images.glob(f"{ICON_STEM}*.png")): + new_name = name + path.name[len(ICON_STEM):] + path.rename(images / new_name) + count += 1 + return count + + +def substitute(root: Path, name: str, display: str) -> int: + """Replace the template's placeholder identifiers throughout the tree.""" + replacements = [ + (PLACEHOLDER, name), + # Icon references: the install glob and the .desktop Icon= line. + (f"{ICON_STEM}*.png", f"{name}*.png"), + (f"{ICON_STEM}.png", f"{name}.png"), + (DISPLAY_PLACEHOLDER, display), + ] + patched = 0 + for path in sorted(root.rglob("*")): + if not path.is_file() or path.suffix.lower() in BINARY_SUFFIXES: + continue + try: + text = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + continue + new_text = text + for old, new in replacements: + new_text = new_text.replace(old, new) + if new_text != text: + path.write_text(new_text, encoding="utf-8") + patched += 1 + return patched + + +def init_repo(root: Path, name: str, template: str, ref: str, sha: str): + try: + subprocess.run(["git", "init", "--quiet"], cwd=root, check=True) + subprocess.run(["git", "add", "-A"], cwd=root, check=True) + subprocess.run( + ["git", "commit", "--quiet", "-m", + f"chore: scaffold {name} from {template}@{ref} ({sha})"], + cwd=root, check=True) + except subprocess.CalledProcessError: + # A missing user.name/user.email shouldn't fail the whole scaffold — + # the files are already in place. + print("(commit skipped)", end="")