From b8be2ef95aee658f5b5f6197e29c0036da90294d Mon Sep 17 00:00:00 2001 From: KenwoodFox Date: Sun, 28 Jun 2026 11:34:54 -0400 Subject: [PATCH] Inital ver (port) --- .gitea/workflows/nightly.yml | 53 +++ .gitignore | 4 + README.md | 134 ++++++ autopackage.py | 432 ++++++++++++++++++ bacula/.gitkeep | 0 fchat-horizon-appimage/.gitkeep | 0 ggml-git-latest-commit/.gitkeep | 0 openrgb-git/.gitkeep | 0 paru/.gitkeep | 0 .../0001-sentry-native-inproc-backend.patch | 19 + qownnotes/.gitkeep | 0 ventoy-bin/.gitkeep | 0 visual-studio-code-bin/.gitkeep | 0 13 files changed, 642 insertions(+) create mode 100644 .gitea/workflows/nightly.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 autopackage.py create mode 100644 bacula/.gitkeep create mode 100644 fchat-horizon-appimage/.gitkeep create mode 100644 ggml-git-latest-commit/.gitkeep create mode 100644 openrgb-git/.gitkeep create mode 100644 paru/.gitkeep create mode 100644 plezy-bin/0001-sentry-native-inproc-backend.patch create mode 100644 qownnotes/.gitkeep create mode 100644 ventoy-bin/.gitkeep create mode 100644 visual-studio-code-bin/.gitkeep diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml new file mode 100644 index 0000000..c657d29 --- /dev/null +++ b/.gitea/workflows/nightly.yml @@ -0,0 +1,53 @@ +name: nightly-build + +on: + schedule: + # 03:30 UTC every night + - cron: "30 3 * * *" + workflow_dispatch: + inputs: + only: + description: "Space-separated package dirs to build (blank = all)" + required: false + default: "" + replace: + description: "Replace existing registry files on version clash" + required: false + default: "false" + +jobs: + build: + runs-on: ubuntu-latest + container: + image: archlinux:latest + # makepkg refuses to run as root; we create a build user below. + options: --privileged + steps: + - name: Install toolchain + run: | + pacman -Syu --noconfirm --needed base-devel git python sudo + + - name: Create build user + run: | + useradd -m -s /bin/bash builder + echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder + chmod 0440 /etc/sudoers.d/builder + + - name: Checkout + uses: actions/checkout@v4 + + - name: Fix permissions + run: chown -R builder:builder "$GITHUB_WORKSPACE" + + - name: Build and publish + env: + AUTOPKG_REGISTRY_URL: ${{ github.server_url }} + AUTOPKG_OWNER: ${{ github.repository_owner }} + AUTOPKG_REPO: arch + AUTOPKG_USER: ${{ github.actor }} + AUTOPKG_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + AUTOPKG_REPLACE: ${{ github.event.inputs.replace }} + ONLY: ${{ github.event.inputs.only }} + run: | + sudo -E -u builder \ + python3 autopackage.py --keep-going ${ONLY:+--only $ONLY} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6251aac --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.build/ +__pycache__/ +*.pkg.tar.* +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b61772 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# autopackage + +Nightly, directory-driven Arch package builder for Gitea CI. + +Every top-level folder in this repo is a package. The nightly job +([`.gitea/workflows/nightly.yml`](.gitea/workflows/nightly.yml)) builds them all +in an `archlinux:latest` container and publishes the resulting +`*.pkg.tar.zst` files to a [Gitea Arch package +registry](https://docs.gitea.com/usage/packages/arch). Gitea keeps the pacman +database up to date automatically — there is no `repo-add` step. + +## How a folder is classified + +For a folder `foo/`: + +| Contents of `foo/` | Treated as | What happens | +| ----------------------------------------- | ---------- | ------------ | +| Contains a `PKGBUILD` | **custom** | Built as-is from the folder. | +| No `PKGBUILD` | **AUR** | `https://aur.archlinux.org/foo.git` is cloned, then overrides/patches from `foo/` are applied. | + +### AUR packages + +The folder name **is** the AUR package name. The simplest AUR package is a +folder that just contains a `.gitkeep` (git won't track an empty directory): + +``` +openrgb-git/ + .gitkeep +``` + +To customize an AUR package without forking the whole `PKGBUILD`, drop files in +the folder: + +- **Patches** — any `*.patch` / `*.diff` files are applied (in sorted order) on + top of the freshly cloned AUR repo. `git apply` is tried first, then + `patch -Np1`. +- **Overrides** — any other file (e.g. an extra source file, a `.install` + script) is copied into the clone, replacing a file of the same name. + +> Note: if you add a full `PKGBUILD` to the folder it becomes a *custom* +> package and the AUR repo is no longer cloned. Use patches/overrides to tweak +> an AUR package. + +Example AUR package with a patch: + +``` +openrgb-git/ + .gitkeep + 0001-fix-udev-rules.patch +``` + +### Custom packages + +A folder with its own `PKGBUILD` (plus any local sources) is built directly: + +``` +my-thing/ + PKGBUILD + my-thing.install + some-source.tar.gz +``` + +## Publishing / consuming + +Packages are uploaded to `{GITEA_URL}/api/packages/{owner}/arch/{repo}` where +`{repo}` defaults to `arch` (configurable via `AUTOPKG_REPO`). + +Add this to `/etc/pacman.conf` on client machines (replace the host/owner): + +```ini +[arch] +SigLevel = Optional TrustAll +Server = https://git.example.com/api/packages//arch/arch/$arch +``` + +> `SigLevel = Optional TrustAll` is used because packages are unsigned by +> default. To sign them, set up a GPG key on the runner and pass `--sign` +> (the `.sig` files are uploaded automatically); then you can switch to a +> stricter `SigLevel`. + +## CI configuration + +The workflow needs one secret: + +- **`PACKAGE_TOKEN`** — a Gitea access token with the `write:package` scope + (and `read:package`). Create it under *Settings → Applications → Generate New + Token*, then add it under the repo's *Settings → Actions → Secrets*. + +Everything else is derived from built-in Gitea Actions variables +(`GITHUB_SERVER_URL`, `GITHUB_REPOSITORY_OWNER`, `github.actor`). + +The job runs nightly at 03:30 UTC and can also be triggered manually +(*workflow_dispatch*) with optional `only` (subset of package dirs) and +`replace` inputs. + +## Running locally + +```bash +# Dry run: show what would be built/published +./autopackage.py --dry-run + +# Build a subset without publishing +./autopackage.py --only openrgb-git paru --no-publish + +# Build everything and publish to a registry +AUTOPKG_REGISTRY_URL=https://git.example.com \ +AUTOPKG_OWNER=joe \ +AUTOPKG_TOKEN=xxxxxxxx \ +./autopackage.py --keep-going +``` + +makepkg must run as a non-root user with `sudo` available for dependency +installation. Builds happen in `./.build/` (gitignored). + +### Useful flags + +| Flag | Purpose | +| ---- | ------- | +| `--only NAME ...` | Build just these package dirs. | +| `--no-publish` | Build only; skip uploads. | +| `--dry-run` | Print actions without running them. | +| `--cleanbuild` | Pass `--cleanbuild` to makepkg. | +| `--sign` | Sign packages and upload `.sig` files. | +| `--replace` | Delete + re-upload on a version clash (HTTP 409). | +| `--keep-going` | Continue if a package fails. | + +Config can also come from env vars: `AUTOPKG_REGISTRY_URL`, `AUTOPKG_OWNER`, +`AUTOPKG_REPO`, `AUTOPKG_USER`, `AUTOPKG_TOKEN`, `AUTOPKG_REPLACE`. + +## Ignoring folders + +Top-level dirs `.git`, `.gitea`, `.github`, `.build`, `scripts`, `dist` and +`__pycache__` are never treated as packages. Add more (one name per line) in a +`.autopackageignore` file at the repo root. diff --git a/autopackage.py b/autopackage.py new file mode 100644 index 0000000..04018ad --- /dev/null +++ b/autopackage.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +""" +autopackage.py + +This is a rewrite/port of my original autopackage python script, that i used +to just keep to myself but... now im just trusting enough of my personal supply chain +that i think im comfy sharing package builds... at least for friends! + +I mostly intend for CI to run this script but, if you want to make tweaks or experiment locally +thats super fine too! + +Examples: + ./autopackage.py --dry-run + ./autopackage.py --only openrgb-git paru + ./autopackage.py --no-publish # build everything, skip upload +""" + +from __future__ import annotations + +import argparse +import base64 +import os +import shlex +import shutil +import subprocess +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +AUR_BASE = "https://aur.archlinux.org" + +# Top-level directories that are never packages +DEFAULT_IGNORE = { + ".git", + ".gitea", + ".github", + ".build", + "scripts", + "dist", + "__pycache__", +} + +PKG_GLOBS = ("*.pkg.tar.zst", "*.pkg.tar.xz", "*.pkg.tar.gz", "*.pkg.tar.bz2") + + +@dataclass +class Config: + repo_root: Path + workdir: Path + makepkg_cmd: List[str] + sign: bool + keep_going: bool + dry_run: bool + publish: bool + replace: bool + # Gitea Arch registry settings + registry_url: str + owner: str + arch_repo: str + auth_user: str + token: str + ignore: set = field(default_factory=set) + + +# --------------------------------------------------------------------------- # +# Shell helpers +# --------------------------------------------------------------------------- # +def run(cmd: List[str], cwd: Optional[Path] = None, dry_run: bool = False, + check: bool = True) -> subprocess.CompletedProcess: + pretty = " ".join(shlex.quote(c) for c in cmd) + if cwd: + pretty = f"(cd {cwd}) {pretty}" + print(f"+ {pretty}", flush=True) + if dry_run: + return subprocess.CompletedProcess(cmd, 0) + return subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=check) + + +# --------------------------------------------------------------------------- # +# Discovery / classification +# --------------------------------------------------------------------------- # +def load_ignore(repo_root: Path) -> set: + ignore = set(DEFAULT_IGNORE) + ignore_file = repo_root / ".autopackageignore" + if ignore_file.is_file(): + for line in ignore_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#"): + ignore.add(line) + return ignore + + +def discover_packages(cfg: Config, only: List[str]) -> List[Path]: + pkgs: List[Path] = [] + for entry in sorted(cfg.repo_root.iterdir()): + if not entry.is_dir(): + continue + name = entry.name + if name.startswith(".") or name in cfg.ignore: + continue + if only and name not in only: + continue + pkgs.append(entry) + return pkgs + + +def is_custom(pkg_src: Path) -> bool: + return (pkg_src / "PKGBUILD").is_file() + + +# --------------------------------------------------------------------------- # +# Build preparation +# --------------------------------------------------------------------------- # +def reset_build_dir(build_dir: Path, dry_run: bool) -> None: + if dry_run: + print(f"+ rm -rf {build_dir}") + return + if build_dir.exists(): + shutil.rmtree(build_dir) + build_dir.parent.mkdir(parents=True, exist_ok=True) + + +def prepare_custom(pkg_src: Path, build_dir: Path, dry_run: bool) -> None: + print(f" custom package -> copying sources into {build_dir}") + if dry_run: + print(f"+ cp -r {pkg_src} {build_dir}") + return + shutil.copytree(pkg_src, build_dir, ignore=shutil.ignore_patterns(".git")) + + +def prepare_aur(pkg_src: Path, build_dir: Path, cfg: Config) -> None: + name = pkg_src.name + aur_url = f"{AUR_BASE}/{name}.git" + print(f" AUR package -> cloning {aur_url}") + run(["git", "clone", "--depth", "1", aur_url, str(build_dir)], dry_run=cfg.dry_run) + apply_overrides(pkg_src, build_dir, cfg) + + +def apply_overrides(pkg_src: Path, build_dir: Path, cfg: Config) -> None: + patches = sorted( + p for p in pkg_src.iterdir() + if p.is_file() and p.suffix in (".patch", ".diff") + ) + overrides = sorted( + p for p in pkg_src.iterdir() + if p.is_file() + and p.suffix not in (".patch", ".diff") + and p.name != ".gitkeep" + ) + + for ov in overrides: + dest = build_dir / ov.name + print(f" override: {ov.name}") + if cfg.dry_run: + print(f"+ cp {ov} {dest}") + continue + shutil.copy2(ov, dest) + + for patch in patches: + print(f" patch: {patch.name}") + apply_patch(patch, build_dir, cfg.dry_run) + + +def apply_patch(patch: Path, build_dir: Path, dry_run: bool) -> None: + # Use `git apply` first if theres a patch that i have in there + git_apply = run( + ["git", "apply", "--whitespace=nowarn", str(patch)], + cwd=build_dir, dry_run=dry_run, check=False, + ) + if git_apply.returncode == 0: + return + if dry_run: + return + print(f" (git apply failed, trying patch -p1) {patch.name}") + with patch.open("rb") as fh: + result = subprocess.run( + ["patch", "-Np1", "--forward"], + cwd=str(build_dir), stdin=fh, + ) + if result.returncode != 0: + raise RuntimeError(f"failed to apply patch {patch.name}") + + +# --------------------------------------------------------------------------- # +# Build +# --------------------------------------------------------------------------- # +def find_artifacts(build_dir: Path, want_sig: bool) -> List[Path]: + found: List[Path] = [] + for pat in PKG_GLOBS: + found.extend(build_dir.glob(pat)) + pkgs = [p for p in found if p.is_file() and not p.name.endswith(".sig")] + pkgs.sort(key=lambda p: p.stat().st_mtime, reverse=True) + artifacts: List[Path] = [] + for p in pkgs: + artifacts.append(p) + if want_sig: + sig = p.with_name(p.name + ".sig") + if sig.is_file(): + artifacts.append(sig) + return artifacts + + +def build_one(pkg_src: Path, cfg: Config) -> List[Path]: + name = pkg_src.name + build_dir = cfg.workdir / name + print(f"\n=== {name} ===") + + reset_build_dir(build_dir, cfg.dry_run) + if is_custom(pkg_src): + prepare_custom(pkg_src, build_dir, cfg.dry_run) + else: + prepare_aur(pkg_src, build_dir, cfg) + + run(cfg.makepkg_cmd, cwd=build_dir, dry_run=cfg.dry_run) + + if cfg.dry_run: + return [] + + artifacts = find_artifacts(build_dir, want_sig=cfg.sign) + pkg_files = [a for a in artifacts if not a.name.endswith(".sig")] + if not pkg_files: + raise RuntimeError(f"{name}: build finished but no *.pkg.tar.* produced") + print(f" built: {', '.join(a.name for a in pkg_files)}") + return artifacts + + +# --------------------------------------------------------------------------- # +# Publish to Gitea Arch registry +# --------------------------------------------------------------------------- # +def parse_pkg_filename(filename: str) -> Optional[tuple]: + """Return (name, version, arch) from ---.pkg.tar.zst.""" + base = filename + for suffix in (".pkg.tar.zst", ".pkg.tar.xz", ".pkg.tar.gz", ".pkg.tar.bz2"): + if base.endswith(suffix): + base = base[: -len(suffix)] + break + else: + return None + parts = base.rsplit("-", 3) + if len(parts) != 4: + return None + name, ver, rel, arch = parts + return name, f"{ver}-{rel}", arch + + +def registry_base(cfg: Config) -> str: + return f"{cfg.registry_url.rstrip('/')}/api/packages/{cfg.owner}/arch/{cfg.arch_repo}" + + +def auth_header(cfg: Config) -> str: + raw = f"{cfg.auth_user}:{cfg.token}".encode("utf-8") + return "Basic " + base64.b64encode(raw).decode("ascii") + + +def http_request(url: str, method: str, cfg: Config, + data: Optional[bytes] = None) -> tuple: + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", auth_header(cfg)) + if data is not None: + req.add_header("Content-Type", "application/octet-stream") + try: + with urllib.request.urlopen(req) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + + +def delete_version(filename: str, cfg: Config) -> None: + meta = parse_pkg_filename(filename) + if not meta: + return + name, version, arch = meta + url = f"{registry_base(cfg)}/{name}/{version}/{arch}" + status, body = http_request(url, "DELETE", cfg) + print(f" delete {name} {version} {arch} -> {status}") + + +def publish_file(path: Path, cfg: Config) -> None: + url = registry_base(cfg) + if cfg.dry_run: + print(f"+ PUT {url} ({path.name})") + return + data = path.read_bytes() + status, body = http_request(url, "PUT", cfg, data=data) + if status in (200, 201): + print(f" uploaded {path.name} -> {status}") + return + if status == 409 and cfg.replace: + print(f" {path.name} exists; replacing") + delete_version(path.name, cfg) + status, body = http_request(url, "PUT", cfg, data=data) + if status in (200, 201): + print(f" uploaded {path.name} -> {status}") + return + if status == 409: + print(f" {path.name} already published (409); skipping " + f"(use --replace to overwrite)") + return + raise RuntimeError( + f"upload of {path.name} failed: HTTP {status}: {body.strip()[:500]}" + ) + + +def publish(artifacts: List[Path], cfg: Config) -> None: + for art in artifacts: + publish_file(art, cfg) + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def env(name: str, default: str = "") -> str: + return os.environ.get(name, default) + + +def build_config(args: argparse.Namespace) -> Config: + repo_root = args.repo_root.resolve() + + makepkg_cmd = ["makepkg", "-s", "--noconfirm", "--needed"] + if args.cleanbuild: + makepkg_cmd.append("--cleanbuild") + if args.sign: + makepkg_cmd.append("--sign") + + cfg = Config( + repo_root=repo_root, + workdir=args.workdir.resolve(), + makepkg_cmd=makepkg_cmd, + sign=args.sign, + keep_going=args.keep_going, + dry_run=args.dry_run, + publish=not args.no_publish, + replace=args.replace, + registry_url=args.registry_url, + owner=args.owner, + arch_repo=args.arch_repo, + auth_user=args.auth_user or args.owner, + token=args.token, + ignore=load_ignore(repo_root), + ) + return cfg + + +def main(argv: List[str]) -> int: + ap = argparse.ArgumentParser( + description="Nightly directory-driven Arch package builder for Gitea CI", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + ap.add_argument("--repo-root", type=Path, default=Path.cwd(), + help="Repo root to scan for package directories") + ap.add_argument("--workdir", type=Path, default=Path.cwd() / ".build", + help="Scratch directory for clones/builds") + ap.add_argument("--only", nargs="*", default=[], + help="Only build these package directory names") + ap.add_argument("--cleanbuild", action="store_true", + help="Pass --cleanbuild to makepkg") + ap.add_argument("--sign", action="store_true", + help="Sign packages (makepkg --sign) and upload .sig files") + ap.add_argument("--keep-going", action="store_true", + help="Continue with remaining packages if one fails") + ap.add_argument("--dry-run", action="store_true", + help="Print actions without executing them") + ap.add_argument("--no-publish", action="store_true", + help="Build only; do not upload to the registry") + ap.add_argument("--replace", action="store_true", + default=env("AUTOPKG_REPLACE", "") not in ("", "0", "false"), + help="Delete + re-upload when a file already exists (409)") + + # Gitea Arch registry (CLI overrides env; env defaults suit Gitea Actions) + ap.add_argument("--registry-url", default=env("AUTOPKG_REGISTRY_URL", + env("GITHUB_SERVER_URL")), + help="Gitea base URL, e.g. https://git.example.com") + ap.add_argument("--owner", default=env("AUTOPKG_OWNER", + env("GITHUB_REPOSITORY_OWNER")), + help="Package registry owner (user or org)") + ap.add_argument("--arch-repo", default=env("AUTOPKG_REPO", "arch"), + help="Registry repository/group name (the [repo] in pacman.conf)") + ap.add_argument("--auth-user", default=env("AUTOPKG_USER", + env("GITHUB_ACTOR")), + help="Username for HTTP basic auth (defaults to owner)") + ap.add_argument("--token", default=env("AUTOPKG_TOKEN", + env("PACKAGE_TOKEN", env("GITHUB_TOKEN"))), + help="Gitea token with write:package scope") + + args = ap.parse_args(argv) + cfg = build_config(args) + + pkgs = discover_packages(cfg, args.only) + if not pkgs: + print("No package directories found.", file=sys.stderr) + return 2 + + print(f"Discovered {len(pkgs)} package(s): " + f"{', '.join(p.name for p in pkgs)}") + + if cfg.publish and not cfg.dry_run: + missing = [n for n, v in (("registry-url", cfg.registry_url), + ("owner", cfg.owner), + ("token", cfg.token)) if not v] + if missing: + print(f"Cannot publish: missing {', '.join(missing)}. " + f"Set the matching env/secret or pass --no-publish.", + file=sys.stderr) + return 2 + + failures: List[str] = [] + for pkg_src in pkgs: + try: + artifacts = build_one(pkg_src, cfg) + if cfg.publish and not cfg.dry_run: + publish(artifacts, cfg) + elif cfg.publish and cfg.dry_run: + print(f" (dry-run) would publish artifacts for {pkg_src.name}") + except (subprocess.CalledProcessError, RuntimeError, OSError) as e: + print(f"✗ {pkg_src.name}: {e}", file=sys.stderr) + failures.append(pkg_src.name) + if not cfg.keep_going: + break + + if failures: + print(f"\nFailed: {', '.join(failures)}", file=sys.stderr) + return 1 + + print("\nAll done.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/bacula/.gitkeep b/bacula/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/fchat-horizon-appimage/.gitkeep b/fchat-horizon-appimage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ggml-git-latest-commit/.gitkeep b/ggml-git-latest-commit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/openrgb-git/.gitkeep b/openrgb-git/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/paru/.gitkeep b/paru/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plezy-bin/0001-sentry-native-inproc-backend.patch b/plezy-bin/0001-sentry-native-inproc-backend.patch new file mode 100644 index 0000000..24322f2 --- /dev/null +++ b/plezy-bin/0001-sentry-native-inproc-backend.patch @@ -0,0 +1,19 @@ +diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt +index 29b0af464..721fa7864 100644 +--- a/linux/CMakeLists.txt ++++ b/linux/CMakeLists.txt +@@ -81,6 +81,14 @@ set_target_properties(${BINARY_NAME} + ) + + ++# Build sentry-native with the in-process backend instead of crashpad. The ++# crashpad backend forks a handler and calls setsid(), which fails (EPERM) when ++# plezy is launched as a systemd app scope / session leader, tripping a fatal ++# CHECK in crashpad that SIGTRAPs on startup. inproc installs in-process signal ++# handlers and avoids the fork/setsid path. Overrides the plugin's forced ++# SENTRY_BACKEND=crashpad via the env-var escape hatch in sentry-native.cmake. ++set(ENV{SENTRY_NATIVE_BACKEND} "inproc") ++ + # Generated plugin build rules, which manage building the plugins and adding + # them to the application. + include(flutter/generated_plugins.cmake) diff --git a/qownnotes/.gitkeep b/qownnotes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ventoy-bin/.gitkeep b/ventoy-bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/visual-studio-code-bin/.gitkeep b/visual-studio-code-bin/.gitkeep new file mode 100644 index 0000000..e69de29