@olegkoval/agent-skills 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/.cursor-plugin/index.json +5 -0
  3. package/.grok-plugin/index.json +5 -0
  4. package/.kiro/steering/garmin-watchface.md +7 -0
  5. package/.kiro/steering/pr-finalize.md +140 -0
  6. package/.windsurf/rules/garmin-watchface.md +7 -0
  7. package/.windsurf/rules/pr-finalize.md +139 -0
  8. package/README.md +1 -0
  9. package/catalog/skills.json +30 -6
  10. package/package.json +1 -1
  11. package/packages/software-development/pr-finalize/SKILL.md +149 -0
  12. package/packages/software-development/pr-finalize/adapters/claude/plugin.json +5 -0
  13. package/packages/software-development/pr-finalize/adapters/claude/skills/pr-finalize/SKILL.md +150 -0
  14. package/packages/software-development/pr-finalize/adapters/codex/README.md +26 -0
  15. package/packages/software-development/pr-finalize/adapters/cursor/plugin.json +6 -0
  16. package/packages/software-development/pr-finalize/adapters/cursor/skills/pr-finalize/SKILL.md +151 -0
  17. package/packages/software-development/pr-finalize/adapters/grok/plugin.json +6 -0
  18. package/packages/software-development/pr-finalize/adapters/grok/skills/pr-finalize/SKILL.md +150 -0
  19. package/packages/software-development/pr-finalize/adapters/kiro/steering/pr-finalize.md +140 -0
  20. package/packages/software-development/pr-finalize/adapters/windsurf/rules/pr-finalize.md +139 -0
  21. package/packages/wearables/garmin-watchface/SKILL.md +7 -0
  22. package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/SKILL.md +7 -0
  23. package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/references/publishing.md +145 -0
  24. package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/scripts/ciq-release +333 -0
  25. package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/SKILL.md +7 -0
  26. package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/references/publishing.md +145 -0
  27. package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/scripts/ciq-release +333 -0
  28. package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/SKILL.md +7 -0
  29. package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/references/publishing.md +145 -0
  30. package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/scripts/ciq-release +333 -0
  31. package/packages/wearables/garmin-watchface/adapters/kiro/steering/garmin-watchface.md +7 -0
  32. package/packages/wearables/garmin-watchface/adapters/windsurf/rules/garmin-watchface.md +7 -0
  33. package/packages/wearables/garmin-watchface/references/publishing.md +145 -0
  34. package/packages/wearables/garmin-watchface/scripts/ciq-release +333 -0
