@olegkoval/agent-skills 1.23.0 → 1.24.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.kiro/steering/garmin-watchface.md +7 -0
- package/.windsurf/rules/garmin-watchface.md +7 -0
- package/package.json +1 -1
- package/packages/wearables/garmin-watchface/SKILL.md +7 -0
- package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/SKILL.md +7 -0
- package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/references/publishing.md +145 -0
- package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/scripts/ciq-release +333 -0
- package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/SKILL.md +7 -0
- package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/references/publishing.md +145 -0
- package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/scripts/ciq-release +333 -0
- package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/SKILL.md +7 -0
- package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/references/publishing.md +145 -0
- package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/scripts/ciq-release +333 -0
- package/packages/wearables/garmin-watchface/adapters/kiro/steering/garmin-watchface.md +7 -0
- package/packages/wearables/garmin-watchface/adapters/windsurf/rules/garmin-watchface.md +7 -0
- package/packages/wearables/garmin-watchface/references/publishing.md +145 -0
- package/packages/wearables/garmin-watchface/scripts/ciq-release +333 -0
|
@@ -22,6 +22,7 @@ looked like success first.
|
|
|
22
22
|
| `references/simulator.md` | Screenshots, settings not applying, monkeydo hanging |
|
|
23
23
|
| `references/devices.md` | Adding device support, launcher icons, API levels |
|
|
24
24
|
| `references/store.md` | Publishing, listing copy, IP questions |
|
|
25
|
+
| `references/publishing.md` | Driving the store portal in a browser; upload/update flow, validator rejections |
|
|
25
26
|
|
|
26
27
|
## Tools
|
|
27
28
|
|
|
@@ -34,8 +35,14 @@ Run these rather than reinventing them. All are standalone.
|
|
|
34
35
|
<skill-dir>/scripts/ciq-capture out.png # calibrated simulator screenshot
|
|
35
36
|
<skill-dir>/scripts/ciq-capture out.png --face --size 260 # cropped + masked to the round display
|
|
36
37
|
<skill-dir>/scripts/ciq-calibrate # re-derive the display rect if capture looks wrong
|
|
38
|
+
<skill-dir>/scripts/ciq-release # pre-submission check: package, screenshots, icon, keys, copy
|
|
37
39
|
```
|
|
38
40
|
|
|
41
|
+
`<skill-dir>/scripts/ciq-release` is what you run before opening the store
|
|
42
|
+
portal. Every check in it is something otherwise discovered halfway through the
|
|
43
|
+
submission form -- a stale screenshot set, an icon still copied from the last
|
|
44
|
+
project, or copy containing a character the description validator rejects.
|
|
45
|
+
|
|
39
46
|
`<skill-dir>/scripts/ciq-capture` exists because `screencapture -R` grabs a screen *region*, not
|
|
40
47
|
a window: without a frontmost check it silently photographs whatever is on top,
|
|
41
48
|
and the window moves between simulator restarts. Both failure modes produce a
|
|
@@ -21,6 +21,7 @@ looked like success first.
|
|
|
21
21
|
| `references/simulator.md` | Screenshots, settings not applying, monkeydo hanging |
|
|
22
22
|
| `references/devices.md` | Adding device support, launcher icons, API levels |
|
|
23
23
|
| `references/store.md` | Publishing, listing copy, IP questions |
|
|
24
|
+
| `references/publishing.md` | Driving the store portal in a browser; upload/update flow, validator rejections |
|
|
24
25
|
|
|
25
26
|
## Tools
|
|
26
27
|
|
|
@@ -33,8 +34,14 @@ Run these rather than reinventing them. All are standalone.
|
|
|
33
34
|
<skill-dir>/scripts/ciq-capture out.png # calibrated simulator screenshot
|
|
34
35
|
<skill-dir>/scripts/ciq-capture out.png --face --size 260 # cropped + masked to the round display
|
|
35
36
|
<skill-dir>/scripts/ciq-calibrate # re-derive the display rect if capture looks wrong
|
|
37
|
+
<skill-dir>/scripts/ciq-release # pre-submission check: package, screenshots, icon, keys, copy
|
|
36
38
|
```
|
|
37
39
|
|
|
40
|
+
`<skill-dir>/scripts/ciq-release` is what you run before opening the store
|
|
41
|
+
portal. Every check in it is something otherwise discovered halfway through the
|
|
42
|
+
submission form -- a stale screenshot set, an icon still copied from the last
|
|
43
|
+
project, or copy containing a character the description validator rejects.
|
|
44
|
+
|
|
38
45
|
`<skill-dir>/scripts/ciq-capture` exists because `screencapture -R` grabs a screen *region*, not
|
|
39
46
|
a window: without a frontmost check it silently photographs whatever is on top,
|
|
40
47
|
and the window moves between simulator restarts. Both failure modes produce a
|
package/package.json
CHANGED
|
@@ -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
|
package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/SKILL.md
CHANGED
|
@@ -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())
|
package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/SKILL.md
CHANGED
|
@@ -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
|