Files
TZGyn-skill/SKILL.md
T

24 KiB

name, description
name description
TZGyn-skill Use proactively for ANY development task in TZGyn's working directories — starting a new project in any language (Go, Rust, TypeScript, not just Swift), cloning/studying/porting a third-party repo (reference material goes in .reference/, never the working root), working in an existing TZGyn repo, creating Gitea repos, publishing to the TZGyn Homebrew tap (casks for GUI apps, formulae for CLIs), signing and notarizing macOS builds, cutting releases, or extending this skill. Load it at the start of any dev session on this machine even when the task doesn't mention Gitea, Homebrew, or macOS.

TZGyn dev skill

Personal conventions for TZGyn projects. Source of truth is the Git repo https://gitea.tzgyn.dev/TZGyn/TZGyn-skill; it is installed as a global Crush skill at ~/.agents/skills/TZGyn-skill (a symlink to the checkout, so editing the repo updates the live skill).

The reference implementations to copy from are ~/Git/kapture and ~/Git/konference (macOS menu-bar apps); for iOS, ~/Git/koins/apps/ios.

Ground rules

  • Applies from the first command of any session, in any TZGyn working directory — before any clone, scaffold, or build. Third-party repos cloned to study, port, or compare against go straight into .reference/ (section 2); waiting until the project is a git repo is too late.
  • Host: Gitea at https://gitea.tzgyn.dev, owner TZGyn, tea login name gitea.tzgyn.dev. SSH: git@gitea.tzgyn.dev:TZGyn/<repo>.git.
  • Always print the upgrade script (the brew commands below) in the final answer after any release or version bump. The user wants it every time.
  • Every macOS GUI release must be notarized and stapled. An unnotarized build is not a release: never publish one, and never paper over Gatekeeper with xattr -dr com.apple.quarantine or brew install --cask --no-quarantine. If notarization fails, fix the cause (see Troubleshooting) before publishing. (iOS apps are not notarized — Apple has no such step; see section 6.)
  • Never commit build artifacts. .gitignore covers .build/, *.app/, dist/, .swiftpm/, xcuserdata/, DerivedData/, .DS_Store. (go build ./... drops a binary next to the source — ignore it explicitly.)
  • Never commit secrets (.p8, API keys) or push to a remote unless asked.
  • Reference material goes in .reference/, never in the repo. Every project that is modelled on another app or repo gets a .reference/ folder with a .gitignore containing * — see Reference material for the exact shape and what to put in it.
  • Commit messages: concise, why-focused, first line < 72 chars; add a body only when it helps a future reader.
  • Prefer SwiftPM (Swift/SwiftUI) for macOS apps; no external dependencies.

1. New project on Gitea

# 1. Create the repo (as the authenticated user).
tea repos create --name <repo> --desc "<one-line description>" --login gitea.tzgyn.dev
#    Do NOT pass --owner TZGyn: that hits /orgs/TZGyn/repos and 404s for a user.

# 2. Initialise and push.
cd ~/Git/<repo>
git init -b main
git add -A && git commit -m "<why>"
git remote add origin https://gitea.tzgyn.dev/TZGyn/<repo>.git
git push -u origin main