@@ -0,0 +1,139 @@
1
+ <!-- Generated by scripts/build-adapters.sh. Do not edit directly. -->
2
+
3
+ ---
4
+ description: "Drives one GitHub PR to genuinely merge-ready: rebases and resolves conflicts, sweeps every review bot and human comment to zero unaddressed findings, verifies unit and E2E coverage actually exists, runs the repo's real lint/format/test gates before pushing, and reports what still needs a human."
5
+ ---
6
+
7
+ # PR Finalize
8
+
9
+ One PR, taken all the way to merge-ready. The job is not "make the bots stop talking" — it is **land the change in a state a careful reviewer would sign off on**, with evidence for every claim.
10
+
11
+ ## Inputs
12
+
13
+ - **PR number or URL** (optional): detect from the current branch if not given.
14
+ - `--no-push` (optional): do everything except the final push. Use when the repo auto-merges and the user wants to eyeball first.
15
+
16
+ ## Non-negotiables
17
+
18
+ These are the failure modes this skill exists to prevent. Violating one makes the whole run worthless:
19
+
20
+ - **Never report a gate as passing without having run it.** Paste the real output. "Tests should pass" is not a result.
21
+ - **Never resolve or claim-fix a finding you didn't actually fix.** Blocked is an honest outcome; a false "done" is not.
22
+ - **Never `git add -A`.** Stage only the files your fixes touched. Start from a clean tree so that's safe.
23
+ - **Never invent test coverage.** If the change has no browser-reachable surface, say so explicitly and name the suite that *is* the real gate. Do not add a hollow E2E test to look thorough.
24
+ - **A green CI badge is not proof.** Many repos split their required check from the full suite (browser-free half only, etc.). Find out what the required check actually runs.
25
+
26
+ ## Instructions
27
+
28
+ ### 1. Establish the ground truth
29
+
30
+ ```bash
31
+ gh pr view <PR> --json number,title,body,state,isDraft,headRefName,baseRefName,mergeable,mergeStateStatus,labels,reviewDecision,statusCheckRollup,url
32
+ ```
33
+
34
+ Record four things before touching code:
35
+
36
+ - **`mergeable`** — `CONFLICTING` means step 2 is mandatory.
37
+ - **Which checks are failing**, and for each, whether it is *required*. Check branch protection (`gh api repos/{owner}/{repo}/branches/<base>/protection`) rather than assuming — a red optional job (a flaky bot-review runner, a nightly) is not a blocker, and burning the run waiting on one is a classic waste.
38
+ - **Whether the repo auto-merges.** Look for a shepherd/automerge workflow in `.github/workflows/` and read its *gating conditions* (required labels, required checks). If pushing could trigger an immediate merge, decide deliberately: either satisfy the review sweep first, or tell the user before you push. Never let an auto-merge race a review you were asked to complete.
39
+ - **Whether a bot review actually ran.** A `SUCCESS` status from a review bot can still mean "rate-limited, never reviewed" — read its comment body, not just the check state.
40
+
41
+ ### 2. Rebase onto the base branch
42
+
43
+ Require a clean tree first (`git status --porcelain` empty), then:
44
+
45
+ ```bash
46
+ git fetch origin <base>
47
+ git rebase origin/<base>
48
+ ```
49
+
50
+ Resolve conflicts by **understanding both sides**, not by picking one. Read the base-side commit that introduced the conflicting hunk (`git log -1 --format=%s <base> -- <file>`, then the diff) — a conflict usually means main changed an invariant your branch is unaware of, and blindly keeping "your" side silently reverts it. State in the PR comment which invariant you preserved and why.
51
+
52
+ After resolving, re-run the tests before continuing — a rebase that compiles is not a rebase that works.
53
+
54
+ ### 3. Sweep every review source
55
+
56
+ Collect findings from all of these; do not stop at the first:
57
+
58
+ ```bash
59
+ # inline review threads (all bots + humans), with resolution state
60
+ gh api repos/{owner}/{repo}/pulls/<PR>/comments --paginate
61
+ # rollup issue comments (where CodeRabbit/Qodo post summaries and rate-limit notices)
62
+ gh api repos/{owner}/{repo}/issues/<PR>/comments --paginate
63
+ # formal reviews
64
+ gh pr view <PR> --json reviews
65
+ ```
66
+
67
+ **Delegate to the dedicated loop skill when one exists** rather than reimplementing its bot's protocol — `qodoloop` for Qodo, `coderabbitloop` for CodeRabbit, `greploop` for Greptile. Each already encodes that bot's posting shape, its ready-made per-finding fix prompt, and its real terminal states. Use this skill's own sweep for human comments and for bots without a dedicated loop.
68
+
69
+ Triage every finding into exactly one bucket:
70
+
71
+ | Bucket | Action |
72
+ |---|---|
73
+ | **Real defect** | Fix it. This is the point of the exercise. |
74
+ | **Legitimate improvement** | Fix it if it's in scope for this PR. |
75
+ | **False positive** | Reply explaining *specifically* why the bot is wrong. Do not resolve silently. |
76
+ | **Needs a human call** | Reply, leave open, surface in the final report. |
77
+
78
+ Bot findings are suggestions from something that has not run the code. A "🐞 Bug" label is a hypothesis — verify it against the actual source before fixing, and against the actual behavior before dismissing.
79
+
80
+ ### 4. Close the coverage gap
81
+
82
+ Ask, for the change as a whole, what would catch a regression here — then check whether the PR actually has it.
83
+
84
+ - **Unit / integration tests:** does every new branch have a test that fails if you invert it? Bots frequently and correctly flag helper functions exercised only *indirectly* through a caller. A direct test per branch is cheap and is usually the right response.
85
+ - **Browser / E2E tests:** determine whether the repo supports them (`playwright.config.*`, `cypress.config.*`, a `test:e2e` script) **and** whether this change has any surface they can reach. Then do one of exactly two things:
86
+ - it has a reachable surface → add or extend the E2E spec;
87
+ - it does not → **say so explicitly in the PR comment**, name why (e.g. "this is entirely inside a Durable Object alarm loop; the E2E harness only serves static assets"), and name the suite that is the real gate.
88
+
89
+ Silence here reads as an oversight. An explicit "not applicable, because X" reads as diligence — and is checkable.
90
+
91
+ ### 5. Run the real gates
92
+
93
+ Discover them from the repo (`Makefile`, `package.json` scripts, CI workflow) — do not guess target names. Run, at minimum, the equivalents of:
94
+
95
+ ```bash
96
+ <lint> # e.g. make lint / npm run lint
97
+ <format> # check mode first; only write if it reports drift
98
+ <tests> # the suite covering the changed package
99
+ ```
100
+
101
+ Capture exit codes, not just output. Repo-specific traps worth checking for before you burn a cycle: linters whose cache replays findings from deleted sibling worktrees, formatters whose local toolchain version outruns CI's (churning unrelated files), and test runners that hang when two runs overlap.
102
+
103
+ If a gate fails, fix it. Do not push red.
104
+
105
+ ### 6. Push and explain
106
+
107
+ Stage only the files you touched, commit, and push (`--force-with-lease` after a rebase — never bare `--force`).
108
+
109
+ Then post **one** PR comment that a reviewer can audit without re-reading the diff:
110
+
111
+ - what the rebase conflict was and which side won, with the reason;
112
+ - each finding, and what you did about it (including the ones you rejected, with the argument);
113
+ - the coverage decision from step 4, including the explicit not-applicable if that's the answer;
114
+ - **verbatim** gate output — real numbers (`# pass 480 / # fail 0`, `0 issues.`), not adjectives.
115
+
116
+ Re-trigger any bot that never actually reviewed (e.g. `@coderabbitai review`) so the PR gets the pass it was owed.
117
+
118
+ ### 7. Verify and report
119
+
120
+ Re-read the PR state after the push — confirm `mergeable` flipped, checks went green, and clear now-stale labels (`needs-rebase`, etc.). Poll re-triggered bots for their new pass and handle anything it raises.
121
+
122
+ ```text
123
+ PR #965 finalized.
124
+ Rebase: 1 conflict resolved (kept main's #834 D1-first ordering)
125
+ Findings: 2 addressed, 0 rejected, 0 blocked
126
+ Coverage: +5 unit tests; E2E not applicable (no browser surface) — stated on the PR
127
+ Gates: tests 480/480, lint 0 issues, format clean
128
+ State: MERGEABLE, checks green
129
+ Needs human: none
130
+ ```
131
+
132
+ If anything is left, say precisely what and why — an honest "blocked on X" is the deliverable when X is genuinely a human call.
133
+
134
+ ## Related skills
135
+
136
+ - `qodoloop` — full Qodo thread protocol (per-finding Agent Prompts, resolve mutations).
137
+ - `coderabbitloop` — full CodeRabbit protocol.
138
+ - `greploop` — drives a PR to a 5/5 Greptile confidence score.
139
+ - `ci-fix-loop` — when the blocker is a failing pipeline rather than review feedback.
@@ -25,6 +25,7 @@ looked like success first.
25
25
  | `references/simulator.md` | Screenshots, settings not applying, monkeydo hanging |
