diff --git a/.github/workflows/deploy-site.yaml b/.github/workflows/deploy-site.yaml index e6958a24..7299831f 100644 --- a/.github/workflows/deploy-site.yaml +++ b/.github/workflows/deploy-site.yaml @@ -74,6 +74,25 @@ jobs: --llms-full-output ./docs/site/llms-full.txt \ $PREV_FLAG + - name: Stamp build freshness (date + version) into SEO assets + # index.html (JSON-LD dateModified/softwareVersion) and sitemap.xml + # () carry __BUILD_DATE__/__VERSION__ placeholders in the repo. + # Substitute them here, in the throwaway runner checkout, so deployed + # structured data signals freshness without committing volatile values. + # Version comes from commands.json, which the generator just wrote using + # the same cleaning logic as the on-page version badge (single source). + run: | + BUILD_DATE="$(date -u +%Y-%m-%d)" + VERSION="$(python3 -c "import json; print(json.load(open('docs/site/commands.json'))['version'])")" + COUNT="$(python3 -c "import json; print(format(json.load(open('docs/site/commands.json'))['commandCount'], ','))")" + sed -i "s/__BUILD_DATE__/${BUILD_DATE}/g; s/__VERSION__/${VERSION}/g; s/__COMMAND_COUNT__/${COUNT}/g" \ + docs/site/index.html docs/site/sitemap.xml + if grep -REn '__BUILD_DATE__|__VERSION__|__COMMAND_COUNT__' docs/site/index.html docs/site/sitemap.xml; then + echo "::error::Unsubstituted SEO placeholder(s) remain after stamping." >&2 + exit 1 + fi + echo "Stamped freshness: version=${VERSION} date=${BUILD_DATE} commands=${COUNT}" + - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: diff --git a/.github/workflows/site-preflight.yaml b/.github/workflows/site-preflight.yaml index a0d64053..003c5187 100644 --- a/.github/workflows/site-preflight.yaml +++ b/.github/workflows/site-preflight.yaml @@ -76,4 +76,5 @@ jobs: --commands-json docs/site/commands.json \ --llms-txt docs/site/llms.txt \ --llms-full-txt docs/site/llms-full.txt \ + --binary ./bin/jamf-cli \ --site-dir docs/site diff --git a/Makefile b/Makefile index a1e63540..eafb246b 100644 --- a/Makefile +++ b/Makefile @@ -191,6 +191,7 @@ verify-site-output: build --commands-json /tmp/jamf-cli-site/commands.json \ --llms-txt /tmp/jamf-cli-site/llms.txt \ --llms-full-txt /tmp/jamf-cli-site/llms-full.txt \ + --binary ./bin/jamf-cli \ --site-dir docs/site # Verify generated code is up to date (CI-safe) diff --git a/docs/site/index.html b/docs/site/index.html index b7b5bb29..f30d8308 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -4,6 +4,7 @@ + jamf-cli — Your fleet, one command away @@ -16,17 +17,20 @@ - + - + + and structured data for AI/search consumers. + __VERSION__ and __BUILD_DATE__ are substituted with the release version + and deploy date by the "Stamp build freshness" step in deploy-site.yaml. + They stay as literal placeholders in the repo (valid JSON strings). --> +
diff --git a/docs/site/sitemap.xml b/docs/site/sitemap.xml index a4185da3..a6b054ca 100644 --- a/docs/site/sitemap.xml +++ b/docs/site/sitemap.xml @@ -1,22 +1,29 @@ + https://jamf-concepts.github.io/jamf-cli/ + __BUILD_DATE__ weekly 1.0 https://jamf-concepts.github.io/jamf-cli/llms.txt + __BUILD_DATE__ weekly 0.8 https://jamf-concepts.github.io/jamf-cli/llms-full.txt + __BUILD_DATE__ weekly 0.8 https://jamf-concepts.github.io/jamf-cli/commands.json + __BUILD_DATE__ weekly 0.6 diff --git a/docs/site/style.css b/docs/site/style.css index 8fe08bfb..5d580061 100644 --- a/docs/site/style.css +++ b/docs/site/style.css @@ -2047,6 +2047,53 @@ pre.example-command.copied::after { text-align: center; } +#faq { + scroll-margin-top: 60px; + padding: 4rem 2rem; +} + +.faq-inner { + max-width: 760px; + margin: 0 auto; +} + +#faq h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 2rem; + text-align: center; +} + +.faq-item { + border-top: 1px solid var(--border); + padding: 1.25rem 0; +} + +.faq-item:last-child { + border-bottom: 1px solid var(--border); +} + +.faq-item h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.faq-item p { + color: var(--text-secondary); + line-height: 1.6; + font-size: 0.9rem; +} + +.faq-item code { + font-family: 'Geist Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.82em; + background: var(--page-bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.1em 0.35em; +} + .install-grid { display: flex; flex-direction: column; diff --git a/generator/site/main.go b/generator/site/main.go index d4041334..79bb1b72 100644 --- a/generator/site/main.go +++ b/generator/site/main.go @@ -158,7 +158,14 @@ func renderLLMSTxt(d siteData) string { fmt.Fprintf(&b, "- Credentials never accepted via CLI flags or stdin (shell-history safe). Interactive prompts, env vars (`JAMF_*`, `JAMFPROTECT_*`), or keychain-backed config profiles.\n\n") fmt.Fprintf(&b, "## Output Formats\n\n") - fmt.Fprintf(&b, "Every command supports `-o {table,plain,json,yaml,csv}` plus `--field ` for jq-free scalar extraction. Pipe-friendly, scriptable, NO_COLOR aware.\n\n") + fmt.Fprintf(&b, "Every command supports `-o {table,plain,json,yaml,csv}` (default `json`). Shrink large responses with `--select id,general.name` (project dot-path fields), `--field general.name` (extract one scalar), or `--compact` (drop arrays and nested objects). Lists over 50 rows print a `hint:` to stderr suggesting how to narrow. Pipe-friendly, scriptable, NO_COLOR aware.\n\n") + + fmt.Fprintf(&b, "## For AI Agents\n\n") + fmt.Fprintf(&b, "**Discover commands at runtime.** `jamf-cli commands -o json` emits the full command tree (path, description, flags, aliases, product, group). Prefer it over guessing command names.\n\n") + fmt.Fprintf(&b, "**Run unattended.** Pass `--no-input` (fail instead of prompting) and `--quiet`. Authenticate with `JAMF_*` / `JAMFPROTECT_*` env vars — never flags. Set `JAMF_CLI_ARGS='--quiet --no-input'` to apply both to every invocation.\n\n") + fmt.Fprintf(&b, "**Parse outcomes.** Default output is JSON. Exit codes are stable: 0 success · 1 general · 2 usage · 3 auth · 4 not-found · 5 permission · 6 rate-limited. With `-o json`, failures print `{\"error\",\"message\",\"exitCode\"}`, so success and failure are both machine-readable.\n\n") + fmt.Fprintf(&b, "**Stay within context.** Use `--select`, `--field`, and `--compact` to avoid pulling multi-thousand-line payloads you don't need.\n\n") + fmt.Fprintf(&b, "**Mutate safely.** `apply` is a name-based upsert — prefer it over create/update for idempotency. Destructive commands require `--yes` (bulk also requires `--confirm-destructive`). Preview any write with `--dry-run`/`-n`.\n\n") fmt.Fprintf(&b, "## Optional\n\n") fmt.Fprintf(&b, "- [Source code (Go)](https://github.com/Jamf-Concepts/jamf-cli)\n") diff --git a/generator/site/main_test.go b/generator/site/main_test.go index fc913757..ba5e7de6 100644 --- a/generator/site/main_test.go +++ b/generator/site/main_test.go @@ -4,7 +4,11 @@ package main import ( "encoding/json" + "fmt" + "strings" "testing" + + "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" ) func TestTransformCommands(t *testing.T) { @@ -280,6 +284,26 @@ func TestParseVersion(t *testing.T) { input: "jamf-cli v2.0.0-beta.1\n commit: abc1234\n", want: "2.0.0-beta.1", }, + { + name: "json release (default -o json output)", + input: `{ + "version": "1.18.0", + "commit": "c71ad8a", + "built": "2026-05-31T05:22:01Z", + "specProVersion": "unknown" +}`, + want: "1.18.0", + }, + { + name: "json dirty dev build strips git-describe suffix", + input: `{"version":"v1.17.0-25-g74846ff-dirty","commit":"74846ff"}`, + want: "1.17.0", + }, + { + name: "json pre-release preserved", + input: `{"version":"v2.0.0-beta.1"}`, + want: "2.0.0-beta.1", + }, { name: "empty output", input: "", @@ -321,3 +345,47 @@ func TestParseVersion(t *testing.T) { }) } } + +// TestRenderLLMSTxt_AgentContract guards the "For AI Agents" section. The +// exit-code table is the load-bearing part: it must stay in lockstep with +// internal/exitcode. We interpolate each constant's value here, so renaming +// or renumbering a code in exitcode.go without updating llms.txt fails CI. +func TestRenderLLMSTxt_AgentContract(t *testing.T) { + out := renderLLMSTxt(siteData{Version: "1.0.0", CommandCount: 1200}) + + if !strings.Contains(out, "## For AI Agents") { + t.Fatalf("llms.txt is missing the \"For AI Agents\" section") + } + + exitCodes := []struct { + code int + label string + }{ + {exitcode.Success, "success"}, + {exitcode.General, "general"}, + {exitcode.Usage, "usage"}, + {exitcode.Authentication, "auth"}, + {exitcode.NotFound, "not-found"}, + {exitcode.PermissionDenied, "permission"}, + {exitcode.RateLimited, "rate-limited"}, + } + for _, ec := range exitCodes { + want := fmt.Sprintf("%d %s", ec.code, ec.label) + if !strings.Contains(out, want) { + t.Errorf("exit-code table out of sync with internal/exitcode: missing %q", want) + } + } + + // Spot-check the rest of the contract so a silent deletion is caught. + for _, want := range []string{ + "jamf-cli commands -o json", + "--no-input", + "--select", + "--confirm-destructive", + "apply", + } { + if !strings.Contains(out, want) { + t.Errorf("agent contract missing %q", want) + } + } +} diff --git a/scripts/verify-site-output.sh b/scripts/verify-site-output.sh index 40fee11a..22551958 100755 --- a/scripts/verify-site-output.sh +++ b/scripts/verify-site-output.sh @@ -27,6 +27,7 @@ COMMANDS_JSON="docs/site/commands.json" SITE_DIR="docs/site" LLMS_TXT="" LLMS_FULL_TXT="" +BINARY="" while [[ $# -gt 0 ]]; do case "$1" in @@ -34,6 +35,7 @@ while [[ $# -gt 0 ]]; do --site-dir) SITE_DIR="$2"; shift 2 ;; --llms-txt) LLMS_TXT="$2"; shift 2 ;; --llms-full-txt) LLMS_FULL_TXT="$2"; shift 2 ;; + --binary) BINARY="$2"; shift 2 ;; -h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \?//' exit 0 @@ -134,6 +136,142 @@ for i, m in enumerate(matches, 1): PY fi +echo "==> FAQ <-> FAQPage consistency in $SITE_DIR/index.html" +if [[ ! -f "$SITE_DIR/index.html" ]]; then + fail "$SITE_DIR/index.html missing" +else + python3 - "$SITE_DIR/index.html" <<'PY' || ERRORS=$((ERRORS + 1)) +import json, re, sys +from html import unescape +html = open(sys.argv[1]).read() + +def norm(s): + # Strip inline tags (e.g. ), decode entities, collapse whitespace — + # so the visible markup and the plain-text JSON-LD compare apples to apples. + s = re.sub(r'<[^>]+>', '', s) + s = unescape(s) + return re.sub(r'\s+', ' ', s).strip() + +# Question -> answer declared in the FAQPage structured data. +faq = None +for m in re.findall(r']+type="application/ld\+json"[^>]*>(.*?)', html, re.DOTALL): + try: + data = json.loads(m) + except json.JSONDecodeError: + continue + if data.get("@type") == "FAQPage": + faq = data + break +if faq is None: + print(" FAIL: no FAQPage JSON-LD block found in index.html", file=sys.stderr) + sys.exit(1) +jsonld = {norm(q.get("name", "")): norm((q.get("acceptedAnswer") or {}).get("text", "")) + for q in faq.get("mainEntity", [])} + +# Question -> answer a human sees in the visible #faq section. +sec = re.search(r'
', html, re.DOTALL) +if not sec: + print(" FAIL: no visible
found in index.html", file=sys.stderr) + sys.exit(1) +visible = {norm(h): norm(p) + for h, p in re.findall(r'

