Skip to content
Closed
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.14",
"version": "0.1.15",
"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.15.0",
"paths": [
"modules"
]
Expand Down
37 changes: 30 additions & 7 deletions docs/package-resolution-admin-guide.md

Large diffs are not rendered by default.

47 changes: 42 additions & 5 deletions modules/assets/agents-conf-fingerprints.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,64 @@
{
"id": "v0-placeholders-no-onboardingPrompt",
"sha256": "452b737ede2af5da3ea660cb0a2226d422b5624fa1684bc883279422c0728421",
"note": "Legacy template with example repo keys, before onboardingPrompt"
"note": "v0.8.x template: enabled false + example repo keys, before onboardingPrompt. Turned on; the keys stay for the routing session to check",
"migrate": ["enable"]
},
{
"id": "v0-placeholders-enabled-by-migration",
"sha256": "f4580cf6b09af744cae816b8f47c29105b0d6a67b0f89447cf286cc666be88c7",
"note": "v0.8.x template after the v0.11–v0.14 migration turned it on with the example repo keys; keys a session proves missing or wrong-type are pruned, working ones stay. A partly cleaned file records this id and can be cleaned again",
"exampleRepos": {
"npm": "npm-virtual",
"pypi": "pypi-virtual",
"maven": "maven-virtual",
"gradle": "gradle-virtual",
"go": "go-virtual",
"docker": "docker-virtual",
"helm": "helm-virtual",
"nuget": "nuget-virtual"
},
"migrate": ["prune-repos"]
},
{
"id": "v0-placeholders-enabled-prompt-off",
"sha256": "8dcb38e74a8a973bfec584a20221c858f8a04d0371ce9413a0a1dd01aa8cf148",
"note": "Same, then a global dismiss set onboardingPrompt: off. A partly cleaned file records this id and can be cleaned again",
"exampleRepos": {
"npm": "npm-virtual",
"pypi": "pypi-virtual",
"maven": "maven-virtual",
"gradle": "gradle-virtual",
"go": "go-virtual",
"docker": "docker-virtual",
"helm": "helm-virtual",
"nuget": "nuget-virtual"
},
"migrate": ["prune-repos"]
},
{
"id": "v1-placeholders-onboardingPrompt-auto",
"sha256": "b19251b4671db244a8050885bcbaf5f217f0e4eecfec34c0338264b08fa7c871",
"note": "Legacy template with example repo keys + onboardingPrompt: auto"
"note": "Same as v0-placeholders-enabled-by-migration, then the user set enabled: false",
"migrate": ["clear-repos", "silence"]
},
{
"id": "v2-empty-defaultGlobalRepos",
"sha256": "5a104c83c4cb67f2cb01d71ad0044a438f9125bab868ef76e24bd7be7828b82b",
"note": "Empty defaultGlobalRepos after #84 (no onboardingPrompt)"
"note": "v0.9.x–v0.10.x template: enabled false + empty defaultGlobalRepos, no onboardingPrompt",
"migrate": ["enable"]
},
{
"id": "v3-empty-onboardingPrompt-auto",
"sha256": "8b68d55af89e2dadf4ff0c3ae0784b70051c24d1fb75e3ea6df8ba3044c5cefa",
"note": "Legacy template: enabled false + empty defaultGlobalRepos + onboardingPrompt: auto"
"note": "Current template with enabled: false — the user's explicit off, never re-enabled; silence pins it for every plugin version",
"migrate": ["silence"]
},
{
"id": "v4-enabled-onboardingPrompt-auto",
"sha256": "f0481d915f1f7f2a1e7d88ab23ce7b9430d3e44e41aaabb4d4d13e2b40963ae2",
"note": "Current shipped template: enabled true + empty defaultGlobalRepos + onboardingPrompt: auto"
"note": "Current shipped template: enabled true + empty defaultGlobalRepos + onboardingPrompt: auto",
"migrate": []
}
]
}
23 changes: 20 additions & 3 deletions modules/core/agents-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,11 @@ function normalizeOnboardingPrompt(raw) {
* `packageResolution.defaultGlobalRepos` and `autoSetup` are replaced when
* present in the patch (Consent Enable replaces the map with verified keys only).
* @param {object} patch
* @param {{ onlyIf?: (current: object) => boolean }} [opts] `onlyIf` sees the
* on-disk config under the lock; returning false skips the patch
* @returns {object | null} the written config, or null when `onlyIf` declined
*/
export function mergeAgentsConfigPatch(patch) {
export function mergeAgentsConfigPatch(patch, { onlyIf } = {}) {
return withAgentsConfigLock(() => {
ensureAgentsConfigScaffoldUnlocked();
const configPath = agentsConfigPath();
Expand Down Expand Up @@ -351,6 +354,7 @@ export function mergeAgentsConfigPatch(patch) {
}
current = {};
}
if (onlyIf && !onlyIf(current)) return null;
const next = deepMerge(current, patch);
if (
patch?.packageResolution &&
Expand All @@ -370,6 +374,17 @@ export function mergeAgentsConfigPatch(patch) {
next.packageResolution = next.packageResolution ?? {};
next.packageResolution.autoSetup = patch.packageResolution.autoSetup;
}
// null clears the prune marker. A string value is kept by deepMerge.
if (
patch?.packageResolution &&
Object.prototype.hasOwnProperty.call(
patch.packageResolution,
"scaffoldLineage",
) &&
patch.packageResolution.scaffoldLineage == null
) {
delete next.packageResolution?.scaffoldLineage;
}
mkdirSync(path.dirname(configPath), { recursive: true });
const tmp = `${configPath}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
Expand Down Expand Up @@ -453,12 +468,14 @@ export function normalizeAutoSetup(raw) {
return out;
}

/** Trim string repo keys; drop empty values. */
/** Trim string repo keys; drop empty, unsafe, and non-string values. */
export function normalizeRepoMap(raw) {
if (!raw || typeof raw !== "object") return {};
const out = {};
for (const [type, key] of Object.entries(raw)) {
if (isSafeRepoKey(key?.trim())) out[type] = key.trim();
if (typeof key === "string" && isSafeRepoKey(key.trim())) {
out[type] = key.trim();
}
}
return out;
}
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.15.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