26
26
  | `references/devices.md` | Adding device support, launcher icons, API levels |
27
27
  | `references/store.md` | Publishing, listing copy, IP questions |
28
+ | `references/publishing.md` | Driving the store portal in a browser; upload/update flow, validator rejections |
28
29
 
29
30
  ## Tools
30
31
 
@@ -37,8 +38,14 @@ Run these rather than reinventing them. All are standalone.
37
38
  <skill-dir>/scripts/ciq-capture out.png # calibrated simulator screenshot
38
39
  <skill-dir>/scripts/ciq-capture out.png --face --size 260 # cropped + masked to the round display
39
40
  <skill-dir>/scripts/ciq-calibrate # re-derive the display rect if capture looks wrong
41
+ <skill-dir>/scripts/ciq-release # pre-submission check: package, screenshots, icon, keys, copy
40
42
  ```
41
43
 
44
+ `<skill-dir>/scripts/ciq-release` is what you run before opening the store
45
+ portal. Every check in it is something otherwise discovered halfway through the
46
+ submission form -- a stale screenshot set, an icon still copied from the last
47
+ project, or copy containing a character the description validator rejects.
48
+
42
49
  `<skill-dir>/scripts/ciq-capture` exists because `screencapture -R` grabs a screen *region*, not
43
50
  a window: without a frontmost check it silently photographs whatever is on top,
44
51
  and the window moves between simulator restarts. Both failure modes produce a
@@ -26,6 +26,7 @@ looked like success first.
26
26
  | `references/simulator.md` | Screenshots, settings not applying, monkeydo hanging |
27
27
  | `references/devices.md` | Adding device support, launcher icons, API levels |
28
28
  | `references/store.md` | Publishing, listing copy, IP questions |
29
+ | `references/publishing.md` | Driving the store portal in a browser; upload/update flow, validator rejections |
29
30
 
30
31
  ## Tools
31
32
 
@@ -38,8 +39,14 @@ Run these rather than reinventing them. All are standalone.
38
39
  <skill-dir>/scripts/ciq-capture out.png # calibrated simulator screenshot
39
40
  <skill-dir>/scripts/ciq-capture out.png --face --size 260 # cropped + masked to the round display
40
41
  <skill-dir>/scripts/ciq-calibrate # re-derive the display rect if capture looks wrong
42
+ <skill-dir>/scripts/ciq-release # pre-submission check: package, screenshots, icon, keys, copy
41
43
  ```
