diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 08beedb..022121c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jfrog", - "version": "0.1.13", + "version": "0.1.14", "description": "JFrog skills and the JFrog MCP server for Codex \u2014 interact with the JFrog Platform.", "author": { "name": "JFrog", diff --git a/package.json b/package.json index ab3139d..d181a04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-plugin", - "version": "0.1.13", + "version": "0.1.14", "private": true, "type": "module", "description": "JFrog skills and MCP server plugin for OpenAI Codex.", diff --git a/scripts/sync-skills-vendor.json b/scripts/sync-skills-vendor.json index 41ba594..f011ed2 100644 --- a/scripts/sync-skills-vendor.json +++ b/scripts/sync-skills-vendor.json @@ -1,6 +1,6 @@ { "repo": "jfrog/jfrog-skills", - "pin": "v0.43.0", + "pin": "v0.44.0", "paths": [ "skills" ] diff --git a/skills/jfrog-ai-catalog/references/installing-plugins.md b/skills/jfrog-ai-catalog/references/installing-plugins.md index b3f38e3..c238a90 100644 --- a/skills/jfrog-ai-catalog/references/installing-plugins.md +++ b/skills/jfrog-ai-catalog/references/installing-plugins.md @@ -32,7 +32,7 @@ every choice (`--repo`, target) up front. **Resolve `` from the environment check script — never from your model name.** If `` is not already known from this session, run -`bash /../jfrog/scripts/check-environment.sh ` now and capture +`node /../jfrog/scripts/check-environment.mjs ` now and capture its stdout as ``. Parse the `tool=` field from `` and map it to a `jf` harness name: diff --git a/skills/jfrog-ai-catalog/references/installing-skills.md b/skills/jfrog-ai-catalog/references/installing-skills.md index 8c3276c..3d6cc7b 100644 --- a/skills/jfrog-ai-catalog/references/installing-skills.md +++ b/skills/jfrog-ai-catalog/references/installing-skills.md @@ -52,7 +52,7 @@ up front. **Resolve `` from the environment check script — never from your model name.** If `` is not already known from this session, run -`bash /../jfrog/scripts/check-environment.sh ` now and capture +`node /../jfrog/scripts/check-environment.mjs ` now and capture its stdout as ``. Parse the `tool=` field from `` and map it to a `jf` harness name: @@ -60,21 +60,24 @@ its stdout as ``. Parse the `tool=` field from `` and map it to a |-------------------------|------------------------------| | `claude` | `claude-code` | | `cursor` | `cursor` | -| `copilot` | `github-copilot` | +| `copilot` | `github-copilot` (plus `--global`, see below) | | `unknown`, empty, or any other | Ask the user | If `tool` is `unknown`, empty, or not in the table — do **not** guess. Ask the user for the desired install path and use `--path ` instead. **Exception — Kiro and Junie (install only):** `jf` accepts neither `--harness kiro` nor `--harness junie`, so if you're self-identified as either -(Kiro IDE / `kiro-cli`, or Junie — `check-environment.sh` detects Junie but not +(Kiro IDE / `kiro-cli`, or Junie — `check-environment.mjs` detects Junie but not Kiro), skip asking and use `--path` directly: `.kiro/skills` (project) / `~/.kiro/skills` (global, or `$KIRO_HOME/skills` if `KIRO_HOME` is set) for Kiro; `.junie/skills` (project) / `~/.junie/skills` (global) for Junie. This exception does not extend to `jf skills list` — see *List currently installed skills* in `managing-installed-skills.md`. -Choose exactly one install target (these are mutually exclusive): +**Exception — Copilot:** also pass `--global` (installs to `~/.copilot/skills`); +without it, skills land under Copilot Chat's cwd, which VS Code may not scan. + +Otherwise choose exactly one install target (these are mutually exclusive): | Flag | Installs into | |------|---------------| @@ -315,8 +318,8 @@ jf skills update "" --server-id "" --harness "" --repo "" --harness "" --check-updates ``` Resolve `` to the current agent (see `installing-skills.md`). +For Copilot, add `--global` to `--check-updates` too. **Exception — Kiro and Junie:** neither `--harness kiro` nor `--harness junie` works, and `list` has no `--path` flag, so list directly from the filesystem: skill directory names under `.kiro/skills` / `~/.kiro/skills` (or diff --git a/skills/jfrog-init/scripts/jfrog-login-register-session.mjs b/skills/jfrog-init/scripts/jfrog-login-register-session.mjs index b72508d..5c9db27 100644 --- a/skills/jfrog-init/scripts/jfrog-login-register-session.mjs +++ b/skills/jfrog-init/scripts/jfrog-login-register-session.mjs @@ -25,6 +25,7 @@ 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 { resolveCommand } from "./lib/command.mjs"; import { SAFE_URL } from "./jfrog-login-save-credentials.mjs"; // Pins stdio so a failing/logging `jf` subprocess can't leak output to @@ -102,18 +103,25 @@ export function registerSession(platformUrlRaw) { 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", - ]); + // Register the session with the Access API. A `jf.cmd` shim runs under + // cmd.exe, which splits on spaces and reads `"` as a metacharacter (see + // jfApi()), so there the body goes over stdin and the header drops its + // space. Elsewhere the body stays a plain `-d` arg. + const body = JSON.stringify({ session: sessionUuid }); + const { shell } = resolveCommand("jf"); + const register = jfApi( + [ + "--url", + platformUrl, + "-X", + "POST", + "-H", + shell ? "Content-Type:application/json" : "Content-Type: application/json", + ...(shell ? ["--input", "-"] : ["-d", body]), + "/access/api/v2/authentication/jfrog_client_login/request", + ], + shell ? { input: body } : {} + ); if (!register.ok) { const code = parseHttpStatus(register.stderr); process.stderr.write(`ERROR: Session registration failed (HTTP ${code})\n`); diff --git a/skills/jfrog-init/scripts/jfrog-login-save-credentials.mjs b/skills/jfrog-init/scripts/jfrog-login-save-credentials.mjs index c564223..b15b7df 100644 --- a/skills/jfrog-init/scripts/jfrog-login-save-credentials.mjs +++ b/skills/jfrog-init/scripts/jfrog-login-save-credentials.mjs @@ -34,6 +34,7 @@ import { execFileSync } from "node:child_process"; import { jfApi, parseHttpStatus } from "./lib/jf-api.mjs"; import { + assertJfArgsShellSafe, isMainModule, jfConfigShow, normalizeJpdUrl, @@ -46,6 +47,9 @@ import { resolveCommand } from "./lib/command.mjs"; // 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._-]+$/; +// The session UUID goes into the token path, so only register-session's +// canonical randomUUID() form is accepted. +export const SAFE_SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // Derive server ID from URL // SaaS: https://mycompany.jfrog.io → mycompany @@ -73,6 +77,13 @@ function execFileOpts(timeoutMs, needsShell, input) { }; } +// Every direct `jf` spawn below goes through the same screen as runJf(). +// An optional `input` is written to the child's stdin. +export function execJf(jfPath, needsShell, args, timeoutMs, input) { + assertJfArgsShellSafe(args, needsShell, "saveCredentials"); + return execFileSync(jfPath, args, execFileOpts(timeoutMs, needsShell, input)); +} + export function saveCredentials(platformUrlRaw, sessionUuid) { if (!platformUrlRaw || !sessionUuid) { process.stderr.write("Usage: node jfrog-login-save-credentials.mjs \n"); @@ -83,12 +94,16 @@ export function saveCredentials(platformUrlRaw, sessionUuid) { process.stderr.write("ERROR: platform URL contains unexpected characters.\n"); return 1; } + if (!SAFE_SESSION_UUID.test(sessionUuid)) { + process.stderr.write("ERROR: session UUID is not a valid UUID.\n"); + return 1; + } selfHealPath(); const { target: jfPath, shell: needsShell } = resolveCommand("jf"); try { - execFileSync(jfPath, ["--version"], execFileOpts(10_000, needsShell)); + execJf(jfPath, needsShell, ["--version"], 10_000); } catch (err) { if (err.code === "ENOENT") { process.stderr.write("ERROR: jf is not installed\n"); @@ -151,19 +166,25 @@ export function saveCredentials(platformUrlRaw, sessionUuid) { // Save credentials to jf config (writes to ~/.jfrog/, needs unrestricted filesystem) try { - execFileSync(jfPath, ["config", "remove", serverId, "--quiet"], execFileOpts(10_000, needsShell)); + execJf(jfPath, needsShell, ["config", "remove", serverId, "--quiet"], 10_000); } catch { // No existing entry to remove — fine. } // Token goes over stdin, never argv — argv is visible to other local users // in process listings (and, under a `.cmd` shim, in cmd.exe's command line). + const addArgs = ["config", "add", serverId, `--url=${platformUrl}`, "--access-token-stdin", "--interactive=false"]; + // Screened before the try so a rejected argument isn't reported below as a + // sandbox problem. + try { + assertJfArgsShellSafe(addArgs, needsShell, "saveCredentials"); + } catch (err) { + process.stderr.write(`ERROR: ${err.message}\n`); + return 4; + } + try { - execFileSync( - jfPath, - ["config", "add", serverId, `--url=${platformUrl}`, "--access-token-stdin", "--interactive=false"], - execFileOpts(15_000, needsShell, accessToken) - ); + execJf(jfPath, needsShell, addArgs, 15_000, accessToken); } 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"); diff --git a/skills/jfrog-init/scripts/lib/jf-api.mjs b/skills/jfrog-init/scripts/lib/jf-api.mjs index 92ba907..aabe075 100644 --- a/skills/jfrog-init/scripts/lib/jf-api.mjs +++ b/skills/jfrog-init/scripts/lib/jf-api.mjs @@ -7,6 +7,7 @@ import { execFileSync } from "node:child_process"; import { resolveCommand } from "./command.mjs"; +import { assertJfArgsShellSafe } from "./jf.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 @@ -28,14 +29,19 @@ export function parseHttpStatus(text) { // 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) { +// bug. A shell-unsafe arg under a `.cmd` shim is rejected before anything +// spawns and comes back as `{ ok: false }` too, with the reason in `stderr` +// (this function never throws). JSON never passes that screen, so a +// request body goes in via `input` (stdin, read by `--input -`). +export function jfApi(args, { input } = {}) { + const { target, shell } = resolveCommand("jf"); try { - const { target, shell } = resolveCommand("jf"); + assertJfArgsShellSafe(args, shell, "jfApi"); const stdout = execFileSync(target, ["api", ...args], { encoding: "utf8", timeout: JF_API_TIMEOUT_MS, shell, + input, // 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 @@ -43,14 +49,16 @@ export function jfApi(args) { // 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"], + // everything is still captured. stdin is piped only to carry `input`. + stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], env: { ...process.env, CI: "true" }, }); return { ok: true, stdout, stderr: "" }; } catch (err) { const stdout = err.stdout ? err.stdout.toString() : ""; - const stderr = err.stderr ? err.stderr.toString() : ""; + // A rejected arg never spawned, so there is no child stderr: surface the + // rejection message instead. + const stderr = err.stderr ? err.stderr.toString() : /refusing shell-unsafe/.test(err.message) ? err.message : ""; 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 index 944452f..0664d26 100755 --- a/skills/jfrog-init/scripts/lib/jf.mjs +++ b/skills/jfrog-init/scripts/lib/jf.mjs @@ -82,17 +82,22 @@ export const JF_CLI_TIMEOUT_MS = 30_000; // SHELL_UNSAFE, a `jf` arg (e.g. a server ID) may legitimately contain one. export const JF_ARG_UNSAFE = /[&|;$<>`"'\\^\n]/; +// The screen every `jf` spawn with caller-built args applies first — runJf(), +// lib/jf-api.mjs's jfApi(), jfrog-login-save-credentials.mjs — so none drifts. +export function assertJfArgsShellSafe(args, shell, caller) { + if (!shell) return; + const unsafe = args.find((a) => JF_ARG_UNSAFE.test(a)); + if (unsafe !== undefined) { + throw new Error(`${caller}: refusing shell-unsafe argument: ${JSON.stringify(unsafe)}`); + } +} + // 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"); - if (shell) { - const unsafe = args.find((a) => JF_ARG_UNSAFE.test(a)); - if (unsafe !== undefined) { - throw new Error(`runJf: refusing shell-unsafe argument: ${JSON.stringify(unsafe)}`); - } - } + assertJfArgsShellSafe(args, shell, "runJf"); // Without this, execFileSync forwards jf's stderr to ours. // Node's DEP0190 fires on every shell:true spawn with an args array, // warning generically about unescaped-argument injection — the exact diff --git a/skills/jfrog-mcp-management/references/harness-common.md b/skills/jfrog-mcp-management/references/harness-common.md index 8c9a33b..36751b3 100644 --- a/skills/jfrog-mcp-management/references/harness-common.md +++ b/skills/jfrog-mcp-management/references/harness-common.md @@ -11,7 +11,7 @@ plus **exactly one** harness file; do NOT open the others. ## Step A — detect the harness and open ONE file The `CLAUDECODE` / `CURSOR_*` / `CODEX_*` / `OPENCODE` signals below -mirror `../../jfrog/scripts/check-environment.sh` `detect_harness()`; the +mirror `../../jfrog/scripts/check-environment.mjs` `detectHarness()`; the `TERM_PROGRAM=vscode` editor hint is **not** in that script, and Devin and Kiro are **not** detected by the script. Each row's signal is **self-contained and non-overlapping**, so detection does not depend on evaluation order. The VS @@ -19,7 +19,7 @@ Code harness file targets the **VS Code editor** (Copilot MCP support), not the standalone GitHub Copilot terminal CLI — the CLI (`COPILOT_CLI`) has no editor UI or `mcp.json`, so it falls through to the Fallback section. -1. Call `../../jfrog/scripts/check-environment.sh` and parse `tool=` from +1. Run `node "/../jfrog/scripts/check-environment.mjs"` and parse `tool=` from the User-Agent line. When `tool` is `claude` or `cursor`, that matches the Claude or Cursor row below — open that harness file. This call also satisfies the Prerequisites environment check — capture/export diff --git a/skills/jfrog/SKILL.md b/skills/jfrog/SKILL.md index 6f2e8fa..93f0f18 100644 --- a/skills/jfrog/SKILL.md +++ b/skills/jfrog/SKILL.md @@ -15,11 +15,13 @@ description: >- advanced security, exposures, sast, or any JFrog product name. Do NOT use this skill to install, add, remove, list, or manage MCP servers. compatibility: >- - 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. + Requires bash, jq, and awk on PATH (Git Bash or WSL on Windows), and Node.js 18 + or newer for the environment-check scripts (bash fallback if Node is missing). + 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.43.0" + version: "0.44.0" --- # JFrog Skill @@ -153,8 +155,13 @@ 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. Skip the CLI offer during `jfrog-init`. +Run `node --version` first. Node 18+ → use the `.mjs` scripts below. Missing or +older → use the `.sh` twin of every script below with `bash` (needs bash and jq; +Git Bash or WSL on Windows): same arguments incl. `--clear`/`--force`, same +output. + ```bash -bash /scripts/check-environment.sh +node /scripts/check-environment.mjs # 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) @@ -163,16 +170,18 @@ bash /scripts/check-environment.sh Then, on exit 0/1 only: ```bash -bash /scripts/cli-newer-version-offer.sh +node /scripts/cli-newer-version-offer.mjs +# No Node 18+ → bash /scripts/cli-newer-version-offer.sh # SKIP → continue the original task; do not mention the offer. # NEWER_AVAILABLE (suggest_upgrade) → never block or ask # mid-task. Immediately run (before any other command): -# bash /scripts/cli-newer-version-offer.sh --clear +# node /scripts/cli-newer-version-offer.mjs --clear +# (no Node 18+: bash /scripts/cli-newer-version-offer.sh --clear) # Then finish the original task (success or failure) and end the final reply # with this line verbatim as its very last line (no other upgrade mention): # JFrog CLI is available (you have ). Upgrade? Yes / No # Yes (later turn) → references/jfrog-cli-install-upgrade.md, then -# check-environment.sh --force (Tier 2/3 only on exit 0/1). No → silent. +# check-environment.mjs (or .sh) --force (Tier 2/3 only on exit 0/1). No → silent. # Next offer = next new latest. JFROG_CLI_AVOID_NEW_VERSION_WARNING=true → SKIP. ``` @@ -192,7 +201,7 @@ invent, rebuild, or edit it). On exit 2/3 there is no `` — do not synthesi 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 + discard it and **re-run** `check-environment.mjs`. 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 @@ -251,7 +260,7 @@ Use **`jq`** for all JSON parsing of CLI and API output (pipes, `-r`, filters). `${JFROG_CLI_HOME_DIR:-$HOME/.jfrog}/skills-cache/` is **not** a general scratch or temp directory. Use it **only** for these two artifacts: -1. **`jfrog-skill-state.json`** — written by `scripts/check-environment.sh` +1. **`jfrog-skill-state.json`** — written by `scripts/check-environment.mjs` (24-hour CLI check cache). 2. **`onemodel-schema-${JFROG_SERVER_ID}.graphql`** — cached OneModel supergraph schema (see `references/onemodel-graphql.md`). @@ -481,7 +490,7 @@ reused); re-read for `jq` — never re-run the same network call to fix parsing. only when the next action needs `jf api` / advanced CLI: - [ ] `export JFROG_CLI_USER_AGENT=''` — exact stdout line from - `check-environment.sh` exit 0/1; CLI offer done or N/A (`jfrog-init`); + `check-environment.mjs` exit 0/1 (`.sh` if no Node 18+); CLI offer done or N/A (`jfrog-init`); `NEWER_AVAILABLE` = end-of-reply note, never a mid-task stop - [ ] network `jf`: `--server-id ` after subcommand (not `jf --version` / `jf config show` pre-SID) diff --git a/skills/jfrog/references/jfrog-cli-install-upgrade.md b/skills/jfrog/references/jfrog-cli-install-upgrade.md index 84d06f9..c160101 100644 --- a/skills/jfrog/references/jfrog-cli-install-upgrade.md +++ b/skills/jfrog/references/jfrog-cli-install-upgrade.md @@ -2,15 +2,13 @@ ## Minimum version for skills -Skills that call `jf api` require JFrog CLI **2.100.0** or later. On an older CLI -`jf api` is an unknown command, so the login flow stops as a prerequisite failure -rather than reaching the platform. Web login itself needs **2.86.0** or later. - -Check with `jf --version`, and upgrade below that floor using the steps below. +Skills that call `jf api` need JFrog CLI **2.100.0** or later; on an older CLI +`jf api` is an unknown command and the login flow stops. Web login itself needs +**2.86.0** or later. Check with `jf --version`; upgrade below that floor. ## Installing the JFrog CLI -If `jf` is not installed (environment check exits with code 2), guide the user: +If `jf` is missing (environment check exit 2), guide the user: ```bash # macOS @@ -20,23 +18,26 @@ brew install jfrog-cli curl -fL https://install-cli.jfrog.io | sh ``` -After installation, run `jf --version` to confirm, then -`bash /scripts/check-environment.sh --force`. +Then run `jf --version` and +`node /scripts/check-environment.mjs --force` (no Node: +`bash …/check-environment.sh --force`). ## Upgrading the JFrog CLI On `NEWER_AVAILABLE`, run -`bash /scripts/cli-newer-version-offer.sh --clear` immediately. -Never interrupt the task: finish the user's task first, then offer the upgrade -as the last line of the reply. Upgrade only after the user says Yes: +`node /scripts/cli-newer-version-offer.mjs --clear` (no Node 18+: +`bash …/cli-newer-version-offer.sh --clear`) immediately. +Never interrupt: finish the user's task first, then offer the upgrade as the +last line of the reply. Upgrade only after the user says Yes: ```bash # macOS brew upgrade jfrog-cli -# Linux / generic (reinstall) +# Linux / generic curl -fL https://install-cli.jfrog.io | sh ``` -After upgrading, run `jf --version` to confirm, then -`bash /scripts/check-environment.sh --force`. +Then run `jf --version` and +`node /scripts/check-environment.mjs --force` (no Node: +`bash …/check-environment.sh --force`). diff --git a/skills/jfrog/scripts/check-environment.mjs b/skills/jfrog/scripts/check-environment.mjs new file mode 100755 index 0000000..d6c72f0 --- /dev/null +++ b/skills/jfrog/scripts/check-environment.mjs @@ -0,0 +1,726 @@ +#!/usr/bin/env node +// check-environment.mjs — Cached JFrog CLI environment check +// +// Cross-platform port of check-environment.sh (bash does not run natively on +// Windows). Checks if jf is installed and its version, using a 24h-TTL cache +// at ${JFROG_CLI_HOME_DIR:-/.jfrog}/skills-cache/jfrog-skill-state.json +// to avoid redundant checks. The skills-cache/ dir holds only this file and +// the OneModel schema cache — not temp API output. +// +// Usage: +// node check-environment.mjs [] [--force] +// +// stdout: bare JFROG_CLI_USER_AGENT value (one line) — agent captures it +// and exports it once at the top of every shell invocation that +// calls jf +// stderr: JSON state (informational, also written to cache file) +// +// Exit codes: +// 0 — cache fresh, CLI ready +// 1 — cache refreshed, CLI ready +// 2 — jf not installed +// 3 — jf below MIN_CLI_VERSION (required for `jf api`) +// 4 — Node below 18: nothing is run or cached; use check-environment.sh +// +// Env: JFROG_CLI_HOME_DIR overrides the ~/.jfrog cache location +// JFROG_SKILLS_RELEASES_URL overrides the latest-version listing +// (used by tests; default is the public +// releases.jfrog.io listing) +// HTTPS_PROXY / HTTP_PROXY / NO_PROXY +// honored for the latest-version lookup +// (http:// proxies; same as curl in the .sh) +// +// Requires Node.js 18+ (global fetch, AbortSignal.timeout, Object.hasOwn). + +import { spawnSync } from "node:child_process"; +import { + accessSync, + constants as fsConstants, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { homedir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Before anything touches the cache: on Node < 18 there is no global fetch, so +// the latest-version lookup would fail and cache "unknown" for 24h, which then +// hides the upgrade offer from the .sh fallback too. +if (Number(process.versions.node.split(".")[0]) < 18) { + process.stderr.write( + `check-environment.mjs needs Node 18+ (found ${process.versions.node}); ` + + "use bash check-environment.sh instead.\n", + ); + process.exit(4); +} + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const SKILL_ROOT = join(dirname(SCRIPT_PATH), ".."); +const JFROG_HOME = process.env.JFROG_CLI_HOME_DIR || join(homedir(), ".jfrog"); +const CACHE_DIR = join(JFROG_HOME, "skills-cache"); +const CACHE_FILE = join(CACHE_DIR, "jfrog-skill-state.json"); +const DEFAULT_TTL_HOURS = 24; +const RELEASES_URL = + process.env.JFROG_SKILLS_RELEASES_URL || + "https://releases.jfrog.io/artifactory/jfrog-cli/v2-jf/"; + +// Minimum jf CLI version required by this skill. `jf api` (the generic +// authenticated REST pass-through used by nearly every reference in this +// skill) landed in 2.100.0; older CLIs fail with "unknown command: api". +const MIN_CLI_VERSION = "2.100.0"; + +// CLIs >= this version emit ai-agent/ + ai-client/ + ai-model/ (Client→Agent→Model +// via jfrog-cli-core #1602 + jfrog-cli #3645). Omit client= in the skill UA to +// avoid double-encoding. Always emit tool= — mcp-management Step A parses it from +// this script's stdout, which never includes the CLI's ai-agent/ token. +// +// MERGE / RELEASE PIN: tip `CliVersion` is still 2.119.0 while the identity code +// is already on master. Released 2.118/2.119 only appended ai-agent/. Keep this +// gate at the first *released* CLI that ships full Client→Agent→Model (expected +// 2.120.0). When that release cuts, confirm the tag and update this constant if +// the version number differs — do not lower it to tip's 2.119.0. +const AGENT_UA_MIN_CLI_VERSION = "2.120.0"; + +const SEMVER = /\d+\.\d+\.\d+/g; +const EXACT_SEMVER = /^\d+\.\d+\.\d+$/; +const env = (name) => process.env[name] ?? ""; + +// ---------------------------------------------------------------- versions + +function parseVersion(v) { + return v.split(".").map(Number); +} + +// Plain X.Y.Z numeric comparison (`sort -V` in the bash original). +function compareVersions(a, b) { + const pa = parseVersion(a); + const pb = parseVersion(b); + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1; + } + return 0; +} + +// True if `a` is strictly less than `b`. A non-semver side is never "less". +function versionLt(a, b) { + if (!EXACT_SEMVER.test(a) || !EXACT_SEMVER.test(b)) return false; + return compareVersions(a, b) < 0; +} + +// ------------------------------------------------------------- jf resolution + +// A pure-Node PATH scan (PATHEXT-aware on Windows) so no `which`/`where` +// binary is needed and no shell parses the lookup. +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(";") + .filter(Boolean) + .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 — F_OK is the best signal there. + if (!statSync(full).isFile()) continue; + accessSync( + full, + process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK, + ); + return full; + } catch { + // Not in this PATH entry — keep looking. + } + } + } + return ""; +} + +// First X.Y.Z printed by `jf --version`, or "unknown". Node refuses to spawn a +// Windows `.cmd` shim (the npm-installed jf) without a shell, so that one case +// goes through cmd.exe with the path quoted; the only argument is a constant. +function jfVersion(jfPath) { + const viaShell = /\.(cmd|bat)$/i.test(jfPath); + const prevNoDeprecation = process.noDeprecation; + process.noDeprecation = true; + let result; + try { + result = spawnSync(viaShell ? `"${jfPath}"` : jfPath, ["--version"], { + encoding: "utf8", + timeout: 15_000, + shell: viaShell, + }); + } finally { + process.noDeprecation = prevNoDeprecation; + } + const match = `${result.stdout || ""}`.match(SEMVER); + return match ? match[0] : "unknown"; +} + +// ------------------------------------------------------------------ cache + +// The text of the last cache read, so the fresh path can echo exactly what it +// parsed instead of reading the file a second time (it can vanish in between). +let cacheText = ""; + +function readCache() { + try { + cacheText = readFileSync(CACHE_FILE, "utf8"); + const parsed = JSON.parse(cacheText); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : null; + } catch { + return null; + } +} + +function isCacheFresh(cache) { + if (!cache || !cache.checked_at) return false; + const checkedMs = Date.parse(cache.checked_at); + if (Number.isNaN(checkedMs)) return false; + const ttlHours = cache.ttl_hours ?? DEFAULT_TTL_HOURS; + if (typeof ttlHours !== "number" || !Number.isFinite(ttlHours)) return false; + const ageSeconds = + Math.floor(Date.now() / 1000) - Math.floor(checkedMs / 1000); + return ageSeconds < ttlHours * 3600; +} + +function isoNow() { + return new Date().toISOString().replace(/\.\d+Z$/, "Z"); +} + +// Atomic: write a sibling temp file, then rename over the cache. Best-effort: +// an unwritable cache (read-only dir, full disk, a file locked on Windows) only +// costs the next run a refresh, so it must not change the exit code or lose the UA. +function writeCache(state) { + const tmp = join(CACHE_DIR, `.jfrog-skill-state.${process.pid}.tmp`); + try { + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`); + renameSync(tmp, CACHE_FILE); + } catch { + try { + rmSync(tmp, { force: true }); + } catch { + // Nothing more to clean up. + } + } +} + +function emitMinVersionError(version) { + process.stderr.write( + `${JSON.stringify({ + error: `jf CLI ${version} is below minimum ${MIN_CLI_VERSION} required by this skill (needed for 'jf api'). See references/jfrog-cli-install-upgrade.md.`, + })}\n`, + ); +} + +// ------------------------------------------------------------ latest version + +// Node's fetch ignores HTTPS_PROXY/HTTP_PROXY/NO_PROXY, which curl in the .sh +// honors. So behind a proxy the lookup goes through it the way curl does: an +// absolute-URI request for http targets, a CONNECT tunnel for https. Plain +// http:// proxies only (no socks, PAC or NTLM); anything else goes direct. +function bypassesProxy(target, noProxy) { + const host = target.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + const port = target.port || (target.protocol === "https:" ? "443" : "80"); + return noProxy.split(",").some((entry) => { + const [name, entryPort] = entry + .trim() + .toLowerCase() + .split(/:(?=\d+$)/); + if (!name || (entryPort && entryPort !== port)) return false; + if (name === "*") return true; + const suffix = name.replace(/^\*?\./, ""); + return host === suffix || host.endsWith(`.${suffix}`); + }); +} + +function proxyFor(target) { + const env = process.env; + const raw = + target.protocol === "https:" + ? env.https_proxy || env.HTTPS_PROXY + : env.http_proxy || env.HTTP_PROXY; + if (!raw || bypassesProxy(target, env.no_proxy || env.NO_PROXY || "")) { + return null; + } + try { + const proxy = new URL(raw.includes("://") ? raw : `http://${raw}`); + return proxy.protocol === "http:" ? proxy : null; + } catch { + return null; + } +} + +function getViaProxy(target, proxy, signal) { + return new Promise((resolve, reject) => { + const headers = { Host: target.host }; + if (proxy.username) { + const login = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`; + headers["Proxy-Authorization"] = + `Basic ${Buffer.from(login).toString("base64")}`; + } + const send = (req) => { + req.on("error", reject); + req.on("response", (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("error", reject); + res.on("end", () => + resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + body: Buffer.concat(chunks).toString("utf8"), + }), + ); + }); + req.end(); + }; + const viaProxy = { + host: proxy.hostname.replace(/^\[|\]$/g, ""), + port: proxy.port || 80, + signal, + }; + if (target.protocol === "http:") { + send(httpRequest({ ...viaProxy, path: target.href, headers })); + return; + } + const tunnel = httpRequest({ + ...viaProxy, + method: "CONNECT", + path: `${target.hostname}:${target.port || 443}`, + headers, + }); + tunnel.on("error", reject); + tunnel.on("connect", (res, socket) => { + if (res.statusCode !== 200) { + socket.destroy(); + reject(new Error(`proxy answered CONNECT with ${res.statusCode}`)); + return; + } + send( + httpsRequest({ + host: target.hostname, + servername: target.hostname, + path: `${target.pathname}${target.search}`, + socket, + agent: false, + signal, + }), + ); + }); + tunnel.end(); + }); +} + +// Best-effort and non-blocking: any failure is "unknown". +async function fetchLatestVersion() { + try { + const signal = AbortSignal.timeout(5000); + const target = new URL(RELEASES_URL); + const proxy = proxyFor(target); + const res = proxy + ? await getViaProxy(target, proxy, signal) + : await fetch(target, { signal }).then(async (direct) => ({ + ok: direct.ok, + body: await direct.text(), + })); + if (!res.ok) return "unknown"; + const found = res.body.match(SEMVER) || []; + if (found.length === 0) return "unknown"; + return found.reduce((best, v) => (compareVersions(v, best) > 0 ? v : best)); + } catch { + return "unknown"; + } +} + +// Offer once per new latest. suggest_upgrade is tri-state: +// true — pending offer +// false — already offered (or not behind); --clear writes false +// missing / non-semver prev latest — never evaluated → set true when behind +function decideSuggestUpgrade({ cliVersion, latestVersion, prev }) { + const prevLatest = EXACT_SEMVER.test(prev?.latest_version_available ?? "") + ? prev.latest_version_available + : ""; + const prevFlag = + prev?.suggest_upgrade === true + ? "true" + : prev?.suggest_upgrade === false + ? "false" + : ""; + let latest = latestVersion; + let suggest = prevFlag === "true"; + if (!EXACT_SEMVER.test(latest)) { + if (prevLatest) latest = prevLatest; + else suggest = false; + } + if (EXACT_SEMVER.test(latest) && versionLt(cliVersion, latest)) { + if (!prevLatest || prevLatest !== latest) suggest = true; + else suggest = prevFlag !== "false"; + } else if (EXACT_SEMVER.test(latest)) { + suggest = false; + } + return { latest, suggest }; +} + +// ---------------------------------------------------------- UA computation + +// Lowercase and keep only [a-z0-9._-], then truncate to 64 chars — mirrors the +// Go CLI sanitizeToken (cardinality bound + no header-splitting on the wire). +function sanitizeToken(s) { + return String(s ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "") + .slice(0, 64); +} + +// A VS Code-fork askpass path is that editor's install, not an unrelated +// substring (a Windows login named "cursor" would otherwise match). +// GIT_ASKPASS is generic git and is excluded. Mirrors jfrog-cli-core +// askpassPathContains. +function askpassValues() { + return ["VSCODE_GIT_ASKPASS_MAIN", "VSCODE_GIT_ASKPASS_NODE"] + .map((key) => env(key).toLowerCase().replaceAll("\\", "/")) + .filter(Boolean); +} + +function askpassPathContains(app) { + return askpassValues().some( + (p) => + p.includes(`/${app}.app`) || + p.includes(`/${app}/resources`) || + p.includes(`/.${app}-server`), + ); +} + +// Positive upstream VS Code install only. A non-empty VSCODE_GIT_ASKPASS_* var +// is not enough — every fork sets those. +function askpassLooksLikeStockVscode() { + return askpassValues().some( + (p) => + p.includes("/visual studio code") || + p.includes("/microsoft vs code") || + p.includes("/.vscode-server") || + p.includes("/code/resources"), + ); +} + +// Short product names for known terminals. Unmapped values (including inherited +// TERM_PROGRAM=vscode) stay empty so client stays on the known-app allowlist. +const TERMINALS = { + iterm: "iterm", + apple_terminal: "terminal", + warpterminal: "warp", + warp: "warp", + tmux: "tmux", + wezterm: "wezterm", + alacritty: "alacritty", + kitty: "kitty", + ghostty: "ghostty", + hyper: "hyper", +}; + +function canonicalTerminalName(raw) { + let name = sanitizeToken(raw); + if (name === "iterm.app") return "iterm"; + if (name.endsWith(".app")) name = name.slice(0, -".app".length); + // hasOwn: "constructor" / "__proto__" must not resolve through the prototype. + return Object.hasOwn(TERMINALS, name) ? TERMINALS[name] : ""; +} + +function fallbackTerminalName() { + if (env("TMUX")) return "tmux"; + if (env("WT_SESSION")) return "windows-terminal"; + if (env("TERM") === "xterm-ghostty") return "ghostty"; + if (env("KITTY_WINDOW_ID")) return "kitty"; + if (env("ALACRITTY_LOG")) return "alacritty"; + return ""; +} + +function hostTerminalName() { + return canonicalTerminalName(env("TERM_PROGRAM")) || fallbackTerminalName(); +} + +function foldAgentName(raw) { + return String(raw).trim().toLowerCase().split("@")[0]; +} + +function isCopilotVscodePluginAlias() { + return ( + foldAgentName(env("AI_AGENT")) === "github_copilot_vscode_agent" || + foldAgentName(env("AGENT")) === "github_copilot_vscode_agent" + ); +} + +// The app hosting this session. Mirrors detectClient() in +// jfrog-cli-core/common/commands/execution_context.go — keep the two in sync. +// +// Deliberately NOT part of detectHarness(): that table drives Agent Guard +// routing and mcp-management's tool= parsing and stays frozen. This only feeds +// the client= key. +// +// Order matters: every VS Code fork inherits the VSCODE_* vars from upstream, so +// a fork must resolve before anything reports vscode. CURSOR_AGENT does not +// prove the window (standalone CLI in iTerm / CI). +function detectHostClient() { + if (env("ZED_TERM")) return "zed"; + if (env("TERMINAL_EMULATOR") === "JetBrains-JediTerm") return "jetbrains"; + if (env("CURSOR_TRACE_ID") || askpassPathContains("cursor")) return "cursor"; + if (env("WINDSURF_CASCADE_TERMINAL") || askpassPathContains("windsurf")) + return "windsurf"; + if (env("ANTIGRAVITY_AGENT") || askpassPathContains("antigravity")) + return "antigravity"; + if (env("TRAE_AI_SHELL_ID") || askpassPathContains("trae")) return "trae"; + if (askpassPathContains("vscodium") || askpassPathContains("codium")) + return "codium"; + if (env("VisualStudioVersion")) return "visualstudio"; + if (askpassLooksLikeStockVscode()) return "vscode"; + if (env("COPILOT_AGENT") === "1" || isCopilotVscodePluginAlias()) { + return hostTerminalName() || "vscode"; + } + if (env("CLAUDE_CODE_CHILD_SESSION") || env("CLAUDE_CODE_IS_COWORK")) + return "claude"; + return hostTerminalName(); +} + +// Map a generic AI_AGENT/AGENT value (agents.md proposal, @vercel/detect-agent) +// to a canonical name. Strips a version suffix (e.g. "goose@1.2.3") and lowercases. +// Empty input → empty; unrecognized non-empty → "unknown". +// Accepts both hyphenated ecosystem ids and our underscore/canonical forms so +// AI_AGENT=roo_code / amazon_q / qwen round-trip the same as the Go CLI. +const AGENT_ALIASES = { + "claude-code": "claude", + claude: "claude", + "gemini-cli": "gemini", + gemini: "gemini", + goose: "goose", + "cursor-cli": "cursor", + cursor: "cursor", + "github-copilot": "copilot", + "copilot-cli": "copilot", + copilot: "copilot", + github_copilot_vscode_agent: "copilot", + kilocode: "kilocode", + "roo-code": "roo_code", + roo_code: "roo_code", + codex: "codex", + windsurf: "windsurf", + aider: "aider", + cline: "cline", + opencode: "opencode", + "opencode-jfrog-plugin": "opencode", + amp: "amp", + augment: "augment", + "qwen-code": "qwen", + qwen: "qwen", + antigravity: "antigravity", + crush: "crush", + iflow: "iflow", + trae: "trae", + "amazon-q-cli": "amazon_q", + "amazon-q": "amazon_q", + amazon_q: "amazon_q", + junie: "junie", +}; + +function canonicalAgentName(input) { + const raw = foldAgentName(input); + if (raw === "") return ""; + return Object.hasOwn(AGENT_ALIASES, raw) ? AGENT_ALIASES[raw] : "unknown"; +} + +// Detect the calling harness from environment signals. First-match order matches +// the JFrog CLI's DetectExecutionContext() table (claude, gemini, goose, cursor, +// …) plus v0.22.0 product envs so mcp-management Step A still sees tool=claude / +// tool=cursor in Claude Code / Cursor IDE terminals (CLAUDECODE, CURSOR_TRACE_ID). +// Those product envs are also set for humans; real agent skill usage is the +// model= slug. Devin Desktop is not detected here — see harness-common.md. +// MODEL_SLUG→unknown fallback is applied by emitSkillEnv (not here). +function detectHarness() { + const any = (...names) => names.some((name) => env(name)); + if (any("CLAUDE_CODE_CHILD_SESSION", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT")) + return "claude"; + if (any("GEMINI_CLI")) return "gemini"; + if (any("GOOSE_TERMINAL")) return "goose"; + if ( + any("CURSOR_AGENT", "CURSOR_CLI", "CURSOR_TRACE_ID") || + env("CURSOR_EXTENSION_HOST_ROLE") === "agent-exec" + ) { + return "cursor"; + } + if (any("COPILOT_CLI", "COPILOT_AGENT_SESSION_ID", "COPILOT_AGENT")) + return "copilot"; + if (any("KILOCODE_FEATURE", "KILO_PID")) return "kilocode"; + if (any("ROO_ACTIVE", "ROO_CLI_RUNTIME")) return "roo_code"; + if (any("CODEX_CI", "CODEX_THREAD_ID", "CODEX_SANDBOX")) return "codex"; + if (any("WINDSURF_CASCADE_TERMINAL")) return "windsurf"; + if (any("CLINE_ACTIVE")) return "cline"; + if (any("OPENCODE", "OPENCODE_SESSION_ID")) return "opencode"; + if (any("AMP_CURRENT_THREAD_ID")) return "amp"; + if (any("AUGMENT_AGENT")) return "augment"; + if (any("QWEN_CODE")) return "qwen"; + if (any("ANTIGRAVITY_AGENT")) return "antigravity"; + if (any("CRUSH")) return "crush"; + if (any("IFLOW_CLI")) return "iflow"; + if (any("TRAE_AI_SHELL_ID")) return "trae"; + // Junie (JetBrains): JUNIE_* env or a ~/.junie/ install; earlier real-agent checks win first. + if ( + any("JUNIE_DATA", "JUNIE_SHIM_PATH") || + // import.meta.url is the real path; argv[1] keeps a symlinked + // ~/.junie/skills/... spelling, which is how skills are installed. + [SCRIPT_PATH, process.argv[1] ?? ""].some((path) => + path.replaceAll("\\", "/").includes("/.junie/"), + ) + ) { + return "junie"; + } + // aider and amazon_q have no reliable session env — AI_AGENT / AGENT only. + if (any("AI_AGENT", "AGENT")) + return canonicalAgentName(env("AI_AGENT") || env("AGENT")); + // No match → empty; the emitter may still apply MODEL_SLUG→unknown. + return ""; +} + +// Version from SKILL.md YAML frontmatter (metadata.version), or "unknown". +function skillVersion() { + try { + let fences = 0; + for (const line of readFileSync(join(SKILL_ROOT, "SKILL.md"), "utf8").split( + /\r?\n/, + )) { + if (line === "---") { + fences++; + continue; + } + if (fences === 1 && /^\s*version:/.test(line)) { + const value = line.trim().split(/\s+/)[1]?.replace(/["']/g, "").trim(); + return value || "unknown"; + } + if (fences > 1) break; + } + } catch { + // SKILL.md unreadable — fall through. + } + return "unknown"; +} + +// The UA line (stdout) for the skill path. +function buildUserAgent({ modelSlugArg, jfPath }) { + // Prefer a live `jf --version` so the AGENT_UA_MIN omit-gate is not stuck on a + // stale cache for up to 24h after the user upgrades the CLI. + let cliVersion = jfPath ? jfVersion(jfPath) : "unknown"; + if (cliVersion === "unknown") + cliVersion = readCache()?.cli_version || "unknown"; + // Sanitize the model slug once so the MODEL_SLUG→unknown fallback and the + // wire model= key share the same cardinality-bounded, header-safe value + // (mirrors the CLI's sanitizeToken on JFROG_CLI_AI_MODEL). + const modelSlug = sanitizeToken(modelSlugArg); + let harness = detectHarness(); + // Defense: re-canonicalize so alias wire values (e.g. claude-code from a + // hand-built AI_AGENT / future detector slip) never reach tool=. Empty stays + // empty; known aliases map; unrecognized non-empty → unknown. + if (harness) harness = canonicalAgentName(harness) || harness; + // Agent invoked us (passed a model slug) but set no harness signal the CLI + // shares — CLI will not emit ai-agent/, so the skill must still carry tool=. + let harnessFromModelFallback = false; + if (!harness && modelSlug) { + harness = "unknown"; + harnessFromModelFallback = true; + } + // Client: the editor window hosting the session (see detectHostClient). + // Omitted on new CLI when the CLI will emit ai-client/ itself (not on the + // model-slug fallback path). + const client = detectHostClient(); + const carryClientUa = + cliVersion === "unknown" || + versionLt(cliVersion, AGENT_UA_MIN_CLI_VERSION) || + harnessFromModelFallback; + // trigger=skill always leads — this script only runs on the skill path. + // (APR agent-hooks set trigger=hook when they spawn jf; see eager-setup.) + // tool= is always emitted when known: mcp-management parses this stdout line + // and never sees the CLI's later ai-agent/ token. + let meta = "trigger=skill"; + if (harness) meta += `; tool=${harness}`; + if (carryClientUa && harness && client) meta += `; client=${client}`; + // model= is emitted regardless of CLI version (not deduped like tool=/client=): + // the CLI's own ai-model/ token is conditional on it detecting the agent via + // env AND the caller exporting JFROG_CLI_AI_MODEL, so the skill can't know + // whether the CLI will carry it. Keeping model= here guarantees the slug is + // always recorded; Coralogix coalesces the two sources so it isn't counted twice. + if (modelSlug) meta += `; model=${modelSlug}`; + return `jfrog-skills/${skillVersion()} (${meta}) jfrog-cli-go/${cliVersion}`; +} + +// -------------------------------------------------------------------- main + +async function main(argv) { + let force = false; + let modelSlugArg = ""; + for (const arg of argv) { + if (arg === "--force") force = true; + else if (!modelSlugArg) modelSlugArg = arg; + } + + const cache = readCache(); + if (!force && isCacheFresh(cache)) { + process.stderr.write(cacheText); + // Re-evaluate the minimum on every run so a bumped MIN_CLI_VERSION + // is enforced without waiting for the 24h cache to expire. + const cachedVersion = cache.cli_version || "unknown"; + if (versionLt(cachedVersion, MIN_CLI_VERSION)) { + emitMinVersionError(cachedVersion); + return 3; + } + const jfPath = resolveBinaryPath("jf"); + process.stdout.write(`${buildUserAgent({ modelSlugArg, jfPath })}\n`); + return 0; + } + + const jfPath = resolveBinaryPath("jf"); + if (!jfPath) { + process.stderr.write('{"cli_installed": false}\n'); + return 2; + } + + const cliVersion = jfVersion(jfPath); + const meetsMinimum = + cliVersion !== "unknown" && !versionLt(cliVersion, MIN_CLI_VERSION); + const { latest, suggest } = decideSuggestUpgrade({ + cliVersion, + latestVersion: await fetchLatestVersion(), + prev: cache, + }); + const state = { + checked_at: isoNow(), + ttl_hours: DEFAULT_TTL_HOURS, + cli_installed: true, + cli_path: jfPath, + cli_version: cliVersion, + minimum_version: MIN_CLI_VERSION, + meets_minimum_version: meetsMinimum, + latest_version_available: latest, + suggest_upgrade: suggest, + }; + writeCache(state); + process.stderr.write(`${JSON.stringify(state, null, 2)}\n`); + + if (cliVersion !== "unknown" && versionLt(cliVersion, MIN_CLI_VERSION)) { + emitMinVersionError(cliVersion); + return 3; + } + process.stdout.write(`${buildUserAgent({ modelSlugArg, jfPath })}\n`); + return 1; +} + +process.exitCode = await main(process.argv.slice(2)); diff --git a/skills/jfrog/scripts/cli-newer-version-offer.mjs b/skills/jfrog/scripts/cli-newer-version-offer.mjs new file mode 100755 index 0000000..4648114 --- /dev/null +++ b/skills/jfrog/scripts/cli-newer-version-offer.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// cli-newer-version-offer.mjs — Read the pending CLI upgrade flag. +// +// Cross-platform port of cli-newer-version-offer.sh. +// +// check-environment.mjs is the only writer of suggest_upgrade (true when +// latest is first evaluated or replaced and is strictly newer than +// cli_version). This script only reads that bool; --clear writes false as +// soon as the agent has read NEWER_AVAILABLE. It does not run jf. +// +// --clear means "mark this latest as already offered", not "clear the cache": +// it flips suggest_upgrade to false and deliberately leaves cli_version and +// latest_version_available intact, which is what makes the next offer wait for +// a genuinely new latest instead of re-offering this one. +// +// JFROG_CLI_AVOID_NEW_VERSION_WARNING=true (case-insensitive, same check as the +// jf CLI's own new-version warning) forces SKIP without touching the cache. +// +// Usage: +// node cli-newer-version-offer.mjs +// node cli-newer-version-offer.mjs --clear +// +// stdout (exactly one line, always exit 0): +// SKIP +// NEWER_AVAILABLE + +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const JFROG_HOME = process.env.JFROG_CLI_HOME_DIR || join(homedir(), ".jfrog"); +const CACHE_DIR = join(JFROG_HOME, "skills-cache"); +const CACHE_FILE = join(CACHE_DIR, "jfrog-skill-state.json"); + +function emit(line) { + process.stdout.write(`${line}\n`); + process.exit(0); +} + +function readCache() { + try { + const parsed = JSON.parse(readFileSync(CACHE_FILE, "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : null; + } catch { + return null; + } +} + +// Best-effort, atomic: a failed write must never fail the offer. +function writeCache(state) { + const tmp = join(CACHE_DIR, `.jfrog-skill-state.${process.pid}.tmp`); + try { + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync(tmp, `${JSON.stringify(state)}\n`); + renameSync(tmp, CACHE_FILE); + } catch { + try { + rmSync(tmp, { force: true }); + } catch { + // Nothing more to clean up. + } + } +} + +const cache = readCache(); + +if (process.argv[2] === "--clear") { + if (cache) writeCache({ ...cache, suggest_upgrade: false }); + emit("SKIP"); +} + +if ( + (process.env.JFROG_CLI_AVOID_NEW_VERSION_WARNING ?? "").toLowerCase() === + "true" +) { + emit("SKIP"); +} + +if (cache?.suggest_upgrade === true) { + emit( + `NEWER_AVAILABLE ${cache.cli_version ?? ""} ${cache.latest_version_available ?? ""}`, + ); +} +emit("SKIP"); diff --git a/skills/jfrog/scripts/jfrog-login-save-credentials.sh b/skills/jfrog/scripts/jfrog-login-save-credentials.sh index 03ff0ea..080e6c3 100755 --- a/skills/jfrog/scripts/jfrog-login-save-credentials.sh +++ b/skills/jfrog/scripts/jfrog-login-save-credentials.sh @@ -48,6 +48,12 @@ if [[ -z "$JFROG_PLATFORM_URL" || -z "$SESSION_UUID" ]]; then exit 1 fi +# The session UUID goes into the token path: accept only a canonical UUID. +if [[ ! "$SESSION_UUID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then + echo "ERROR: session UUID is not a valid UUID." >&2 + exit 1 +fi + JFROG_PLATFORM_URL="${JFROG_PLATFORM_URL%/}" for cmd in jq jf; do