Skip to content

Commit 874a8ca

Browse files
committed
@W-23201206: build-mule-integration — port skill scripts bash to Node (hybrid, template-preserving)
Replaces the bash helper scripts with the validated OS-agnostic Node port from mule-dx-agent-evaluation, while preserving the template surface this skill already ships: - scripts/*.sh -> scripts/*.mjs (11 scripts, ESM, zero npm deps) + new lib/*.mjs (7 shared modules). Drops scripts/_suggest_nearest.py (its only consumer, validate_before_build, now uses lib/nearest.mjs). - SKILL.md body synced to the Node version (node scripts/<name>.mjs invocations, update-mode + Step-8 mule-artifact alignment). Bash->Node is a breaking change for direct script callers, so the skill majors: 1.3.0 -> 2.0.0. - references/template-project-creation.md node-ified (.sh -> .mjs); template search (search_templates.mjs), Step 1b, and the Exchange/Local flows preserved. - package.json: 1.5.0 -> 2.0.0 and files += "*/lib/**" so the imported lib/ modules publish (Node scripts import from ../lib/*.mjs). validate-skills (R1-R7) and validate-jtbd both pass; all 18 .mjs node --check clean.
1 parent 9696fef commit 874a8ca

32 files changed

Lines changed: 2688 additions & 1743 deletions

skills/mule-development/build-mule-integration/SKILL.md

Lines changed: 110 additions & 83 deletions
Large diffs are not rendered by default.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// anypoint.mjs — wrap anypoint-cli-v4 invocations and toolchain probes.
2+
// Reproduces NODE_NO_WARNINGS=1, _JAVA_OPTIONS handling, env -u ANYPOINT_ENV via spawn env.
3+
import { spawnSync } from 'node:child_process';
4+
5+
/** @param {Record<string,string>} [extra] Overrides merged last. @returns {Record<string,string>} A child env mirroring `env -u ANYPOINT_ENV NODE_NO_WARNINGS=1`. */
6+
export function anypointEnv(extra = {}) {
7+
const env = { ...process.env };
8+
delete env.ANYPOINT_ENV;
9+
env.NODE_NO_WARNINGS = '1';
10+
return { ...env, ...extra };
11+
}
12+
13+
/** @param {string} cmd Executable name. @returns {boolean} True iff resolvable on PATH (uses `where` on Windows, `command -v` on POSIX). */
14+
export function commandExists(cmd) {
15+
const isWin = process.platform === 'win32';
16+
if (isWin) {
17+
const r = spawnSync('where', [cmd], { stdio: 'ignore' });
18+
return r.status === 0;
19+
}
20+
// POSIX: `command -v` is a shell builtin; invoke it through /bin/sh.
21+
// The `'_'` argv[3] slot is `$0` — a script-name placeholder that lets `cmd`
22+
// arrive as `$1` for the inline shell snippet.
23+
const r = spawnSync('/bin/sh', ['-c', `command -v "$1" >/dev/null 2>&1`, '_', cmd], { stdio: 'ignore' });
24+
return r.status === 0;
25+
}
26+
27+
/** @param {string} cmd Executable. @param {string[]} args Argv. @param {{env?:Record<string,string>}} [opts] @returns {{status:number|null, signal:string|null, error:Error|undefined, stdout:string, stderr:string}} Synchronous spawn capture with anypointEnv() applied. */
28+
export function runProbe(cmd, args, opts = {}) {
29+
const r = spawnSync(cmd, args, {
30+
env: anypointEnv(opts.env),
31+
encoding: 'utf8',
32+
shell: false,
33+
});
34+
return {
35+
status: r.status,
36+
signal: r.signal,
37+
error: r.error,
38+
stdout: r.stdout || '',
39+
stderr: r.stderr || '',
40+
};
41+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// exchange-rank.mjs — port of the jq filter used by get_latest_connector.sh.
2+
//
3+
// rankCandidates(pageA, pageB, searchTerm) takes the two parsed Exchange asset
4+
// arrays + the original search term, and returns a list of ranked GAV strings
5+
// shaped exactly the way the bash original prints to stdout:
6+
// "<groupId>:<assetId>:<version>" one per line.
7+
//
8+
// The ranking algorithm is intentionally a verbatim port of the jq filter:
9+
// 1. Concatenate page A + page B.
10+
// 2. Keep only type=="extension".
11+
// 3. Drop pre-release versions matching /-(SNAPSHOT|RC|alpha|beta|M[0-9])/.
12+
// 4. Group by [groupId, assetId]; within each group keep the highest version,
13+
// where versions are compared by splitting on "." and coercing each segment
14+
// to a number (non-numeric segments become 0, matching `tonumber? // 0`).
15+
// 5. Tokenize search term and asset id by lowercase + split("-"), discarding
16+
// "" / "mule" / "connector" tokens (jq's `select(. != "" and …)`).
17+
// 6. Score by token overlap:
18+
// score = 2 * exact + substring - unmatched
19+
// where:
20+
// - exact: asset token appears in search-token set.
21+
// - substring: not exact AND token len >= 2 AND there exists a search
22+
// token of len >= 2 such that one is a substring of the other.
23+
// - unmatched: neither.
24+
// 7. Group preference: com.mulesoft.connectors=0, org.mule.connectors=1, else=2.
25+
// 8. Sort ascending by [-score, groupPref, assetId.length].
26+
//
27+
// jq numeric comparison on arrays compares element-by-element. We replicate
28+
// that by sorting groups via a key array and a tuple-aware comparator.
29+
30+
const PRERELEASE_RE = /-(SNAPSHOT|RC|alpha|beta|M[0-9])/;
31+
32+
function tokenize(s) {
33+
return String(s)
34+
.toLowerCase()
35+
.split('-')
36+
.filter((t) => t !== '' && t !== 'mule' && t !== 'connector');
37+
}
38+
39+
// Split version into a tuple of numbers (non-numeric segments → 0). Mirrors
40+
// `.version | split(".") | map(tonumber? // 0)`.
41+
function versionKey(version) {
42+
return String(version)
43+
.split('.')
44+
.map((seg) => {
45+
// jq's `tonumber?` returns null on failure; `// 0` substitutes 0.
46+
// It accepts leading-sign integers and floats; we approximate with
47+
// Number() but reject NaN and non-finite.
48+
const n = Number(seg);
49+
return Number.isFinite(n) ? n : 0;
50+
});
51+
}
52+
53+
// Compare two arrays element-by-element (jq semantics on arrays of numbers).
54+
// Shorter array sorts before equal-prefix longer one — same as jq's sort_by.
55+
function compareTuples(a, b) {
56+
const len = Math.max(a.length, b.length);
57+
for (let i = 0; i < len; i += 1) {
58+
const ai = i < a.length ? a[i] : 0;
59+
const bi = i < b.length ? b[i] : 0;
60+
if (ai < bi) return -1;
61+
if (ai > bi) return 1;
62+
}
63+
return 0;
64+
}
65+
66+
function groupPref(groupId) {
67+
if (groupId === 'com.mulesoft.connectors') return 0;
68+
if (groupId === 'org.mule.connectors') return 1;
69+
return 2;
70+
}
71+
72+
// Pick the highest version from a list of {version} objects, mirroring
73+
// `(sort_by([.version | split(".") | map(tonumber? // 0)]) | reverse | .[0].version)`.
74+
function highestVersion(group) {
75+
let best = group[0];
76+
let bestKey = versionKey(best.version);
77+
for (let i = 1; i < group.length; i += 1) {
78+
const k = versionKey(group[i].version);
79+
if (compareTuples(k, bestKey) > 0) {
80+
best = group[i];
81+
bestKey = k;
82+
}
83+
}
84+
return best.version;
85+
}
86+
87+
// Token classification: returns 'exact' | 'substring' | 'none'.
88+
// `searchTokens` is an array; `searchSet` is a Set of the same tokens for the
89+
// exact-match check (mirrors jq's `index($t)` on the search-tokens array).
90+
function classifyToken(t, searchTokens, searchSet) {
91+
if (searchSet.has(t)) return 'exact';
92+
if (t.length < 2) return 'none';
93+
for (const s of searchTokens) {
94+
if (s.length < 2) continue;
95+
// jq's `index` on strings returns the index of the first match (or null).
96+
// The original filter uses `index($t) != null or ($t | index($s)) != null`
97+
// which is true when either is a substring of the other.
98+
if (s.includes(t) || t.includes(s)) return 'substring';
99+
}
100+
return 'none';
101+
}
102+
103+
/** @param {Array<object>} pageA Exchange page-A assets. @param {Array<object>} pageB Exchange page-B assets. @param {string} searchTerm Original user term used for token-overlap scoring. @returns {Array<{groupId:string, assetId:string, version:string, score:number, groupPref:number}>} Ranked candidates, best first; mirrors get_latest_connector.sh's jq pipeline. */
104+
export function rankCandidates(pageA, pageB, searchTerm) {
105+
const all = [...(Array.isArray(pageA) ? pageA : []), ...(Array.isArray(pageB) ? pageB : [])];
106+
107+
const searchTokens = tokenize(searchTerm);
108+
const searchSet = new Set(searchTokens);
109+
110+
// Filter: type=="extension" and version not pre-release.
111+
const filtered = all.filter((row) => {
112+
if (!row || typeof row !== 'object') return false;
113+
if (row.type !== 'extension') return false;
114+
const v = row.version;
115+
if (typeof v !== 'string') return false;
116+
return !PRERELEASE_RE.test(v);
117+
});
118+
119+
// Group by [groupId, assetId].
120+
const groups = new Map();
121+
for (const row of filtered) {
122+
const key = `${row.groupId} ${row.assetId}`;
123+
let bucket = groups.get(key);
124+
if (!bucket) {
125+
bucket = [];
126+
groups.set(key, bucket);
127+
}
128+
bucket.push(row);
129+
}
130+
131+
// Build candidate per group, scored.
132+
const candidates = [];
133+
for (const bucket of groups.values()) {
134+
const first = bucket[0];
135+
const groupId = first.groupId;
136+
const assetId = first.assetId;
137+
const version = highestVersion(bucket);
138+
const assetTokens = tokenize(assetId);
139+
140+
let exact = 0;
141+
let substr = 0;
142+
let unmatched = 0;
143+
for (const t of assetTokens) {
144+
const kind = classifyToken(t, searchTokens, searchSet);
145+
if (kind === 'exact') exact += 1;
146+
else if (kind === 'substring') substr += 1;
147+
else unmatched += 1;
148+
}
149+
150+
const score = 2 * exact + substr - unmatched;
151+
const pref = groupPref(groupId);
152+
153+
candidates.push({
154+
groupId,
155+
assetId,
156+
version,
157+
score,
158+
groupPref: pref,
159+
});
160+
}
161+
162+
// sort_by([-score, groupPref, (assetId | length)])
163+
candidates.sort((a, b) => {
164+
const sa = -a.score;
165+
const sb = -b.score;
166+
if (sa !== sb) return sa - sb;
167+
if (a.groupPref !== b.groupPref) return a.groupPref - b.groupPref;
168+
return a.assetId.length - b.assetId.length;
169+
});
170+
171+
return candidates;
172+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// fsx.mjs — file-system helpers (mkdirp, readJson, writeJson, isFile, isDir,
2+
// listDir, readJsonOrNull, mktempFile, registerCleanup).
3+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, statSync, readdirSync } from 'node:fs';
4+
import { randomBytes } from 'node:crypto';
5+
import path from 'node:path';
6+
import process from 'node:process';
7+
8+
/** @param {string} dir Recursively-created directory. */
9+
export function mkdirp(dir) {
10+
mkdirSync(dir, { recursive: true });
11+
}
12+
13+
/** @param {string} p File path. @returns {any} Parsed JSON; throws on read/parse error. */
14+
export function readJson(p) {
15+
return JSON.parse(readFileSync(p, 'utf8'));
16+
}
17+
18+
/** @param {string} p Output path. @param {any} value JSON-serializable value written 2-space indented with a trailing newline (matches `jq -n` byte layout). */
19+
export function writeJson(p, value) {
20+
const text = JSON.stringify(value, null, 2) + '\n';
21+
writeFileSync(p, text);
22+
}
23+
24+
/** @param {string} p Path. @returns {boolean} True iff path exists and is a regular file. */
25+
export function isFile(p) {
26+
try { return statSync(p).isFile(); } catch { return false; }
27+
}
28+
29+
/** @param {string} p Path. @returns {boolean} True iff path exists and is a directory. */
30+
export function isDir(p) {
31+
try { return statSync(p).isDirectory(); } catch { return false; }
32+
}
33+
34+
/** @param {string} dir Directory path. @returns {import('node:fs').Dirent[]} Entries; empty array on error. */
35+
export function listDir(dir) {
36+
try { return readdirSync(dir, { withFileTypes: true }); } catch { return []; }
37+
}
38+
39+
/** @param {string} p File path. @returns {any|null} Parsed JSON or null on any read/parse error. */
40+
export function readJsonOrNull(p) {
41+
try { return readJson(p); } catch { return null; }
42+
}
43+
44+
/**
45+
* @param {string} template Path containing literal "XXXXXX"; replaced with 6 random chars.
46+
* @returns {string} Path of a freshly-created empty file (mirrors `mktemp <template>`).
47+
*/
48+
export function mktempFile(template) {
49+
const dir = path.dirname(template);
50+
mkdirp(dir);
51+
const idx = template.lastIndexOf('XXXXXX');
52+
if (idx === -1) throw new Error(`mktempFile: template missing XXXXXX: ${template}`);
53+
for (let i = 0; i < 100; i += 1) {
54+
// CSPRNG-derived suffix (3 random bytes → 6 lowercase hex chars). Matches
55+
// the existing /^[A-Za-z0-9]{6}$/ test contract; replaces Math.random()
56+
// which leaks the V8 PRNG state to a co-tenant on the same host.
57+
const suffix = randomBytes(3).toString('hex');
58+
const candidate = template.slice(0, idx) + suffix + template.slice(idx + 6);
59+
if (!existsSync(candidate)) {
60+
writeFileSync(candidate, '', { flag: 'wx' });
61+
return candidate;
62+
}
63+
}
64+
throw new Error(`mktempFile: could not create unique file for ${template}`);
65+
}
66+
67+
const _cleanupPaths = new Set();
68+
let _cleanupRegistered = false;
69+
/** @param {string} p Path to delete on process exit (also on SIGINT/SIGTERM). */
70+
export function registerCleanup(p) {
71+
_cleanupPaths.add(p);
72+
if (_cleanupRegistered) return;
73+
_cleanupRegistered = true;
74+
const cleanup = () => {
75+
for (const f of _cleanupPaths) {
76+
try { unlinkSync(f); } catch { /* already gone */ }
77+
}
78+
};
79+
process.on('exit', cleanup);
80+
process.on('SIGINT', () => { cleanup(); process.exit(130); });
81+
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
82+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// nearest.mjs — port of skills/build-mule-integration/scripts/_suggest_nearest.py
2+
//
3+
// Used by validate_before_build.mjs (and any other validator that needs a
4+
// "did you mean…" suggestion). Reproduces the Python helper's distance
5+
// function exactly: Hamming-on-overlap + abs length delta.
6+
7+
/** @param {string} token Reference. @param {string} candidate Candidate. @returns {number} Hamming distance over the overlapping prefix plus abs length delta. */
8+
export function distance(token, candidate) {
9+
const overlap = Math.min(token.length, candidate.length);
10+
let mismatches = 0;
11+
for (let i = 0; i < overlap; i += 1) {
12+
if (token[i] !== candidate[i]) mismatches += 1;
13+
}
14+
return mismatches + Math.abs(token.length - candidate.length);
15+
}
16+
17+
/** @param {string} token Reference. @param {string[]} candidates Pool. @returns {string} Closest candidate by `distance()`; "" when pool empty; first wins on ties. */
18+
export function nearest(token, candidates) {
19+
if (!candidates || candidates.length === 0) return '';
20+
let best = candidates[0];
21+
let bestDist = distance(token, best);
22+
for (let i = 1; i < candidates.length; i += 1) {
23+
const d = distance(token, candidates[i]);
24+
if (d < bestDist) {
25+
best = candidates[i];
26+
bestDist = d;
27+
}
28+
}
29+
return best;
30+
}
31+
32+
/** @param {string} nsid An `NS:ID` token. @returns {string} The substring before the first ":", or "" when no colon present. */
33+
export function nsOf(nsid) {
34+
const idx = nsid.indexOf(':');
35+
return idx === -1 ? '' : nsid.slice(0, idx);
36+
}
37+
38+
/**
39+
* Find the nearest allowlist entry sharing `miss`'s namespace.
40+
*
41+
* @param {string} miss The unrecognised `NS:ID` token.
42+
* @param {string[]} allowlist Full allowlist (any namespace).
43+
* @returns {string} Nearest same-namespace candidate, or "" when the
44+
* namespace pool is empty. **No-colon fallback:** when `miss` contains no
45+
* `:`, `nsOf(miss)` is `""` and the helper widens to the entire allowlist
46+
* (no namespace filter) — matches the Python original's behaviour.
47+
*/
48+
export function suggestForMiss(miss, allowlist) {
49+
const ns = nsOf(miss);
50+
const pool = ns
51+
? allowlist.filter((c) => nsOf(c) === ns)
52+
: allowlist.slice();
53+
return nearest(miss, pool);
54+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// platform.mjs — platform helpers (parseJavaVersion, semver-like sort).
2+
3+
/** @param {string} stderrOrStdout `java -version` output (stderr typically). @returns {{raw:string, major:number|null}} Parsed version; handles legacy "1.8.0_321"→8 and modern "11.0.21"→11. */
4+
export function parseJavaVersion(stderrOrStdout) {
5+
if (!stderrOrStdout) return { raw: '', major: null };
6+
const firstLine = String(stderrOrStdout).split(/\r?\n/, 1)[0] || '';
7+
// Match the first quoted token, e.g. java version "11.0.21" or openjdk version "1.8.0_321"
8+
const m = firstLine.match(/"([^"]+)"/);
9+
const raw = m ? m[1] : '';
10+
if (!raw) return { raw: '', major: null };
11+
const parts = raw.split('.');
12+
let major;
13+
if (parts[0] === '1' && parts.length > 1) {
14+
major = parseInt(parts[1], 10);
15+
} else {
16+
major = parseInt(parts[0], 10);
17+
}
18+
return { raw, major: Number.isFinite(major) ? major : null };
19+
}
20+
21+
/** @param {string[]} arr Strings like "mule-4.5.0". @returns {string[]} New array sorted by `sort -V` semantics (numeric runs compared numerically). */
22+
export function sortVersionStrings(arr) {
23+
const tokenize = (s) => String(s).split(/(\d+)/).map((t) => /^\d+$/.test(t) ? parseInt(t, 10) : t);
24+
return [...arr].sort((a, b) => {
25+
const ta = tokenize(a);
26+
const tb = tokenize(b);
27+
const len = Math.max(ta.length, tb.length);
28+
for (let i = 0; i < len; i += 1) {
29+
const x = ta[i];
30+
const y = tb[i];
31+
if (x === undefined) return -1;
32+
if (y === undefined) return 1;
33+
if (typeof x === 'number' && typeof y === 'number') {
34+
if (x !== y) return x - y;
35+
} else {
36+
const sx = String(x);
37+
const sy = String(y);
38+
if (sx !== sy) return sx < sy ? -1 : 1;
39+
}
40+
}
41+
return 0;
42+
});
43+
}

0 commit comments

Comments
 (0)