Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/sync-modules-vendor.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"repo": "JFROG/jfrog-agent-hooks",
"pin": "jfrog-agent-hooks/v0.12.2",
"pin": "jfrog-agent-hooks/v0.13.0",
"paths": [
"modules"
]
Expand Down
7 changes: 6 additions & 1 deletion modules/core/jf-user-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import { spawnJfSync } from "./jf-process.mjs";
// Plugin sync stamps this literal with the release semver (jfrog-sync-modules.py
// stamp). Only `modules/` is vendored, so nothing outside this tree is readable
// at runtime. Unstamped trees (this repo, local dev) report 0.0.0.
const PKG_VERSION = "0.12.2";
const PKG_VERSION = "0.13.0";

/** Hooks release semver stamped at plugin sync ("0.0.0" when unstamped). */
export function hooksPackageVersion() {
return PKG_VERSION;
}

/** Product UA for plugin `fetch()` (no trigger, no jfrog-cli-go). */
export function skillsProductUserAgent() {
Expand Down
121 changes: 121 additions & 0 deletions modules/core/jpd-get.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// GET a JPD path through `jf api` (so jf's proxy settings apply), or through
// fetch when jf is missing or older than 2.100.0. jf runs with the caller's env,
// so the caller sets JFROG_CLI_USER_AGENT.

import { execFile } from "node:child_process";

import { authHeader, safeErrorMessage } from "./jf-identity.mjs";
import { jfInvocation } from "./jf-process.mjs";
import { skillsProductUserAgent } from "./jf-user-agent.mjs";

// Never throws. Resolves to { status, body } (body: parsed JSON of a 2xx, else
// null), or { error } when there was no HTTP answer.
export async function jpdGet(
apiPath,
identity,
{ env, timeoutMs, runJfFn = runJf, fetchFn = fetch },
) {
if (identity.serverId) {
const viaJf = await getWithJf(apiPath, identity.serverId, {
env,
timeoutMs,
runJfFn,
});
if (viaJf) return viaJf;
}
return getWithFetch(apiPath, identity, { timeoutMs, fetchFn });
}

// Asks through `jf api`. Returns null when jf can't run it,
// so the caller falls back to fetch.
async function getWithJf(apiPath, serverId, { env, timeoutMs, runJfFn }) {
const run = await runJfFn(
["api", "--server-id", serverId, "-H", "Accept: application/json", apiPath],
{ env, timeoutMs },
);
if (run.missing) return null;
if (run.timedOut) return { error: "timeout" };
const status = parseHttpStatus(run.stderr) || (run.code === 0 ? 200 : 0);
if (status) {
const ok = status >= 200 && status < 300;
return { status, body: ok ? parseJson(run.stdout) : null };
}
if (/is not a jf command|unknown command/i.test(run.stderr + run.stdout)) {
return null;
}
return { error: lastLine(run.stderr) || `jf api exited with ${run.code}` };
}

// Asks directly with the token. Used only when `jf api` can't be used.
async function getWithFetch(apiPath, identity, { timeoutMs, fetchFn }) {
const root = identity.url.replace(/\/+$/, "").replace(/\/artifactory$/, "");
try {
const response = await fetchFn(root + apiPath, {
headers: {
Accept: "application/json",
Authorization: authHeader(identity),
"User-Agent": skillsProductUserAgent(),
},
signal: AbortSignal.timeout(timeoutMs),
});
const body = response.ok ? await response.json().catch(() => null) : null;
return { status: response.status, body };
} catch (err) {
return {
error: err?.name === "TimeoutError" ? "timeout" : safeErrorMessage(err),
};
}
}

// Runs jf and waits for it to finish. Never throws: it returns the output, and
// flags a jf killed at the timeout (timedOut) or not installed (missing).
export function runJf(args, { env, timeoutMs }) {
const { command, args: argv } = jfInvocation(args, { env });
return new Promise((resolve) => {
execFile(
command,
argv,
{ env, timeout: timeoutMs, windowsHide: true, encoding: "utf8" },
(err, stdout, stderr) => {
resolve({
code: err ? (Number.isInteger(err.code) ? err.code : 1) : 0,
stdout: String(stdout ?? ""),
stderr: String(stderr ?? ""),
timedOut: Boolean(err?.killed),
missing: err?.code === "ENOENT",
});
},
);
});
}

// jf api reports the HTTP status only as a stderr line.
// Returns that number, or 0 when there's none.
// The last match wins, so a retry line can't hide the final status.
function parseHttpStatus(text) {
let status = 0;
for (const line of text.split("\n")) {
const match = line.match(/Http Status:\s*(\d{3})/);
if (match) status = Number(match[1]);
}
return status;
}

// The response body jf api prints on stdout. Returns null when it isn't valid JSON.
function parseJson(stdout) {
try {
return JSON.parse(stdout);
} catch {
return null;
}
}

// The error to report when jf failed with no HTTP answer.
function lastLine(stderr) {
const line = stderr
.trim()
.split("\n")
.at(-1)
?.replace(/^\s*\[\w+\]\s*/, "");
return line ? safeErrorMessage({ message: line }) : "";
}
Loading
Loading