Scaffold every repo with README.md, AGENTS.md (architecture + commands + gotchas), .gitignore, and .reference/ (see below). See ~/Git/simpletopbar for the minimal shape: Package.swift, Sources/<Name>/*.swift, build-app.sh, release.sh, scripts/.

2. Reference material (.reference/)

Apps and repos that a project is modelled on are research input, not source code. Keep them in an untracked .reference/ folder at the repo root so they are available to every session without ever entering the repository:

mkdir -p ~/Git/<repo>/.reference
printf '*\n' > ~/Git/<repo>/.reference/.gitignore
  • The .gitignore inside .reference/ contains a single *. Ignore the whole folder there, not in the repo's root .gitignore: the folder is then self-contained and stays untracked even if the root ignore file is rewritten.
  • Any repo cloned to study, port, or compare against is reference material. When asked to "check out this repo" (e.g. to reimplement it in another language), create .reference/ and its .gitignore as the very first step and clone straight into it — never into the working root, even when the project is not a git repo yet.
  • Clone reference repos inside it: git clone --depth 1 <url> .reference/<name>. Do not vendor them into Sources/ or vendor/.
  • Write one short note per reference (tangerine.md, filee.md) covering: what the app does, its interaction model, the formats/features worth matching, and what we deliberately skip. Notes are untracked too — that is fine; they are for the next session, not for the public repo.
  • Check licences before reusing code. MIT/Apache-2.0 code can be adapted with attribution; never copy assets, icons, or code from a closed app.
  • .reference/ is never committed, never released, and never required to build.

3. Homebrew tap

  • The tap clone lives at ~/.cache/homebrew-tap (origin https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git).

  • Layout: GUI apps → Casks/<name>.rb; CLI tools → Formula/<name>.rb.

  • After pushing, sync Homebrew's own clone:

    git -C ~/.cache/homebrew-tap add -A
    git -C ~/.cache/homebrew-tap commit -m "Add <name> cask"
    git -C ~/.cache/homebrew-tap push
    git -C /opt/homebrew/Library/Taps/tzgyn/homebrew-tap pull --ff-only
    brew audit --cask tzgyn/tap/<name>     # or: brew audit --formula tzgyn/tap/<name>
    
  • Users tap once (non-GitHub taps need the full URL):

    brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git
    brew trust TZGyn/tap
    

Cask (GUI app)

cask "<name>" do
  version "X.Y.Z"
  sha256 "<sha256 of the release zip>"

  url "https://gitea.tzgyn.dev/TZGyn/<repo>/releases/download/v#{version}/<Asset>.zip"
  name "<Display Name>"
  desc "<one line>"
  homepage "https://gitea.tzgyn.dev/TZGyn/<repo>"

  app "<Name>.app"
end

Formula (CLI)

class <Name> < Formula
  desc "<one line>"
  homepage "https://gitea.tzgyn.dev/TZGyn/<repo>"
  url "https://gitea.tzgyn.dev/TZGyn/<repo>/releases/download/v0.1.0/<asset>-0.1.0-darwin-arm64.tar.gz"
  sha256 "<sha256 of the tarball>"
  version "0.1.0"

  def install
    bin.install "<binary>"
  end
end

4. Releasing a macOS GUI app

Two scripts per repo: build-app.sh <version> and release.sh <version>.

  1. Build + sign (build-app.sh): swift build -c release → handwritten Info.plist (NOT generated from Package.swift) → make-icon.sh → sign with the Developer ID identity resolved from the login keychain (security find-identity -v -p codesigning | awk '/Developer ID Application:/{print $2;exit}'), falling back to ad-hoc (-). GUI apps set LSUIElement = true and CFBundleIdentifier = dev.tzgyn.<name>.

  2. Notarize + staple + zip (release.sh): build → ditto -c -k --keepParent <App>.app dist/<App>.zip → submit to Apple → staple → re-zip → print sha256. See Notarization for the credentials.

  3. Publish the release and attach the zip:

    tea releases create --tag v<version> --title "<App> <version>" \
      --note "<what changed>" --asset dist/<App>.zip \
      --repo TZGyn/<repo> --login gitea.tzgyn.dev
    

    Push main before this: tea tags the remote default branch, so an unpushed commit means the tag points at source that is not what shipped (the binary would contain changes absent from the tag). If it already happened, re-point the tag: git push origin :refs/tags/v<version> then git tag -f v<version> <commit> && git push origin v<version>.

  4. Bump the cask (version + sha256), push the tap, pull Homebrew's clone, brew audit. Verify by downloading the asset back and comparing the sha256, and spctl -a -vv <App>.app → accepted / source=Notarized Developer ID. If spctl does not say source=Notarized Developer ID, stop and fix notarization first — do not publish the release or the cask.

  5. Print the upgrade script:

    brew update
    brew upgrade --cask <name>
    

5. Notarization

Signing identity: Developer ID Application: Teh Zi Gyn (RX6767S6BW). App Store Connect API key (shared with Kapture):

Value Where
Key file ~/.config/kapture/AuthKey_K5KXMMSB28.p8
Key ID K5KXMMSB28
Issuer ID 3e25e5dc-8b75-453f-a71c-c7f3feaac628 (not secret)
Team ID RX6767S6BW

Signing must use a hardened runtime and a secure timestamp:

codesign --force --options runtime --timestamp --sign "$IDENT" "<App>.app"

Submit and verify:

xcrun notarytool submit dist/<App>.zip \
  --key "$HOME/.config/kapture/AuthKey_K5KXMMSB28.p8" \
  --key-id K5KXMMSB28 --issuer 3e25e5dc-8b75-453f-a71c-c7f3feaac628 \
  --wait --output-format json
xcrun stapler staple <App>.app && xcrun stapler validate <App>.app
spctl -a -vv <App>.app      # want: accepted, source=Notarized Developer ID

release.sh must notarize. Treat any skip or failure as a failed release: do not publish the tag, the release, or the cask, and do not suggest quarantine workarounds (xattr -dr com.apple.quarantine, brew install --cask --no-quarantine) — those hand the user a build macOS rejects. Only publish once spctl -a -vv <App>.app reports accepted with source=Notarized Developer ID.

Notarization troubleshooting

Symptom Cause / fix
HTTP status code: 403. A required agreement is missing or has expired. The Apple Developer Program License Agreement for the team is unsigned or expired. The Account Holder must accept it in App Store Connect → Business → Agreements and at developer.apple.com/account → Agreements. Account-wide: every call fails, including read-only notarytool history, until it is accepted; allow a few minutes to propagate.
The same 403 after accepting The wrong agreement was accepted (App Store Connect "Paid Apps" instead of the Program License Agreement) or it was accepted under a different team's account.
401 Authentication credentials are incorrect Wrong key id / issuer, or the key belongs to another team.

Confirm the team before blaming the credentials — do not "fix" this by switching teams or re-signing:

security find-identity -v -p codesigning     # the Developer ID team must match the key's team
spctl -a -vv /Applications/<App>.app         # an already-notarized app proves the team/key pair works
xcrun notarytool history --key ... --key-id ... --issuer ...   # 403 here = account agreement, not the app

notarytool history failing the same way as the submit is the tell: the problem is the account, not the bundle, so there is nothing to change in the app or the scripts.

6. iOS apps

Native SwiftUI iOS apps are built with XcodeGen — no CocoaPods, no SwiftPM packages. project.yml is the source of truth; the generated <Name>.xcodeproj/ is gitignored and regenerated after any file is added or removed. Working reference: ~/Git/koins/apps/ios.

One-time tool install: brew install xcodegen.

Layout and project file

apps/ios/
  project.yml               XcodeGen project (committed)
  <Name>/                   Swift sources, assets, Info.plist
  <Name>/Bridge/            optional ObjC/ObjC++ bridge + bridging header
  <Name>/Libs/              optional prebuilt static archives (not bundled)
  .gitignore                <Name>.xcodeproj/, build/, DerivedData/, xcuserdata/

Minimal project.yml:

name: <Name>
options:
  bundleIdPrefix: dev.tzgyn
  deploymentTarget:
    iOS: "16.0"
  xcodeVersion: "16.0"

settings:
  base:
    SWIFT_VERSION: "5.0"
    ENABLE_USER_SCRIPT_SANDBOXING: NO

targets:
  <Name>:
    type: application
    platform: iOS
    sources:
      - path: <Name>
        excludes:
          - "Libs"          # static archives are linked, never bundled
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: dev.tzgyn.<name>
        CODE_SIGN_STYLE: Automatic
        DEVELOPMENT_TEAM: RX6767S6BW
        CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]: NO
        SWIFT_OBJC_BRIDGING_HEADER: <Name>/Bridge/<Name>-Bridging-Header.h
        LIBRARY_SEARCH_PATHS:
          - "$(SRCROOT)/<Name>/Libs"
        OTHER_LDFLAGS[sdk=iphoneos*]:
          - "$(inherited)"
          - "-l<name>_device"
        OTHER_LDFLAGS[sdk=iphonesimulator*]:
          - "$(inherited)"
          - "-l<name>_merged"
    info:
      path: <Name>/Info.plist
      properties:
        UILaunchScreen: {}
        UISupportedInterfaceOrientations:
          - UIInterfaceOrientationPortrait
        UIUserInterfaceStyle: Dark
        NSCameraUsageDescription: Scan a QR code.
        NSFaceIDUsageDescription: Unlock the app and approve payments.
  • Bundle id convention: dev.tzgyn.<name> (Koins predates it and uses com.koins.app; keep an existing id rather than migrating).
  • Privacy usage strings are mandatory. A missing NSCameraUsageDescription, NSPhotoLibraryUsageDescription, or NSFaceIDUsageDescription crashes the app the first time that API is used.
  • Prebuilt static archives need separate device and simulator builds; select them with OTHER_LDFLAGS[sdk=…] as above. If ObjC categories fail to load, link with -Wl,-force_load,$(SRCROOT)/<Name>/Libs/lib<name>.a instead.
  • The simulator target uses CODE_SIGNING_ALLOWED[sdk=iphonesimulator*] = NO; device builds use automatic signing with the TZGyn team.

After adding/removing files or changing project.yml:

cd apps/ios && xcodegen generate

Build

cd apps/ios
xcodegen generate

# Simulator (Apple Silicon)
xcodebuild -project <Name>.xcodeproj -scheme <Name> -configuration Debug \
  -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
  ARCHS=arm64 ONLY_ACTIVE_ARCH=NO -derivedDataPath /tmp/<name>-dd build

# Device
xcodebuild -project <Name>.xcodeproj -scheme <Name> -configuration Debug \
  -sdk iphoneos -destination 'generic/platform=iOS' \
  -allowProvisioningUpdates -derivedDataPath /tmp/<name>-dd-device build

-allowProvisioningUpdates creates/refreshes the development profile automatically; no manual profile management is needed with the paid team. Derived data under /tmp is lost on reboot — rebuild rather than chasing a missing .app.

Install on a physical iPhone (over the network, no cable)

This is the normal way to update a TZGyn iPhone app: build the iphoneos slice, then install over Wi-Fi with devicectl. simctl only works with simulators.

One-time setup:

  1. Connect the iPhone once with a cable, open Xcode → Window → Devices and Simulators, select the phone, and tick Connect via network.
  2. On the phone: Settings → Privacy & Security → Developer Mode → On (requires a reboot), then trust the Mac when prompted.

Wireless install and launch:

# 1. Find the paired phone (UDID, ECID, name and DNS name are all accepted).
xcrun devicectl list devices
# want: <phone name>  <phone>.coredevice.local  <UDID>  available (paired)

# 2. Install the device build. This replaces the app in place and keeps its data.
xcrun devicectl device install app --device <UDID|name> --timeout 180 \
  /tmp/<name>-dd-device/Build/Products/Debug-iphoneos/<Name>.app

# 3. Launch it (the phone must be unlocked).
xcrun devicectl device process launch --device <UDID|name> <bundle-id>
  • Use the device build path (-dd-device/.../Debug-iphoneos), not the simulator build; installing the wrong slice fails.
  • --timeout 180 is deliberate: the app bundle transfer over Wi-Fi can take a while, and the default timeout is easy to hit.
  • Installing over the network does not delete app data. Wallet files, seeds and preferences survive updates (seeds live in the Keychain).
  • If the device shows unavailable, or install fails with Network.NWError error 60 - Operation timed out, the phone is asleep, locked, or off Wi-Fi. Wake/unlock it, keep it on the same network, retry. A timed-out install leaves the previous build installed.
  • Launch errors with BSErrorCodeDescription = Locked mean exactly that: the phone must be unlocked for the launch.
  • devicectl is for physical devices only; use simctl for simulators.

Simulator workflow

xcrun simctl list devices available
xcrun simctl boot <UDID>
open -a Simulator

xcrun simctl install <UDID> /tmp/<name>-dd/Build/Products/Debug-iphonesimulator/<Name>.app
xcrun simctl launch <UDID> <bundle-id>
xcrun simctl io <UDID> screenshot /tmp/screen.png
xcrun simctl get_app_container <UDID> <bundle-id> data
  • Files the app writes (self-test reports, exported data) can be read from the data container printed by get_app_container.
  • Simulator preferences are cached by cfprefsd; xcrun simctl spawn <UDID> defaults delete … often does not stick. To reset a preference: terminate the app, xcrun simctl spawn <UDID> launchctl stop com.apple.cfprefsd.xpc.daemon, then edit or delete the key in the plist directly.

Local self-tests

Gate verification code behind launch arguments, write the report into the app's Documents folder, and read it back from the container (console output is not always reachable):

xcrun simctl launch <UDID> <bundle-id> -<name>SelfTest
DATA=$(xcrun simctl get_app_container <UDID> <bundle-id> data)
cat "$DATA/Documents/<name>-selftest.txt"

For unified-log checks:

xcrun simctl spawn <UDID> log show --last 1m \
  --predicate 'subsystem == "<bundle-id>"' --style compact

Keep network tests explicit (-<name>SelfTestNetwork) so the default launch is offline and fast.

Signing and distribution

  • Team: RX6767S6BW for every TZGyn app, macOS and iOS. iOS device builds use automatic signing (CODE_SIGN_STYLE: Automatic, DEVELOPMENT_TEAM: RX6767S6BW) and -allowProvisioningUpdates; no manual profiles.
  • iOS apps are not notarized. Apple has no notarization step for iOS — device installs are gated by code signing + Developer Mode. Do not add a notarytool/stapler step to an iOS build, and do not spctl an iphoneos app.
  • macOS GUI releases are always notarized and stapled (section 5), with the shared App Store Connect key: ~/.config/kapture/AuthKey_K5KXMMSB28.p8, key ID K5KXMMSB28, issuer 3e25e5dc-8b75-453f-a71c-c7f3feaac628, team RX6767S6BW. Never publish a macOS release until spctl -a -vv reports accepted / source=Notarized Developer ID.
  • App Store Connect (only if TestFlight/App Store is ever used) reuses the same key; koins does not use it yet.
  • Android is not codified yet; ask which stack (native, Compose, RN, EAS) before assuming.

7. Extending this skill (self-improvement)

This skill is meant to grow. When the user asks for a new behaviour, preference, or convention:

  1. Edit the relevant section of SKILL.md (add a new section if it is a new topic). Keep it concrete: exact commands, paths, and templates.

  2. Keep it truthful — only document what has actually worked; delete steps that became stale (e.g. a changed path or flag).

  3. Commit and push:

    cd ~/Git/TZGyn-skill
    git add -A && git commit -m "<what behaviour was added>"
    git push
    
  4. The installed copy is a symlink, so it updates immediately; a new Crush session picks it up. Confirm with crush_info (the skill should appear under [skills]).

Prefer updating this skill over repeating ad-hoc instructions in a project: if a convention applies to more than one repo, it belongs here.

8. Coolify

Host: https://coolify.tzgyn.dev. API: $COOLIFY_URL/api/v1, bearer $COOLIFY_TOKEN (keep the token in an env var or secret manager; never commit it). The UI and API are the same deployment.

Labels are the config

  • Coolify stores an app's labels in custom_labels as base64. The UI calls the field Container Labels; in v4.5 it is under General → Container → Labels (older versions place it under General/Advanced).

  • is_container_label_readonly_enabled (UI: Label management):

    • true — “Managed by Coolify (auto-generated)”: Coolify may regenerate labels from domains/proxy settings and drop manual additions.
    • false — “Managed manually (edit labels yourself)”: custom_labels is kept verbatim; domain/proxy changes no longer update it. Set it to false whenever you add labels Coolify does not generate.
  • API-created apps do not get the generated HTTP labels merged. Put the complete set (HTTP routers, middlewares, certresolver, Caddy HTTP labels, and any TCP routers) in custom_labels.

  • PATCH only the field, preserving existing labels:

    curl -X PATCH -H "Authorization: Bearer $COOLIFY_TOKEN" \
      -H 'Content-Type: application/json' \
      -d "{\"custom_labels\":\"$(base64 < labels.txt)\"}" \
      "$COOLIFY_URL/api/v1/applications/$APP"
    

    Decode it back and verify. Nested/double base64 can appear after API updates.

  • After every app update/recreation/deploy, check the TCP lines are still there. If they vanish, Traefik terminates the SNI with its default certificate.

HTTP vs raw TCP

  • Domains only creates HTTP(S) routers (Traefik terminates TLS). Do not add a wildcard domain when the proxy must pass TLS through — that would make Traefik ask for a wildcard certificate and decrypt tenant traffic.

  • For SNI passthrough (tunnels, non-HTTP services), add the full set:

    traefik.tcp.routers.<name>.entrypoints=https
    traefik.tcp.routers.<name>.rule=HostSNIRegexp(`^.+\.<domain>$`)
    traefik.tcp.routers.<name>.service=<name>
    traefik.tcp.routers.<name>.tls.passthrough=true
    traefik.tcp.services.<name>.loadbalancer.server.port=8443
    

    Traefik dials the container port over the coolify Docker network; no host port publish is needed. The app must listen on that port. The ^.+\. regex does not match the apex, so the API domain can stay on the HTTP router.

  • Keep the HTTP labels in the same custom_labels value when using a manual label set; otherwise the dashboard/API router disappears.

API and deploys

  • Deploy: POST $COOLIFY_URL/api/v1/deploy?uuid=<app>.
  • App state: GET /api/v1/applications/<uuid> (status, updated_at).
  • App logs: GET /api/v1/applications/<uuid>/logs (JSON logs string).
  • The deployment list JSON can contain invalid escapes/HTML; jq sometimes fails. Parse with Python (json.load, then join logs[].output). Prefer the specific deployment endpoint or app status over a long poll of /deployments.
  • A deploy can transiently report running:unhealthy; wait for the app and hit /health before debugging.
  • Env vars: is_runtime and is_buildtime; preview and production entries can duplicate the same key. Secrets belong in Coolify envs, not the repo.

Gitea webhook

  • Push-to-deploy uses Coolify's manual Gitea webhook: X-Hub-Signature-256 = HMAC-SHA256 of the raw body with the app's manual_webhook_secret_gitea; Gitea sends this natively. A curl replay needs X-Gitea-Event: push and the signature.

  • Gitea's hook edit API does not update the stored secret. Delete and recreate the hook (branch filter main). The app's secret is regenerated when the app is recreated, so recreate the hook too.

  • The audit log is the oracle: webhook.deployment.queued vs webhook.gitea.signature_failed.

  • Coolify pins the pushed SHA (git fetch origin <sha>), but this Gitea only serves the current tip by SHA. If a later push lands before the fetch, the deploy fails with “not our ref”: re-trigger the deploy. Permanent fix inside the Gitea container:

    git -C /data/git/repositories/TZGyn/<repo>.git config \
      uploadpack.allowReachableSHA1InWant true
    
  • After any app recreation: recreate the webhook and re-check custom labels.

App creation/recreation

  • Use domains in the API (not fqdn). private_key_uuid cannot be changed on update; recreate the app with the key. Use storage type persistent for data volumes.
  • Verify after deploy: app healthy, the real TCP/SNI path (not just HTTP), custom labels present, and the webhook queued.