(.*?)

\s*

(.*?)

', sec.group(0), re.DOTALL)} + +# Google requires the structured-data answer to match the visible answer, so +# check question presence AND answer-text parity (one is for humans, one for +# answer engines — they must not drift apart). +problems = [] +for q, ans in jsonld.items(): + if q not in visible: + problems.append(f"question not shown to humans: {q!r}") + elif visible[q] != ans: + problems.append(f"answer text differs between JSON-LD and visible FAQ for {q!r}") +if problems: + print(" FAIL: FAQPage <-> visible #faq drift:", file=sys.stderr) + for x in problems: + print(f" - {x}", file=sys.stderr) + print(" The structured data and the on-page FAQ (questions and answers) must stay in sync.", file=sys.stderr) + sys.exit(1) +print(f" PASS: all {len(jsonld)} FAQPage questions and answers match the visible #faq section") +PY +fi + +echo "==> llms.txt flag names exist in the CLI ($LLMS_TXT)" +if [[ -z "$BINARY" ]]; then + echo " SKIP: --binary not provided; cannot validate flag names against the CLI" +elif [[ ! -x "$BINARY" ]]; then + fail "--binary $BINARY is not executable" +elif [[ ! -s "$LLMS_TXT" ]]; then + fail "$LLMS_TXT missing; cannot validate flag names" +else + # Authoritative flag set = global/persistent flags (root --help) plus every + # per-command local flag (commands.json). The agent contract must not name a + # flag outside that union. + global_flags="$("$BINARY" --help 2>&1 | grep -oE '\-\-[a-z][a-z0-9-]+' | sort -u)" + python3 - "$LLMS_TXT" "$COMMANDS_JSON" "$global_flags" <<'PY' || ERRORS=$((ERRORS + 1)) +import json, re, sys +llms = open(sys.argv[1]).read() +cmds = json.load(open(sys.argv[2])) +valid = set(sys.argv[3].split()) +for c in cmds["commands"]: + for f in (c.get("flags") or []): + valid.add(f) +named = set(re.findall(r'(? product coverage & command count in llms.txt / FAQ" +if [[ ! -s "$COMMANDS_JSON" || ! -f "$SITE_DIR/index.html" ]]; then + fail "commands.json or index.html missing; cannot validate product/count docs" +else + python3 - "$COMMANDS_JSON" "$LLMS_TXT" "$SITE_DIR/index.html" <<'PY' || ERRORS=$((ERRORS + 1)) +import json, re, sys +cmds = json.load(open(sys.argv[1])) +llms = open(sys.argv[2]).read() +html = open(sys.argv[3]).read() + +labels = {"platform": "Jamf Platform", "pro": "Jamf Pro", + "protect": "Jamf Protect", "school": "Jamf School"} +products = sorted({c.get("product") for c in cmds["commands"] if c.get("product")}) + +m = re.search(r'
', html, re.DOTALL) +faq = m.group(0) if m else "" + +problems = [] +for p in products: + label = labels.get(p) + if not label: + problems.append(f"unknown product {p!r}: add a display name here and mention it in llms.txt + the FAQ") + continue + if label not in llms: + problems.append(f"product {label!r} in commands.json but not mentioned in llms.txt") + if label not in faq: + problems.append(f"product {label!r} in commands.json but not mentioned in the FAQ") + +# The FAQ states a rounded floor ("over N commands"); reality must stay above it. +count = cmds["commandCount"] +fm = re.search(r'over\s+([\d,]+)\s+commands', faq) +if fm: + floor = int(fm.group(1).replace(",", "")) + if count < floor: + problems.append(f"FAQ claims 'over {fm.group(1)} commands' but commands.json has only {count}") + +if problems: + print(" FAIL: product/count documentation drift:", file=sys.stderr) + for x in problems: + print(f" - {x}", file=sys.stderr) + sys.exit(1) +print(f" PASS: {len(products)} products documented; FAQ floor <= {count} commands") +PY +fi + echo "==> Pro pillar coverage in $SITE_DIR/catalog.js" if [[ ! -f "$SITE_DIR/catalog.js" ]]; then fail "$SITE_DIR/catalog.js missing"