42
44
 
45
+ `<skill-dir>/scripts/ciq-release` is what you run before opening the store
46
+ portal. Every check in it is something otherwise discovered halfway through the
47
+ submission form -- a stale screenshot set, an icon still copied from the last
48
+ project, or copy containing a character the description validator rejects.
49
+
43
50
  `<skill-dir>/scripts/ciq-capture` exists because `screencapture -R` grabs a screen *region*, not
44
51
  a window: without a frontmost check it silently photographs whatever is on top,
45
52
  and the window moves between simulator restarts. Both failure modes produce a
@@ -0,0 +1,145 @@
1
+ # Publishing through the browser
2
+
3
+ `references/store.md` covers *what* the listing needs. This covers *driving the
4
+ portal*, which is the only way in — Garmin publishes no submission API.
5
+
6
+ Run `scripts/ciq-release` before opening a browser. Everything it checks is
7
+ something you would otherwise discover halfway through the form.
8
+
9
+ ## The portal is automatable
10
+
11
+ The obvious conclusion is that it is not: `apps.garmin.com/developer/dashboard`
12
+ sits behind Garmin SSO *and* Cloudflare bot protection, and a headless browser
13
+ lands on a "Performing security verification" interstitial. That is what you see
14
+ if you navigate cold.
15
+
16
+ It works anyway, because the session is already sitting in your own browser.
17
+ Import the cookies:
18
+
19
+ ```bash
20
+ B=~/.claude/skills/gstack/browse/dist/browse
21
+
22
+ $B cookie-import-browser comet --domain .garmin.com # note the LEADING DOT
23
+ $B goto https://apps.garmin.com/ # host-scoped cookies
24
+ $B cookie-import-browser comet --domain apps.garmin.com # need you to be on
25
+ $B goto https://sso.garmin.com/ # the host first
26
+ $B cookie-import-browser comet --domain sso.garmin.com
27
+
28
+ $B goto https://apps.garmin.com/developer/dashboard # no SSO redirect
29
+ ```
30
+
31
+ Three separate traps in that block:
32
+
33
+ - **`garmin.com` imports nothing. `.garmin.com` imports everything.** The cookie
34
+ is stored under the dotted domain and the filter is an exact match, so the
35
+ obvious spelling silently returns `Imported 0 cookies` and looks like "not
36
+ logged in".
37
+ - **Host-scoped cookies need you on that host first**, or the import refuses
38
+ with *"does not match current page domain"*. The `.garmin.com` set alone is
39
+ NOT enough to authenticate — the session only holds once
40
+ `apps.` and `sso.` are imported too.
41
+ - **Substitute your browser.** `comet`, `chrome`, `brave`, `arc`, `edge`,
42
+ `chromium` are supported. Whichever one you actually signed into.
43
+
44
+ Verify by checking the URL after `goto`: an unauthenticated session is bounced
45
+ to `sso.garmin.com/portal/sso/...`, an authenticated one stays put.
46
+
47
+ ## "The Connect IQ store is currently in maintenance mode"
48
+
49
+ This string is in the DOM of the dashboard and of every app page, next to
50
+ *Upload New Version*, *Edit Details* and *Remove*. Reading the page text makes
51
+ it look like every action is blocked.
52
+
53
+ **It is pre-rendered hidden markup, not an active block.** Click the button and
54
+ the real form loads. Do not report the store as down on the strength of a text
55
+ dump — click first, then check for a *visible* dialog:
56
+
57
+ ```bash
58
+ $B js "(function(){var o=[];document.querySelectorAll('[role=dialog],.modal').forEach(function(x){if(x.offsetParent!==null)o.push(x.innerText.slice(0,120))});return o.join('|')||'no visible modal'})()"
59
+ ```
60
+
61
+ ## The two-step update flow
62
+
63
+ *Upload New Version* → `/apps/<uuid>/update`, which is two steps.
64
+
65
+ **Step 1 — attach.** A file input and an *App Version* textbox. Publish stays
66
+ disabled until both are set; the page shows the current version so you can pick
67
+ the next one.
68
+
69
+ ```bash
70
+ $B upload "input[type=file]" "$(pwd)/bin/<name>.iq"
71
+ $B fill "input[id*='version'],input[name*='version']" "2.0"
72
+ $B click "button:has-text('Upload and publish')"
73
+ ```
74
+
75
+ On success the page reports **Status: Verified, Signature: Verified** and
76
+ expands the product list — a manifest naming one product can resolve to several
77
+ store devices (`fenix6pro` becomes fēnix 6 Pro, 6 Pro Dual Power, 6 Pro Solar
78
+ and quatix 6).
79
+
80
+ **Step 2 — details.** Title, description, What's New, images, category, contact
81
+ email. Ends in *Submit*.
82
+
83
+ The description arrives pre-filled with the copy from the PREVIOUS version.
84
+ After a redesign it describes a face that no longer exists, and nothing prompts
85
+ you about it. Re-paste from `docs/store/listing.md` every time.
86
+
87
+ ## Image slots, in DOM order
88
+
89
+ There are seven `input[type=file]` elements and none of them carry a name, id
90
+ or label. Order is the only way to tell them apart, so confirm it before
91
+ uploading rather than trusting this table:
92
+
93
+ ```bash
94
+ $B js "(function(){var f=document.querySelectorAll('input[type=file]');var o=[];f.forEach(function(x,i){var n=x,c='';for(var k=0;k<8&&n;k++){n=n.parentElement;if(n&&n.innerText.trim().length>25){c=n.innerText.replace(/\s+/g,' ').slice(0,60);break}}o.push(i+' >> '+c)});return o.join('\n')})()"
95
+ ```
96
+
97
+ | # | Slot | Size | Limit |
98
+ | --- | --- | --- | --- |
99
+ | 0 | Hero image | **1440×720** | 2048 KB |
100
+ | 1 | Cover image | **500×500** | 300 KB |
101
+ | 2–6 | Screen images | any | **150 KB each** |
102
+
103
+ Getting the order wrong puts a 260px screenshot where the hero belongs, and the
104
+ form accepts it.
105
+
106
+ ## The description validator
107
+
108
+ Two rejections, both discovered only after filling the entire form and pressing
109
+ Submit:
110
+
111
+ - `Illegal characters found: <, >.` — an ASCII `->` arrow is enough to trip it.
112
+ - `The app description is invalid. It should not contain emojis.` — **U+2699
113
+ GEAR counts**, which is exactly the character you reach for when writing
114
+ "tap the gear icon". U+2192 RIGHTWARDS ARROW passes today but lives in the
115
+ same symbol space.
116
+
117
+ Write settings paths in words: *"Garmin Connect app, then: your device, Connect
118
+ IQ Apps, Watch Faces, the app name, settings"*. `scripts/ciq-release` checks the fenced
119
+ blocks of `listing.md` for both classes before you open a browser.
120
+
121
+ The error text renders as a leaf node with no `aria-invalid` anywhere, so find
122
+ it by scanning for childless visible elements rather than by form state:
123
+
124
+ ```bash
125
+ $B js "(function(){var o=[];document.querySelectorAll('*').forEach(function(x){if(!x.children.length&&x.offsetParent!==null&&/invalid|illegal/i.test(x.innerText))o.push(x.innerText.trim())});return Array.from(new Set(o)).join(' || ')})()"
126
+ ```
127
+
128
+ Success is a redirect to `apps.garmin.com/apps/<uuid>`. Staying on `/update`
129
+ means a validation failure.
130
+
131
+ ## Things not to click
132
+
133
+ **Remove** sits between *Edit Details* and *Download* in the same button row.
134
+ Never target buttons by index in that row.
135
+
136
+ **Upload and publish** and **Submit** are outward-facing: they push to a public
137
+ listing and accept the Developer License Agreement on the account holder's
138
+ behalf. Confirm with the human before either, even when they have asked for the
139
+ release — version number and stale copy are both worth one question.
140
+
141
+ ## Account state
142
+
143
+ A developer account pending identity verification can still upload; approval
144
+ gates public visibility, not submission. A newly submitted app sits in preview
145
+ mode, visible only to its owner, for up to three days.
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env python3
2
+ """Pre-submission check for a Connect IQ project.
3
+
4
+ Everything that can be verified WITHOUT logging into the store, verified here,
5
+ so the browser session is spent on the form rather than on discovering that the
6
+ package is stale.
7
+
8
+ ciq-release # check ./ and print the verdict
9
+ ciq-release path/to/proj
10
+ ciq-release --quiet # exit code only
11
+
12
+ Exit 0 when submittable, 1 when something is wrong.
13
+
14
+ The checks are the ones that have actually gone wrong, not a generic lint:
15
+
16
+ * screenshots older than the package they claim to show. store.md calls stale
17
+ screenshots the most common thing to forget after a redesign, and they
18
+ misrepresent the version under review -- which is a review rejection, not a
19
+ cosmetic slip.
20
+ * a launcher icon that is byte-identical to another project's. Copying a
21
+ scaffold and forgetting the icon ships someone else's mark.
22
+ * signing keys inside the project. The key signs your app id: commit it and
23
+ anyone can impersonate the app; lose it and you can never update the
24
+ listing.
25
+ * a package older than the sources it was built from.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import hashlib
32
+ import re
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ RESET = "\033[0m"
37
+ RED = "\033[31m"
38
+ GREEN = "\033[32m"
39
+ YELLOW = "\033[33m"
40
+
41
+
42
+ class Report:
43
+ def __init__(self, quiet: bool) -> None:
44
+ self.quiet = quiet
45
+ self.failed = 0
46
+ self.warned = 0
47
+
48
+ def _line(self, colour: str, tag: str, msg: str, detail: str) -> None:
49
+ if self.quiet:
50
+ return
51
+ pad = f" {colour}{tag}{RESET} {msg}"
52
+ print(pad if not detail else f"{pad}\n {detail}")
53
+
54
+ def ok(self, msg: str, detail: str = "") -> None:
55
+ self._line(GREEN, "ok ", msg, detail)
56
+
57
+ def warn(self, msg: str, detail: str = "") -> None:
58
+ self.warned += 1
59
+ self._line(YELLOW, "warn", msg, detail)
60
+
61
+ def fail(self, msg: str, detail: str = "") -> None:
62
+ self.failed += 1
63
+ self._line(RED, "FAIL", msg, detail)
64
+
65
+
66
+ def newest(paths) -> float:
67
+ stamps = [p.stat().st_mtime for p in paths if p.is_file()]
68
+ return max(stamps) if stamps else 0.0
69
+
70
+
71
+ def check_manifest(root: Path, r: Report) -> None:
72
+ manifest = root / "manifest.xml"
73
+ if not manifest.is_file():
74
+ r.fail("no manifest.xml", f"looked in {root}")
75
+ return
76
+ text = manifest.read_text(encoding="utf-8", errors="replace")
77
+
78
+ app_id = re.search(r'id="([0-9a-fA-F]{32})"', text)
79
+ if not app_id:
80
+ r.fail("manifest has no 32-hex application id")
81
+ else:
82
+ r.ok(f"app id {app_id.group(1)}")
83
+
84
+ products = re.findall(r'<iq:product\s+id="([^"]+)"', text)
85
+ if not products:
86
+ r.fail("manifest declares no products")
87
+ else:
88
+ r.ok(f"{len(products)} product(s): {', '.join(products)}")
89
+
90
+ # A watch face that forgot its permissions still builds, then reads null
91
+ # from every sensor on the watch and nowhere else.
92
+ perms = re.findall(r'uses-permission\s+id="([^"]+)"', text)
93
+ r.ok(f"permissions: {', '.join(perms) if perms else 'none'}")
94
+
95
+
96
+ def check_package(root: Path, r: Report) -> Path | None:
97
+ packages = list((root / "bin").glob("*.iq")) if (root / "bin").is_dir() else []
98
+ if not packages:
99
+ r.fail("no .iq package", "run: make package")
100
+ return None
101
+ iq = max(packages, key=lambda p: p.stat().st_mtime)
102
+ built = iq.stat().st_mtime
103
+
104
+ sources = list((root / "source").rglob("*.mc"))
105
+ sources += list((root / "resources").rglob("*"))
106
+ if newest(sources) > built:
107
+ r.fail(
108
+ f"{iq.name} is older than the sources it was built from",
109
+ "run: make package",
110
+ )
111
+ else:
112
+ r.ok(f"{iq.name} ({iq.stat().st_size // 1024} KB) is current")
113
+ return iq
114
+
115
+
116
+ def check_screenshots(root: Path, _iq: Path | None, r: Report) -> None:
117
+ store = root / "docs" / "store"
118
+ shots = sorted(store.glob("*.png")) if store.is_dir() else []
119
+ if not shots:
120
+ r.warn("no screenshots in docs/store/", "the listing form needs at least one")
121
+ return
122
+
123
+ # Screenshots are checked against their source files (source/*.mc and
124
+ # resources/*) rather than the package they claim to show. The question is
125
+ # "do these images show the current face?", and only a source or resource
126
+ # change can make them stale. Stale screenshots misrepresent the version
127
+ # under review, which is a review rejection, not a cosmetic slip.
128
+ sources = list((root / "source").rglob("*.mc"))
129
+ sources += [p for p in (root / "resources").rglob("*") if p.is_file()]
130
+ changed = newest(sources)
131
+ newest_shot = newest(shots)
132
+ if changed > newest_shot:
133
+ stale = [p.name for p in sources if p.stat().st_mtime > newest_shot]
134
+ r.fail(
135
+ f"{len(shots)} screenshot(s) predate the current sources",
136
+ "changed since capture: " + ", ".join(sorted(stale)[:4])
137
+ + ("..." if len(stale) > 4 else ""),
138
+ )
139
+ else:
140
+ r.ok(f"{len(shots)} screenshot(s), current with the sources")
141
+
142
+
143
+ def check_icon(root: Path, r: Report) -> None:
144
+ icons = list((root / "resources").rglob("launcher_icon.png"))
145
+ if not icons:
146
+ r.warn("no launcher_icon.png found")
147
+ return
148
+
149
+ digest = hashlib.sha256(icons[0].read_bytes()).hexdigest()
150
+
151
+ # Compare against every sibling project. A scaffold copied from the last
152
+ # face keeps its icon, and nothing in the build complains.
153
+ for sibling in root.parent.iterdir():
154
+ if not sibling.is_dir() or sibling.resolve() == root.resolve():
155
+ continue
156
+ for other in sibling.rglob("launcher_icon.png"):
157
+ if hashlib.sha256(other.read_bytes()).hexdigest() == digest:
158
+ r.fail(
159
+ "launcher icon is byte-identical to another project's",
160
+ f"same file as {other}",
161
+ )
162
+ return
163
+ r.ok("launcher icon is unique to this project")
164
+
165
+
166
+ def check_keys(root: Path, r: Report) -> None:
167
+ candidates = [p for p in root.rglob("*") if p.suffix in (".der", ".pem") and p.is_file()]
168
+
169
+ # Inspect file contents to confirm they contain private key material
170
+ private_keys = []
171
+ for p in candidates:
172
+ if p.suffix == ".pem":
173
+ try:
174
+ content = p.read_text(errors="replace")
175
+ # Check for private key PEM headers
176
+ if any(marker in content for marker in [
177
+ "BEGIN PRIVATE KEY",
178
+ "BEGIN RSA PRIVATE KEY",
179
+ "BEGIN EC PRIVATE KEY",
180
+ "BEGIN ENCRYPTED PRIVATE KEY",
181
+ ]):
182
+ private_keys.append(p)
183
+ except Exception:
184
+ pass
185
+ elif p.suffix == ".der":
186
+ # DER files are binary; use a simple heuristic: assume .der files
187
+ # in typical key locations are keys unless they're obviously certs
188
+ # (this is a best-effort check; DER format inspection is complex)
189
+ try:
190
+ data = p.read_bytes()
191
+ # Very basic heuristic: if file is small and doesn't contain
192
+ # obvious certificate markers, treat as potential private key
193
+ # This is imperfect but better than blanket rejection
194
+ if len(data) < 10000: # Private keys are typically smaller than cert bundles
195
+ private_keys.append(p)
196
+ except Exception:
197
+ pass
198
+
199
+ if private_keys:
200
+ r.fail(
201
+ "signing key material inside the project",
202
+ "\n ".join(str(p) for p in private_keys),
203
+ )
204
+ return
205
+
206
+ gitignore = root / ".gitignore"
207
+ ignored = gitignore.read_text(errors="replace") if gitignore.is_file() else ""
208
+ if "*.der" not in ignored or "*.pem" not in ignored:
209
+ r.warn(".gitignore does not cover *.der / *.pem")
210
+ else:
211
+ r.ok("no keys in the tree, and both extensions are ignored")
212
+
213
+
214
+ def check_tests(root: Path, r: Report) -> None:
215
+ log = root / "bin" / "test.log"
216
+ if not log.is_file():
217
+ r.warn("no bin/test.log", "run: make test")
218
+ return
219
+ text = log.read_text(errors="replace")
220
+ if re.search(r"^PASSED \(passed=\d+, failed=0, errors=0\)", text, re.M):
221
+ count = re.search(r"passed=(\d+)", text)
222
+ r.ok(f"{count.group(1) if count else '?'} tests passed")
223
+ else:
224
+ r.fail("bin/test.log does not end in a clean PASSED line")
225
+
226
+
227
+ def check_listing(root: Path, r: Report) -> None:
228
+ listing = root / "docs" / "store" / "listing.md"
229
+ if not listing.is_file():
230
+ r.warn("no docs/store/listing.md", "the form wants copy ready to paste")
231
+ return
232
+ doc = listing.read_text(errors="replace")
233
+
234
+ # Only the FENCED blocks are checked -- those are what gets pasted into the
235
+ # store form. The surrounding prose is notes for a human and legitimately
236
+ # contains things the store would reject, like an autolinked <https://...>
237
+ # portal URL. Scanning the whole file flags the documentation.
238
+ raw = "\n".join(re.findall(r"^```\n(.*?)^```", doc, re.S | re.M))
239
+ if not raw:
240
+ r.warn("listing.md has no fenced copy blocks to check")
241
+ return
242
+
243
+ # The store's description validator rejects two things, and both are
244
+ # discovered the slow way -- by filling the whole form and being bounced:
245
+ #
246
+ # "Illegal characters found: <, >."
247
+ # "The app description is invalid. It should not contain emojis."
248
+ #
249
+ # A GEAR (U+2699) counts as an emoji, which is easy to reach for when
250
+ # writing "tap the gear icon". Arrows (U+2192) pass today, but they sit in
251
+ # the same symbol space and cost nothing to avoid.
252
+ if "<" in raw or ">" in raw:
253
+ r.fail(
254
+ "listing copy contains < or >",
255
+ "the store rejects them: \"Illegal characters found\"",
256
+ )
257
+ else:
258
+ r.ok("no angle brackets in the copy")
259
+
260
+ # Explicit emoji Unicode ranges (common blocks that Garmin store rejects)
261
+ def is_emoji(c: str) -> bool:
262
+ o = ord(c)
263
+ return (
264
+ (0x1F300 <= o <= 0x1F9FF) or # Miscellaneous Symbols and Pictographs, Emoticons, etc.
265
+ (0x2600 <= o <= 0x27BF) or # Miscellaneous Symbols, Dingbats
266
+ (0x2300 <= o <= 0x23FF) or # Miscellaneous Technical (includes gear U+2699)
267
+ (0x2190 <= o <= 0x21FF) or # Arrows (includes U+2192)
268
+ (0x2900 <= o <= 0x297F) or # Supplemental Arrows-B
269
+ (0x2B00 <= o <= 0x2BFF) or # Miscellaneous Symbols and Arrows
270
+ (0x3030 <= o <= 0x303F) or # CJK Symbols and Punctuation (wavy dash, etc.)
271
+ (0xFE00 <= o <= 0xFE0F) # Variation Selectors
272
+ )
273
+ emoji = sorted({c for c in raw if is_emoji(c)})
274
+ if emoji:
275
+ r.fail(
276
+ "listing copy contains symbols the store reads as emoji",
277
+ " ".join(f"{c!r} (U+{ord(c):04X})" for c in emoji),
278
+ )
279
+ else:
280
+ r.ok("no emoji in the copy")
281
+
282
+ text = doc.lower()
283
+ # Watch face settings are unreachable from the watch. Omitting this reads as
284
+ # a missing feature to every reviewer and every user.
285
+ if "garmin connect" not in text:
286
+ r.warn(
287
+ "listing never mentions the Garmin Connect app",
288
+ "watch face settings are not reachable from the watch; say so or "
289
+ "it reads as a missing feature",
290
+ )
291
+ else:
292
+ r.ok("listing explains where settings live")
293
+
294
+
295
+ def main() -> int:
296
+ ap = argparse.ArgumentParser(add_help=True)
297
+ ap.add_argument("project", nargs="?", default=".")
298
+ ap.add_argument("--quiet", action="store_true")
299
+ args = ap.parse_args()
300
+
301
+ root = Path(args.project).resolve()
302
+ r = Report(args.quiet)
303
+
304
+ if not args.quiet:
305
+ print(f"Connect IQ release check\n {root}\n")
306
+
307
+ check_manifest(root, r)
308
+ iq = check_package(root, r)
309
+ check_screenshots(root, iq, r)
310
+ check_icon(root, r)
311
+ check_keys(root, r)
312
+ check_tests(root, r)
313
+ check_listing(root, r)
314
+
315
+ if not args.quiet:
316
+ print()
317
+ if r.failed:
318
+ print(f" {RED}{r.failed} blocking problem(s){RESET}, {r.warned} warning(s)")
319
+ elif r.warned:
320
+ print(f" {YELLOW}submittable, {r.warned} warning(s){RESET}")
321
+ else:
322
+ print(f" {GREEN}submittable{RESET}")
323
+ if not r.failed:
324
+ print(
325
+ "\n Upload: apps.garmin.com/developer/dashboard\n"
326
+ " The portal IS drivable headlessly once the session cookies are\n"
327
+ " imported from a signed-in browser. See references/publishing.md."
328
+ )
329
+ return 1 if r.failed else 0
330
+
331
+
332
+ if __name__ == "__main__":
333
+ sys.exit(main())