rulesync 13.0.0 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli/index.cjs +913 -24
- package/dist/cli/index.js +913 -24
- package/dist/cli/index.js.map +1 -1
- package/dist/{import-CMSHVMqU.js → import-CSnC5vl7.js} +194 -14
- package/dist/import-CSnC5vl7.js.map +1 -0
- package/dist/{import-nbmxtKYi.cjs → import-Dh9PCQKq.cjs} +212 -26
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/import-CMSHVMqU.js.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as isSymlink, A as CLAUDECODE_SETTINGS_LOCAL_FILE_NAME, At as
|
|
2
|
+
import { $ as isSymlink, A as CLAUDECODE_SETTINGS_LOCAL_FILE_NAME, At as RULESYNC_PERMISSIONS_JSONC_FILE_NAME, B as findControlCharacter, Bt as ALL_FEATURES_WITH_WILDCARD, C as CommandsProcessor, Ct as RULESYNC_MCP_FILE_NAME, D as CLAUDECODE_DIR, Dt as RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH, E as CODEXCLI_DIR, Et as RULESYNC_MCP_SCHEMA_URL, F as RulesyncCheck, Ft as RULESYNC_SKILLS_RELATIVE_DIR_PATH, G as checkPathTraversal, H as JsonLogger, I as RulesyncCheckFrontmatterSchema, It as RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH, J as ensureDir, K as createTempDirectory, L as stringifyFrontmatter, Lt as RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, M as RulesyncCommand, Mt as RULESYNC_PERMISSIONS_SCHEMA_URL, N as RulesyncCommandFrontmatterSchema, Nt as RULESYNC_RELATIVE_DIR_PATH, O as CLAUDECODE_LOCAL_RULE_FILE_NAME, Ot as RULESYNC_OVERVIEW_FILE_NAME, P as ChecksProcessor, Pt as RULESYNC_RULES_RELATIVE_DIR_PATH, Q as getHomeDirectory, R as loadYaml, Rt as formatError, S as RulesyncHooks, St as RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH, T as CODEXCLI_BASH_RULES_FILE_NAME, Tt as RULESYNC_MCP_RELATIVE_FILE_PATH, U as CLIError, V as ConsoleLogger, W as ErrorCodes, X as findFilesByGlobs, Y as fileExists, Z as getFileSize, _ as McpProcessor, _t as RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH, a as convertFromTool, at as toPosixPath, b as RulesyncIgnore, bt as RULESYNC_HOOKS_RELATIVE_FILE_PATH, c as RulesyncRuleFrontmatterSchema, ct as ALL_TOOL_TARGETS_WITH_WILDCARD, d as RulesyncSubagentFrontmatterSchema, dt as RULESYNC_AIIGNORE_FILE_NAME, et as listDirectoryFiles, f as SkillsProcessor, ft as RULESYNC_AIIGNORE_RELATIVE_FILE_PATH, g as RulesyncPermissions, gt as RULESYNC_CONFIG_SCHEMA_URL, h as RulesyncSkillFrontmatterSchema, ht as RULESYNC_CONFIG_RELATIVE_FILE_PATH, i as getProcessorRegistryEntry, it as removeTempDirectory, j as CLAUDECODE_SKILLS_DIR_PATH, jt as RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH, k as CLAUDECODE_MEMORIES_DIR_NAME, kt as RULESYNC_PERMISSIONS_FILE_NAME, l as SubagentsProcessor, lt as ToolTargetSchema, m as RulesyncSkill, mt as RULESYNC_COMMANDS_RELATIVE_DIR_PATH, n as checkRulesyncDirExists, nt as removeDirectory, o as RulesProcessor, ot as writeFileContent, p as getLocalSkillDirNames, pt as RULESYNC_CHECKS_RELATIVE_DIR_PATH, q as directoryExists, r as generate, rt as removeFile, s as RulesyncRule, st as ALL_TOOL_TARGETS, t as importFromTool, tt as readFileContent, u as RulesyncSubagent, ut as MAX_FILE_SIZE, v as RulesyncMcp, vt as RULESYNC_HOOKS_FILE_NAME, w as SKILL_FILE_NAME$1, wt as RULESYNC_MCP_JSONC_FILE_NAME, x as HooksProcessor, xt as RULESYNC_IGNORE_RELATIVE_FILE_PATH, y as IgnoreProcessor, yt as RULESYNC_HOOKS_JSONC_FILE_NAME, z as ConfigResolver, zt as ALL_FEATURES } from "../import-CSnC5vl7.js";
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { nonnegative, optional, refine, z } from "zod/mini";
|
|
5
5
|
import * as path$1 from "node:path";
|
|
@@ -13,6 +13,7 @@ import { Semaphore } from "es-toolkit/promise";
|
|
|
13
13
|
import { RequestError } from "@octokit/request-error";
|
|
14
14
|
import { Octokit } from "@octokit/rest";
|
|
15
15
|
import { execFile } from "node:child_process";
|
|
16
|
+
import { gunzipSync } from "node:zlib";
|
|
16
17
|
import { FastMCP } from "fastmcp";
|
|
17
18
|
import * as fs from "node:fs";
|
|
18
19
|
import { Readable, Transform } from "node:stream";
|
|
@@ -1240,6 +1241,7 @@ const DERIVED_PATHS_NOT_GITIGNORED = /* @__PURE__ */ new Set([
|
|
|
1240
1241
|
"**/.grok/config.toml",
|
|
1241
1242
|
"**/.vibe/config.toml",
|
|
1242
1243
|
"**/reasonix.toml",
|
|
1244
|
+
"**/.vscode/settings.json",
|
|
1243
1245
|
"**/.zed/settings.json",
|
|
1244
1246
|
"**/kilo.json",
|
|
1245
1247
|
"**/kilo.jsonc",
|
|
@@ -3546,6 +3548,516 @@ async function walkDirectory(dir, outputRoot, depth = 0, ctx = {
|
|
|
3546
3548
|
}
|
|
3547
3549
|
return results;
|
|
3548
3550
|
}
|
|
3551
|
+
const DEFAULT_NPM_TOKEN_ENV = "NPM_TOKEN";
|
|
3552
|
+
/** Abbreviated packument media type (install metadata only). */
|
|
3553
|
+
const PACKUMENT_ACCEPT_HEADER = "application/vnd.npm.install-v1+json";
|
|
3554
|
+
/** Timeout for registry HTTP requests (60 seconds). */
|
|
3555
|
+
const NPM_FETCH_TIMEOUT_MS = 6e4;
|
|
3556
|
+
/** Maximum accepted tarball size (compressed), aligned with the extraction cap. */
|
|
3557
|
+
const MAX_TARBALL_SIZE = 100 * 1024 * 1024;
|
|
3558
|
+
/**
|
|
3559
|
+
* npm package name rules (scoped or unscoped, URL-safe characters only).
|
|
3560
|
+
* This also guards against URL path injection into registry requests.
|
|
3561
|
+
*/
|
|
3562
|
+
const NPM_PACKAGE_NAME_REGEX = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
|
|
3563
|
+
const MAX_NPM_PACKAGE_NAME_LENGTH = 214;
|
|
3564
|
+
const INTEGRITY_ALGORITHM_PREFERENCE = [
|
|
3565
|
+
"sha512",
|
|
3566
|
+
"sha384",
|
|
3567
|
+
"sha256",
|
|
3568
|
+
"sha1"
|
|
3569
|
+
];
|
|
3570
|
+
var NpmClientError = class extends Error {
|
|
3571
|
+
statusCode;
|
|
3572
|
+
constructor(message, options) {
|
|
3573
|
+
super(message, { cause: options?.cause });
|
|
3574
|
+
this.name = "NpmClientError";
|
|
3575
|
+
this.statusCode = options?.statusCode;
|
|
3576
|
+
}
|
|
3577
|
+
};
|
|
3578
|
+
function validateNpmPackageName(name) {
|
|
3579
|
+
if (name.length > MAX_NPM_PACKAGE_NAME_LENGTH || !NPM_PACKAGE_NAME_REGEX.test(name)) throw new NpmClientError(`Invalid npm package name: "${name}". Expected "name" or "@scope/name".`);
|
|
3580
|
+
}
|
|
3581
|
+
function validateNpmRegistryUrl(url, options) {
|
|
3582
|
+
const ctrl = findControlCharacter(url);
|
|
3583
|
+
if (ctrl) throw new NpmClientError(`Registry URL contains control character ${ctrl.hex} at position ${ctrl.position}`);
|
|
3584
|
+
if (!url.startsWith("https://") && !url.startsWith("http://")) throw new NpmClientError(`Unsupported registry URL: "${url}". Use https:// (or http://).`);
|
|
3585
|
+
if (url.startsWith("http://")) options?.logger?.warn(`Registry URL "${url}" uses an unencrypted protocol. Consider using https:// instead.`);
|
|
3586
|
+
}
|
|
3587
|
+
/**
|
|
3588
|
+
* Resolve the registry token from the environment. When `tokenEnv` is set it
|
|
3589
|
+
* must name an existing environment variable; otherwise `NPM_TOKEN` is used
|
|
3590
|
+
* when present. The token value itself is never logged.
|
|
3591
|
+
*/
|
|
3592
|
+
function resolveNpmToken(params) {
|
|
3593
|
+
const { tokenEnv } = params;
|
|
3594
|
+
if (tokenEnv !== void 0) {
|
|
3595
|
+
const value = process.env[tokenEnv];
|
|
3596
|
+
if (value === void 0 || value === "") throw new NpmClientError(`Environment variable "${tokenEnv}" (from tokenEnv) is not set. Export it or remove the tokenEnv field.`);
|
|
3597
|
+
return value;
|
|
3598
|
+
}
|
|
3599
|
+
const fallback = process.env[DEFAULT_NPM_TOKEN_ENV];
|
|
3600
|
+
return fallback === void 0 || fallback === "" ? void 0 : fallback;
|
|
3601
|
+
}
|
|
3602
|
+
/** Build the packument URL for a (possibly scoped) package on a registry. */
|
|
3603
|
+
function buildPackumentUrl(params) {
|
|
3604
|
+
const { registryUrl, packageName } = params;
|
|
3605
|
+
validateNpmPackageName(packageName);
|
|
3606
|
+
const base = registryUrl.endsWith("/") ? registryUrl : `${registryUrl}/`;
|
|
3607
|
+
const encodedName = packageName.replaceAll("/", "%2F");
|
|
3608
|
+
return new URL(encodedName, base).toString();
|
|
3609
|
+
}
|
|
3610
|
+
async function fetchWithTimeout(url, headers) {
|
|
3611
|
+
try {
|
|
3612
|
+
return await fetch(url, {
|
|
3613
|
+
headers,
|
|
3614
|
+
redirect: "follow",
|
|
3615
|
+
signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS)
|
|
3616
|
+
});
|
|
3617
|
+
} catch (error) {
|
|
3618
|
+
throw new NpmClientError(`Network error while requesting ${url}`, { cause: error });
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
/**
|
|
3622
|
+
* Fetch the (abbreviated) packument for a package from a registry.
|
|
3623
|
+
*/
|
|
3624
|
+
async function fetchPackument(params) {
|
|
3625
|
+
const { registryUrl, packageName, token } = params;
|
|
3626
|
+
validateNpmPackageName(packageName);
|
|
3627
|
+
const url = buildPackumentUrl({
|
|
3628
|
+
registryUrl,
|
|
3629
|
+
packageName
|
|
3630
|
+
});
|
|
3631
|
+
const headers = { Accept: PACKUMENT_ACCEPT_HEADER };
|
|
3632
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
3633
|
+
const response = await fetchWithTimeout(url, headers);
|
|
3634
|
+
if (!response.ok) throw new NpmClientError(`Failed to fetch package metadata for "${packageName}" from ${registryUrl}: HTTP ${response.status}`, { statusCode: response.status });
|
|
3635
|
+
try {
|
|
3636
|
+
return await response.json();
|
|
3637
|
+
} catch (error) {
|
|
3638
|
+
throw new NpmClientError(`Failed to parse package metadata for "${packageName}" from ${registryUrl}`, { cause: error });
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
/**
|
|
3642
|
+
* Resolve a requested version or dist-tag against a packument. Only exact
|
|
3643
|
+
* versions and dist-tags are supported — semver ranges are intentionally out
|
|
3644
|
+
* of scope (no semver dependency).
|
|
3645
|
+
*/
|
|
3646
|
+
function resolvePackumentVersion(params) {
|
|
3647
|
+
const { packument, packageName, requested } = params;
|
|
3648
|
+
const versions = packument.versions ?? {};
|
|
3649
|
+
if (Object.prototype.hasOwnProperty.call(versions, requested)) return requested;
|
|
3650
|
+
const distTags = packument["dist-tags"] ?? {};
|
|
3651
|
+
const tagged = Object.prototype.hasOwnProperty.call(distTags, requested) ? distTags[requested] : void 0;
|
|
3652
|
+
if (tagged !== void 0 && Object.prototype.hasOwnProperty.call(versions, tagged)) return tagged;
|
|
3653
|
+
throw new NpmClientError(`Could not resolve "${packageName}@${requested}": not an exact published version or dist-tag. Note: semver ranges are not supported by the npm transport.`);
|
|
3654
|
+
}
|
|
3655
|
+
/** Get the dist metadata (tarball URL, integrity) for a resolved version. */
|
|
3656
|
+
function getPackumentVersionDist(params) {
|
|
3657
|
+
const { packument, packageName, version } = params;
|
|
3658
|
+
const versions = packument.versions ?? {};
|
|
3659
|
+
const dist = (Object.prototype.hasOwnProperty.call(versions, version) ? versions[version] : void 0)?.dist;
|
|
3660
|
+
if (!dist?.tarball) throw new NpmClientError(`Registry metadata for "${packageName}@${version}" is missing the dist.tarball URL.`);
|
|
3661
|
+
return dist;
|
|
3662
|
+
}
|
|
3663
|
+
/**
|
|
3664
|
+
* Download a package tarball. The Authorization header is only attached when
|
|
3665
|
+
* the tarball is hosted on the same origin (scheme + host) as the registry,
|
|
3666
|
+
* so the token never leaks to third-party CDNs or plaintext downgrades.
|
|
3667
|
+
*/
|
|
3668
|
+
async function fetchTarball(params) {
|
|
3669
|
+
const { tarballUrl, registryUrl, token } = params;
|
|
3670
|
+
const maxSize = params.maxSize ?? MAX_TARBALL_SIZE;
|
|
3671
|
+
if (!tarballUrl.startsWith("https://") && !tarballUrl.startsWith("http://")) throw new NpmClientError(`Unsupported tarball URL: "${tarballUrl}". Use https:// (or http://).`);
|
|
3672
|
+
const headers = {};
|
|
3673
|
+
if (token && isSameOrigin(tarballUrl, registryUrl)) headers.Authorization = `Bearer ${token}`;
|
|
3674
|
+
const response = await fetchWithTimeout(tarballUrl, headers);
|
|
3675
|
+
if (!response.ok) throw new NpmClientError(`Failed to download tarball ${tarballUrl}: HTTP ${response.status}`, { statusCode: response.status });
|
|
3676
|
+
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
|
3677
|
+
if (Number.isFinite(contentLength) && contentLength > maxSize) throw new NpmClientError(oversizedTarballMessage(tarballUrl, maxSize));
|
|
3678
|
+
return await readBodyWithLimit({
|
|
3679
|
+
response,
|
|
3680
|
+
tarballUrl,
|
|
3681
|
+
maxSize
|
|
3682
|
+
});
|
|
3683
|
+
}
|
|
3684
|
+
function oversizedTarballMessage(tarballUrl, maxSize) {
|
|
3685
|
+
return `Tarball ${tarballUrl} exceeds max size of ${maxSize / 1024 / 1024}MB.`;
|
|
3686
|
+
}
|
|
3687
|
+
/**
|
|
3688
|
+
* Read a response body incrementally, aborting as soon as the size cap is
|
|
3689
|
+
* exceeded. content-length can be absent or forged, so the streaming check is
|
|
3690
|
+
* the actual enforcement of the cap.
|
|
3691
|
+
*/
|
|
3692
|
+
async function readBodyWithLimit(params) {
|
|
3693
|
+
const { response, tarballUrl, maxSize } = params;
|
|
3694
|
+
const reader = response.body?.getReader();
|
|
3695
|
+
if (!reader) {
|
|
3696
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
3697
|
+
if (arrayBuffer.byteLength > maxSize) throw new NpmClientError(oversizedTarballMessage(tarballUrl, maxSize));
|
|
3698
|
+
return Buffer.from(arrayBuffer);
|
|
3699
|
+
}
|
|
3700
|
+
const chunks = [];
|
|
3701
|
+
let totalBytes = 0;
|
|
3702
|
+
while (true) {
|
|
3703
|
+
const { done, value } = await reader.read();
|
|
3704
|
+
if (done) break;
|
|
3705
|
+
totalBytes += value.byteLength;
|
|
3706
|
+
if (totalBytes > maxSize) {
|
|
3707
|
+
await reader.cancel();
|
|
3708
|
+
throw new NpmClientError(oversizedTarballMessage(tarballUrl, maxSize));
|
|
3709
|
+
}
|
|
3710
|
+
chunks.push(Buffer.from(value));
|
|
3711
|
+
}
|
|
3712
|
+
return Buffer.concat(chunks);
|
|
3713
|
+
}
|
|
3714
|
+
function isSameOrigin(urlA, urlB) {
|
|
3715
|
+
try {
|
|
3716
|
+
return new URL(urlA).origin === new URL(urlB).origin;
|
|
3717
|
+
} catch {
|
|
3718
|
+
return false;
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* Convert a hex sha1 shasum to the SRI form used by `verifyTarballIntegrity`.
|
|
3723
|
+
* Rejects malformed shasum values so a broken value is never recorded in the
|
|
3724
|
+
* lockfile as a seemingly valid SRI string.
|
|
3725
|
+
*/
|
|
3726
|
+
function shasumToSri(shasum) {
|
|
3727
|
+
if (!/^[0-9a-f]{40}$/i.test(shasum)) throw new NpmClientError(`Malformed sha1 shasum in registry metadata: "${shasum}"`);
|
|
3728
|
+
return `sha1-${Buffer.from(shasum, "hex").toString("base64")}`;
|
|
3729
|
+
}
|
|
3730
|
+
/**
|
|
3731
|
+
* Verify a downloaded tarball against registry integrity metadata.
|
|
3732
|
+
* Prefers the strongest supported algorithm in the SRI `integrity` string and
|
|
3733
|
+
* falls back to the legacy sha1 `shasum`. An `integrity` string that is
|
|
3734
|
+
* present but cannot be parsed fails closed; a warning is only logged when
|
|
3735
|
+
* the registry provides no integrity metadata at all.
|
|
3736
|
+
*/
|
|
3737
|
+
function verifyTarballIntegrity(params) {
|
|
3738
|
+
const { tarball, integrity, shasum, context, logger } = params;
|
|
3739
|
+
if (integrity !== void 0) {
|
|
3740
|
+
const sri = pickStrongestSriEntry(integrity);
|
|
3741
|
+
if (!sri) throw new NpmClientError(`Unsupported or malformed integrity metadata for ${context}. Expected an SRI string with sha512/sha384/sha256/sha1.`);
|
|
3742
|
+
const actual = createHash(sri.algorithm).update(tarball).digest("base64");
|
|
3743
|
+
if (actual !== sri.digest) throw new NpmClientError(`Integrity verification failed for ${context}: expected ${sri.algorithm}-${sri.digest}, got ${sri.algorithm}-${actual}. The tarball may have been tampered with.`);
|
|
3744
|
+
return;
|
|
3745
|
+
}
|
|
3746
|
+
if (shasum) {
|
|
3747
|
+
const actual = createHash("sha1").update(tarball).digest("hex");
|
|
3748
|
+
if (actual !== shasum.toLowerCase()) throw new NpmClientError(`Integrity verification failed for ${context}: expected sha1 ${shasum}, got ${actual}. The tarball may have been tampered with.`);
|
|
3749
|
+
return;
|
|
3750
|
+
}
|
|
3751
|
+
logger?.warn(`No integrity metadata available for ${context}; skipping tarball verification.`);
|
|
3752
|
+
}
|
|
3753
|
+
function pickStrongestSriEntry(integrity) {
|
|
3754
|
+
if (!integrity) return;
|
|
3755
|
+
const entries = integrity.split(/\s+/).map((entry) => {
|
|
3756
|
+
const separatorIndex = entry.indexOf("-");
|
|
3757
|
+
if (separatorIndex === -1) return void 0;
|
|
3758
|
+
const algorithm = entry.slice(0, separatorIndex);
|
|
3759
|
+
const digest = entry.slice(separatorIndex + 1).split("?")[0] ?? "";
|
|
3760
|
+
const known = INTEGRITY_ALGORITHM_PREFERENCE.find((a) => a === algorithm);
|
|
3761
|
+
if (!known || digest.length === 0) return void 0;
|
|
3762
|
+
return {
|
|
3763
|
+
algorithm: known,
|
|
3764
|
+
digest
|
|
3765
|
+
};
|
|
3766
|
+
}).filter((entry) => Boolean(entry));
|
|
3767
|
+
for (const algorithm of INTEGRITY_ALGORITHM_PREFERENCE) {
|
|
3768
|
+
const match = entries.find((entry) => entry.algorithm === algorithm);
|
|
3769
|
+
if (match) return match;
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
/**
|
|
3773
|
+
* Log contextual hints for NpmClientError to help users troubleshoot
|
|
3774
|
+
* authentication problems without ever logging the token itself.
|
|
3775
|
+
*/
|
|
3776
|
+
function logNpmAuthHints(params) {
|
|
3777
|
+
const { error, logger } = params;
|
|
3778
|
+
if (error.statusCode === 401 || error.statusCode === 403) logger.info("Hint: The registry rejected the request. Set NPM_TOKEN (or the per-source tokenEnv variable) to a token with read access. Note: .npmrc files are not read by the npm transport.");
|
|
3779
|
+
else if (error.statusCode === 404) logger.info("Hint: Package not found. Check the package name and the registry URL. Some registries also return 404 for unauthorized requests.");
|
|
3780
|
+
}
|
|
3781
|
+
const NpmLockedSkillSchema = z.object({ integrity: z.string() });
|
|
3782
|
+
/**
|
|
3783
|
+
* Schema for a single locked npm source entry.
|
|
3784
|
+
*/
|
|
3785
|
+
const NpmLockedSourceSchema = z.object({
|
|
3786
|
+
registry: optional(z.string()),
|
|
3787
|
+
requestedVersion: optional(z.string()),
|
|
3788
|
+
resolvedVersion: z.string(),
|
|
3789
|
+
/** SRI integrity of the package tarball as reported by the registry. */
|
|
3790
|
+
integrity: optional(z.string()),
|
|
3791
|
+
resolvedAt: optional(z.string()),
|
|
3792
|
+
skills: z.record(z.string(), NpmLockedSkillSchema)
|
|
3793
|
+
});
|
|
3794
|
+
const NpmSourcesLockSchema = z.object({
|
|
3795
|
+
lockfileVersion: z.number(),
|
|
3796
|
+
sources: z.record(z.string(), NpmLockedSourceSchema)
|
|
3797
|
+
});
|
|
3798
|
+
/**
|
|
3799
|
+
* Create an empty npm lockfile structure.
|
|
3800
|
+
*/
|
|
3801
|
+
function createEmptyNpmLock() {
|
|
3802
|
+
return {
|
|
3803
|
+
lockfileVersion: 1,
|
|
3804
|
+
sources: {}
|
|
3805
|
+
};
|
|
3806
|
+
}
|
|
3807
|
+
/**
|
|
3808
|
+
* Read the npm lockfile from disk.
|
|
3809
|
+
* @returns The parsed lockfile, or an empty lockfile if it doesn't exist or is invalid.
|
|
3810
|
+
*/
|
|
3811
|
+
async function readNpmLockFile(params) {
|
|
3812
|
+
const { logger } = params;
|
|
3813
|
+
const lockPath = join(params.projectRoot, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH);
|
|
3814
|
+
if (!await fileExists(lockPath)) {
|
|
3815
|
+
logger.debug("No npm sources lockfile found, starting fresh.");
|
|
3816
|
+
return createEmptyNpmLock();
|
|
3817
|
+
}
|
|
3818
|
+
try {
|
|
3819
|
+
const content = await readFileContent(lockPath);
|
|
3820
|
+
const result = NpmSourcesLockSchema.safeParse(JSON.parse(content));
|
|
3821
|
+
if (result.success) return result.data;
|
|
3822
|
+
logger.warn(`Invalid npm sources lockfile format (${RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH}). Starting fresh.`);
|
|
3823
|
+
return createEmptyNpmLock();
|
|
3824
|
+
} catch {
|
|
3825
|
+
logger.warn(`Failed to read npm sources lockfile (${RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH}). Starting fresh.`);
|
|
3826
|
+
return createEmptyNpmLock();
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
/**
|
|
3830
|
+
* Write the npm lockfile to disk.
|
|
3831
|
+
*/
|
|
3832
|
+
async function writeNpmLockFile(params) {
|
|
3833
|
+
const { logger } = params;
|
|
3834
|
+
const lockPath = join(params.projectRoot, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH);
|
|
3835
|
+
await writeFileContent(lockPath, JSON.stringify(params.lock, null, 2) + "\n");
|
|
3836
|
+
logger.debug(`Wrote npm sources lockfile to ${lockPath}`);
|
|
3837
|
+
}
|
|
3838
|
+
/**
|
|
3839
|
+
* Normalize an npm source key (package name) for lockfile lookups.
|
|
3840
|
+
*/
|
|
3841
|
+
function normalizeNpmSourceKey(source) {
|
|
3842
|
+
return source.trim();
|
|
3843
|
+
}
|
|
3844
|
+
/**
|
|
3845
|
+
* Get the locked entry for an npm source key, if it exists.
|
|
3846
|
+
*/
|
|
3847
|
+
function getNpmLockedSource(lock, sourceKey) {
|
|
3848
|
+
const normalized = normalizeNpmSourceKey(sourceKey);
|
|
3849
|
+
return Object.prototype.hasOwnProperty.call(lock.sources, normalized) ? lock.sources[normalized] : void 0;
|
|
3850
|
+
}
|
|
3851
|
+
/**
|
|
3852
|
+
* Set (or update) a locked entry for an npm source key (immutable).
|
|
3853
|
+
*/
|
|
3854
|
+
function setNpmLockedSource(lock, sourceKey, entry) {
|
|
3855
|
+
return {
|
|
3856
|
+
lockfileVersion: lock.lockfileVersion,
|
|
3857
|
+
sources: {
|
|
3858
|
+
...lock.sources,
|
|
3859
|
+
[normalizeNpmSourceKey(sourceKey)]: entry
|
|
3860
|
+
}
|
|
3861
|
+
};
|
|
3862
|
+
}
|
|
3863
|
+
/**
|
|
3864
|
+
* Get the skill names from a locked npm source entry.
|
|
3865
|
+
*/
|
|
3866
|
+
function getNpmLockedSkillNames(entry) {
|
|
3867
|
+
return Object.keys(entry.skills);
|
|
3868
|
+
}
|
|
3869
|
+
//#endregion
|
|
3870
|
+
//#region src/lib/npm-tar.ts
|
|
3871
|
+
/**
|
|
3872
|
+
* Minimal, hardened tar reader for npm package tarballs (EXPERIMENTAL npm
|
|
3873
|
+
* transport). Intentionally supports only the subset of the (pax-extended)
|
|
3874
|
+
* ustar format that npm-compatible registries produce:
|
|
3875
|
+
*
|
|
3876
|
+
* - regular files (typeflag "0" / "\0")
|
|
3877
|
+
* - directories (typeflag "5") — skipped; directories are created implicitly
|
|
3878
|
+
* - pax extended headers (typeflag "x") — only the `path` override is honored
|
|
3879
|
+
* - GNU long names (typeflag "L")
|
|
3880
|
+
*
|
|
3881
|
+
* Everything else (symlinks, hardlinks, devices, FIFOs, ...) is skipped and
|
|
3882
|
+
* never materialized. Extraction is bounded by file-count and total-byte caps
|
|
3883
|
+
* to prevent decompression bombs, and every entry path is validated against
|
|
3884
|
+
* traversal (absolute paths, `..` segments, backslashes).
|
|
3885
|
+
*/
|
|
3886
|
+
const BLOCK_SIZE = 512;
|
|
3887
|
+
var NpmTarError = class extends Error {
|
|
3888
|
+
constructor(message, cause) {
|
|
3889
|
+
super(message, { cause });
|
|
3890
|
+
this.name = "NpmTarError";
|
|
3891
|
+
}
|
|
3892
|
+
};
|
|
3893
|
+
/**
|
|
3894
|
+
* Gunzip and extract a npm package tarball into an in-memory file list.
|
|
3895
|
+
* Throws {@link NpmTarError} on malformed archives, traversal attempts, or
|
|
3896
|
+
* resource-limit violations.
|
|
3897
|
+
*/
|
|
3898
|
+
function extractPackageTarball(params) {
|
|
3899
|
+
const { tarball, onSkippedEntry } = params;
|
|
3900
|
+
const maxFiles = params.maxFiles ?? 1e4;
|
|
3901
|
+
const maxTotalBytes = params.maxTotalBytes ?? 104857600;
|
|
3902
|
+
let tar;
|
|
3903
|
+
try {
|
|
3904
|
+
tar = gunzipSync(tarball, { maxOutputLength: maxTotalBytes + maxFiles * 3 * BLOCK_SIZE + 2 * BLOCK_SIZE });
|
|
3905
|
+
} catch (error) {
|
|
3906
|
+
throw new NpmTarError("Failed to gunzip package tarball", error);
|
|
3907
|
+
}
|
|
3908
|
+
return parseTarBuffer({
|
|
3909
|
+
tar,
|
|
3910
|
+
maxFiles,
|
|
3911
|
+
maxTotalBytes,
|
|
3912
|
+
onSkippedEntry
|
|
3913
|
+
});
|
|
3914
|
+
}
|
|
3915
|
+
function parseTarBuffer(params) {
|
|
3916
|
+
const { tar, maxFiles, maxTotalBytes, onSkippedEntry } = params;
|
|
3917
|
+
const files = [];
|
|
3918
|
+
let totalBytes = 0;
|
|
3919
|
+
let offset = 0;
|
|
3920
|
+
let pendingLongName;
|
|
3921
|
+
let pendingPaxPath;
|
|
3922
|
+
while (offset + BLOCK_SIZE <= tar.length) {
|
|
3923
|
+
const header = tar.subarray(offset, offset + BLOCK_SIZE);
|
|
3924
|
+
if (isZeroBlock(header)) break;
|
|
3925
|
+
verifyHeaderChecksum(header);
|
|
3926
|
+
const size = parseOctalField(header, 124, 12, "size");
|
|
3927
|
+
const typeflag = String.fromCharCode(header[156] ?? 0);
|
|
3928
|
+
const dataStart = offset + BLOCK_SIZE;
|
|
3929
|
+
const dataEnd = dataStart + size;
|
|
3930
|
+
if (dataEnd > tar.length) throw new NpmTarError("Truncated tar archive: entry data extends past end of archive");
|
|
3931
|
+
switch (typeflag) {
|
|
3932
|
+
case "x": {
|
|
3933
|
+
const records = parsePaxRecords(tar.subarray(dataStart, dataEnd));
|
|
3934
|
+
if (records.has("size")) throw new NpmTarError("Unsupported tar archive: pax size override is not supported");
|
|
3935
|
+
pendingPaxPath = records.get("path") ?? pendingPaxPath;
|
|
3936
|
+
break;
|
|
3937
|
+
}
|
|
3938
|
+
case "L":
|
|
3939
|
+
pendingLongName = trimAtFirstNul(tar.toString("utf8", dataStart, dataEnd));
|
|
3940
|
+
break;
|
|
3941
|
+
case "g": {
|
|
3942
|
+
const records = parsePaxRecords(tar.subarray(dataStart, dataEnd));
|
|
3943
|
+
if (records.has("size") || records.has("path")) throw new NpmTarError("Unsupported tar archive: global pax size/path overrides are not supported");
|
|
3944
|
+
break;
|
|
3945
|
+
}
|
|
3946
|
+
case "0":
|
|
3947
|
+
case "\0": {
|
|
3948
|
+
const rawName = resolveEntryName({
|
|
3949
|
+
header,
|
|
3950
|
+
pendingLongName,
|
|
3951
|
+
pendingPaxPath
|
|
3952
|
+
});
|
|
3953
|
+
pendingLongName = void 0;
|
|
3954
|
+
pendingPaxPath = void 0;
|
|
3955
|
+
const relativePath = toSafeRelativePath(rawName);
|
|
3956
|
+
if (relativePath !== null) {
|
|
3957
|
+
if (files.length + 1 > maxFiles) throw new NpmTarError(`Package tarball exceeds max file count of ${maxFiles}. Aborting to prevent resource exhaustion.`);
|
|
3958
|
+
totalBytes += size;
|
|
3959
|
+
if (totalBytes > maxTotalBytes) throw new NpmTarError(`Package tarball exceeds max total size of ${maxTotalBytes / 1024 / 1024}MB. Aborting to prevent resource exhaustion.`);
|
|
3960
|
+
files.push({
|
|
3961
|
+
relativePath,
|
|
3962
|
+
content: Buffer.from(tar.subarray(dataStart, dataEnd))
|
|
3963
|
+
});
|
|
3964
|
+
}
|
|
3965
|
+
break;
|
|
3966
|
+
}
|
|
3967
|
+
case "5":
|
|
3968
|
+
pendingLongName = void 0;
|
|
3969
|
+
pendingPaxPath = void 0;
|
|
3970
|
+
break;
|
|
3971
|
+
default: {
|
|
3972
|
+
const rawName = resolveEntryName({
|
|
3973
|
+
header,
|
|
3974
|
+
pendingLongName,
|
|
3975
|
+
pendingPaxPath
|
|
3976
|
+
});
|
|
3977
|
+
pendingLongName = void 0;
|
|
3978
|
+
pendingPaxPath = void 0;
|
|
3979
|
+
onSkippedEntry?.(`Skipping unsupported tar entry type "${typeflag}" for "${rawName}" (only regular files are extracted).`);
|
|
3980
|
+
break;
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
offset = dataStart + Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE;
|
|
3984
|
+
}
|
|
3985
|
+
return files;
|
|
3986
|
+
}
|
|
3987
|
+
function isZeroBlock(block) {
|
|
3988
|
+
return block.every((byte) => byte === 0);
|
|
3989
|
+
}
|
|
3990
|
+
/** Cut a string at its first NUL character (tar fields are NUL-terminated). */
|
|
3991
|
+
function trimAtFirstNul(value) {
|
|
3992
|
+
const nulIndex = value.indexOf("\0");
|
|
3993
|
+
return nulIndex === -1 ? value : value.substring(0, nulIndex);
|
|
3994
|
+
}
|
|
3995
|
+
function parseOctalField(block, offset, length, field) {
|
|
3996
|
+
if (((block[offset] ?? 0) & 128) !== 0) throw new NpmTarError(`Unsupported tar archive: base-256 ${field} field is not supported`);
|
|
3997
|
+
const text = trimAtFirstNul(block.toString("latin1", offset, offset + length)).trim();
|
|
3998
|
+
if (text === "") return 0;
|
|
3999
|
+
if (!/^[0-7]+$/.test(text)) throw new NpmTarError(`Invalid tar header: malformed octal ${field} field`);
|
|
4000
|
+
return Number.parseInt(text, 8);
|
|
4001
|
+
}
|
|
4002
|
+
/**
|
|
4003
|
+
* Verify the ustar header checksum: the unsigned byte sum of the 512-byte
|
|
4004
|
+
* header with the checksum field itself treated as ASCII spaces.
|
|
4005
|
+
*/
|
|
4006
|
+
function verifyHeaderChecksum(header) {
|
|
4007
|
+
const stored = parseOctalField(header, 148, 8, "checksum");
|
|
4008
|
+
let sum = 0;
|
|
4009
|
+
for (let i = 0; i < BLOCK_SIZE; i++) sum += i >= 148 && i < 156 ? 32 : header[i] ?? 0;
|
|
4010
|
+
if (sum !== stored) throw new NpmTarError("Invalid tar header: checksum mismatch");
|
|
4011
|
+
}
|
|
4012
|
+
function readCString(block, offset, length) {
|
|
4013
|
+
const end = block.indexOf(0, offset);
|
|
4014
|
+
const stop = end === -1 || end > offset + length ? offset + length : end;
|
|
4015
|
+
return block.toString("utf8", offset, stop);
|
|
4016
|
+
}
|
|
4017
|
+
function resolveEntryName(params) {
|
|
4018
|
+
const { header, pendingLongName, pendingPaxPath } = params;
|
|
4019
|
+
if (pendingPaxPath !== void 0) return pendingPaxPath;
|
|
4020
|
+
if (pendingLongName !== void 0) return pendingLongName;
|
|
4021
|
+
const name = readCString(header, 0, 100);
|
|
4022
|
+
const prefix = header.toString("latin1", 257, 262) === "ustar" ? readCString(header, 345, 155) : "";
|
|
4023
|
+
return prefix.length > 0 ? `${prefix}/${name}` : name;
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Parse pax extended header records of the form `<len> <key>=<value>\n`,
|
|
4027
|
+
* where `<len>` is the decimal length of the whole record.
|
|
4028
|
+
*/
|
|
4029
|
+
function parsePaxRecords(data) {
|
|
4030
|
+
const records = /* @__PURE__ */ new Map();
|
|
4031
|
+
let offset = 0;
|
|
4032
|
+
while (offset < data.length) {
|
|
4033
|
+
if (data[offset] === 0) break;
|
|
4034
|
+
const spaceIndex = data.indexOf(32, offset);
|
|
4035
|
+
if (spaceIndex === -1) throw new NpmTarError("Invalid pax header: missing length delimiter");
|
|
4036
|
+
const recordLength = Number.parseInt(data.toString("utf8", offset, spaceIndex), 10);
|
|
4037
|
+
if (!Number.isInteger(recordLength) || recordLength <= 0 || offset + recordLength > data.length) throw new NpmTarError("Invalid pax header: malformed record length");
|
|
4038
|
+
const record = data.toString("utf8", spaceIndex + 1, offset + recordLength - 1);
|
|
4039
|
+
const equalsIndex = record.indexOf("=");
|
|
4040
|
+
if (equalsIndex !== -1) records.set(record.slice(0, equalsIndex), record.slice(equalsIndex + 1));
|
|
4041
|
+
offset += recordLength;
|
|
4042
|
+
}
|
|
4043
|
+
return records;
|
|
4044
|
+
}
|
|
4045
|
+
/**
|
|
4046
|
+
* Validate a tar entry path and convert it to a package-root-relative path
|
|
4047
|
+
* with the first component stripped. Returns null for entries that resolve to
|
|
4048
|
+
* the package root itself (e.g. the `package/` folder entry). Throws on
|
|
4049
|
+
* traversal attempts.
|
|
4050
|
+
*/
|
|
4051
|
+
function toSafeRelativePath(rawPath) {
|
|
4052
|
+
if (rawPath.includes("\0")) throw new NpmTarError(`Unsafe tar entry path (NUL byte): "${rawPath}"`);
|
|
4053
|
+
if (rawPath.includes("\\")) throw new NpmTarError(`Unsafe tar entry path (backslash): "${rawPath}"`);
|
|
4054
|
+
if (rawPath.startsWith("/")) throw new NpmTarError(`Unsafe tar entry path (absolute): "${rawPath}"`);
|
|
4055
|
+
const segments = rawPath.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
4056
|
+
if (segments.includes("..")) throw new NpmTarError(`Unsafe tar entry path (".." segment): "${rawPath}"`);
|
|
4057
|
+
segments.shift();
|
|
4058
|
+
if (segments.length === 0) return null;
|
|
4059
|
+
return segments.join("/");
|
|
4060
|
+
}
|
|
3549
4061
|
/**
|
|
3550
4062
|
* Schema for a single locked skill entry with content integrity.
|
|
3551
4063
|
*/
|
|
@@ -3735,37 +4247,41 @@ async function resolveAndFetchSources(params) {
|
|
|
3735
4247
|
projectRoot,
|
|
3736
4248
|
logger
|
|
3737
4249
|
});
|
|
4250
|
+
let npmLock = options.updateSources ? createEmptyNpmLock() : await readNpmLockFile({
|
|
4251
|
+
projectRoot,
|
|
4252
|
+
logger
|
|
4253
|
+
});
|
|
3738
4254
|
if (options.frozen) assertFrozenLockCoversSources({
|
|
3739
4255
|
lock,
|
|
4256
|
+
npmLock,
|
|
3740
4257
|
sources
|
|
3741
4258
|
});
|
|
3742
4259
|
const originalLockJson = JSON.stringify(lock);
|
|
4260
|
+
const originalNpmLockJson = JSON.stringify(npmLock);
|
|
3743
4261
|
const client = new GitHubClient({ token: GitHubClient.resolveToken(options.token) });
|
|
3744
4262
|
const localSkillNames = await getLocalSkillDirNames(projectRoot);
|
|
3745
4263
|
let totalSkillCount = 0;
|
|
3746
4264
|
const allFetchedSkillNames = /* @__PURE__ */ new Set();
|
|
3747
4265
|
for (const sourceEntry of sources) try {
|
|
3748
|
-
const
|
|
4266
|
+
const result = await fetchSingleSource({
|
|
3749
4267
|
sourceEntry,
|
|
3750
4268
|
client,
|
|
3751
4269
|
projectRoot,
|
|
3752
4270
|
lock,
|
|
4271
|
+
npmLock,
|
|
3753
4272
|
localSkillNames,
|
|
3754
4273
|
alreadyFetchedSkillNames: allFetchedSkillNames,
|
|
3755
4274
|
updateSources: options.updateSources ?? false,
|
|
3756
4275
|
frozen: options.frozen ?? false,
|
|
3757
4276
|
logger
|
|
3758
4277
|
});
|
|
3759
|
-
lock =
|
|
3760
|
-
|
|
3761
|
-
|
|
4278
|
+
lock = result.lock;
|
|
4279
|
+
npmLock = result.npmLock;
|
|
4280
|
+
totalSkillCount += result.skillCount;
|
|
4281
|
+
for (const name of result.fetchedSkillNames) allFetchedSkillNames.add(name);
|
|
3762
4282
|
} catch (error) {
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
error,
|
|
3766
|
-
logger
|
|
3767
|
-
});
|
|
3768
|
-
else if (error instanceof GitClientError) logGitClientHints({
|
|
4283
|
+
logSourceFetchFailure({
|
|
4284
|
+
sourceEntry,
|
|
3769
4285
|
error,
|
|
3770
4286
|
logger
|
|
3771
4287
|
});
|
|
@@ -3775,18 +4291,100 @@ async function resolveAndFetchSources(params) {
|
|
|
3775
4291
|
sources,
|
|
3776
4292
|
logger
|
|
3777
4293
|
});
|
|
3778
|
-
|
|
4294
|
+
npmLock = pruneStaleNpmLockEntries({
|
|
4295
|
+
npmLock,
|
|
4296
|
+
sources,
|
|
4297
|
+
logger
|
|
4298
|
+
});
|
|
4299
|
+
await writeLockFilesIfChanged({
|
|
3779
4300
|
projectRoot,
|
|
3780
4301
|
lock,
|
|
4302
|
+
npmLock,
|
|
4303
|
+
originalLockJson,
|
|
4304
|
+
originalNpmLockJson,
|
|
4305
|
+
frozen: options.frozen ?? false,
|
|
3781
4306
|
logger
|
|
3782
4307
|
});
|
|
3783
|
-
else logger.debug("Lockfile unchanged, skipping write.");
|
|
3784
4308
|
return {
|
|
3785
4309
|
fetchedSkillCount: totalSkillCount,
|
|
3786
4310
|
sourcesProcessed: sources.length
|
|
3787
4311
|
};
|
|
3788
4312
|
}
|
|
3789
4313
|
/**
|
|
4314
|
+
* Dispatch a single source to the npm fetcher or the git/github fetcher,
|
|
4315
|
+
* returning the (possibly) updated lock objects for both lockfiles.
|
|
4316
|
+
*/
|
|
4317
|
+
async function fetchSingleSource(params) {
|
|
4318
|
+
const { sourceEntry, lock, npmLock } = params;
|
|
4319
|
+
if ((sourceEntry.transport ?? "github") === "npm") {
|
|
4320
|
+
const result = await fetchSourceViaNpm({
|
|
4321
|
+
sourceEntry,
|
|
4322
|
+
projectRoot: params.projectRoot,
|
|
4323
|
+
npmLock,
|
|
4324
|
+
localSkillNames: params.localSkillNames,
|
|
4325
|
+
alreadyFetchedSkillNames: params.alreadyFetchedSkillNames,
|
|
4326
|
+
updateSources: params.updateSources,
|
|
4327
|
+
logger: params.logger
|
|
4328
|
+
});
|
|
4329
|
+
return {
|
|
4330
|
+
skillCount: result.skillCount,
|
|
4331
|
+
fetchedSkillNames: result.fetchedSkillNames,
|
|
4332
|
+
lock,
|
|
4333
|
+
npmLock: result.updatedLock
|
|
4334
|
+
};
|
|
4335
|
+
}
|
|
4336
|
+
const result = await fetchSourceByTransport({
|
|
4337
|
+
sourceEntry,
|
|
4338
|
+
client: params.client,
|
|
4339
|
+
projectRoot: params.projectRoot,
|
|
4340
|
+
lock,
|
|
4341
|
+
localSkillNames: params.localSkillNames,
|
|
4342
|
+
alreadyFetchedSkillNames: params.alreadyFetchedSkillNames,
|
|
4343
|
+
updateSources: params.updateSources,
|
|
4344
|
+
frozen: params.frozen,
|
|
4345
|
+
logger: params.logger
|
|
4346
|
+
});
|
|
4347
|
+
return {
|
|
4348
|
+
skillCount: result.skillCount,
|
|
4349
|
+
fetchedSkillNames: result.fetchedSkillNames,
|
|
4350
|
+
lock: result.updatedLock,
|
|
4351
|
+
npmLock
|
|
4352
|
+
};
|
|
4353
|
+
}
|
|
4354
|
+
/** Log a per-source fetch failure with transport-specific troubleshooting hints. */
|
|
4355
|
+
function logSourceFetchFailure(params) {
|
|
4356
|
+
const { sourceEntry, error, logger } = params;
|
|
4357
|
+
logger.error(`Failed to fetch source "${sourceEntry.source}": ${formatError(error)}`);
|
|
4358
|
+
if (error instanceof GitHubClientError) logGitHubAuthHints({
|
|
4359
|
+
error,
|
|
4360
|
+
logger
|
|
4361
|
+
});
|
|
4362
|
+
else if (error instanceof GitClientError) logGitClientHints({
|
|
4363
|
+
error,
|
|
4364
|
+
logger
|
|
4365
|
+
});
|
|
4366
|
+
else if (error instanceof NpmClientError) logNpmAuthHints({
|
|
4367
|
+
error,
|
|
4368
|
+
logger
|
|
4369
|
+
});
|
|
4370
|
+
}
|
|
4371
|
+
/** Write each lockfile only when it changed (and never in frozen mode). */
|
|
4372
|
+
async function writeLockFilesIfChanged(params) {
|
|
4373
|
+
const { projectRoot, lock, npmLock, originalLockJson, originalNpmLockJson, frozen, logger } = params;
|
|
4374
|
+
if (!frozen && JSON.stringify(lock) !== originalLockJson) await writeLockFile({
|
|
4375
|
+
projectRoot,
|
|
4376
|
+
lock,
|
|
4377
|
+
logger
|
|
4378
|
+
});
|
|
4379
|
+
else logger.debug("Lockfile unchanged, skipping write.");
|
|
4380
|
+
if (!frozen && JSON.stringify(npmLock) !== originalNpmLockJson) await writeNpmLockFile({
|
|
4381
|
+
projectRoot,
|
|
4382
|
+
lock: npmLock,
|
|
4383
|
+
logger
|
|
4384
|
+
});
|
|
4385
|
+
else logger.debug("npm lockfile unchanged, skipping write.");
|
|
4386
|
+
}
|
|
4387
|
+
/**
|
|
3790
4388
|
* Log contextual hints for GitClientError to help users troubleshoot.
|
|
3791
4389
|
*/
|
|
3792
4390
|
function logGitClientHints(params) {
|
|
@@ -3795,13 +4393,15 @@ function logGitClientHints(params) {
|
|
|
3795
4393
|
else logger.info("Hint: Check your git credentials (SSH keys, credential helper, or access token).");
|
|
3796
4394
|
}
|
|
3797
4395
|
/**
|
|
3798
|
-
* Frozen mode: validate the
|
|
3799
|
-
* remediation guidance listing any uncovered source keys.
|
|
4396
|
+
* Frozen mode: validate the lockfiles cover every declared source. Throws with
|
|
4397
|
+
* remediation guidance listing any uncovered source keys. npm-transport
|
|
4398
|
+
* sources are checked against the npm lockfile; everything else against the
|
|
4399
|
+
* main sources lockfile.
|
|
3800
4400
|
*/
|
|
3801
4401
|
function assertFrozenLockCoversSources(params) {
|
|
3802
|
-
const { lock, sources } = params;
|
|
4402
|
+
const { lock, npmLock, sources } = params;
|
|
3803
4403
|
const missingKeys = [];
|
|
3804
|
-
for (const source of sources) if (!getLockedSource(lock, source.source)) missingKeys.push(source.source);
|
|
4404
|
+
for (const source of sources) if (!((source.transport ?? "github") === "npm" ? getNpmLockedSource(npmLock, source.source) : getLockedSource(lock, source.source))) missingKeys.push(source.source);
|
|
3805
4405
|
if (missingKeys.length > 0) throw new Error(`Frozen install failed: lockfile is missing entries for: ${missingKeys.join(", ")}. Run 'rulesync install' to update the lockfile.`);
|
|
3806
4406
|
}
|
|
3807
4407
|
/**
|
|
@@ -3837,7 +4437,7 @@ async function fetchSourceByTransport(params) {
|
|
|
3837
4437
|
*/
|
|
3838
4438
|
function pruneStaleLockEntries(params) {
|
|
3839
4439
|
const { lock, sources, logger } = params;
|
|
3840
|
-
const sourceKeys = new Set(sources.map((s) => normalizeSourceKey(s.source)));
|
|
4440
|
+
const sourceKeys = new Set(sources.filter((s) => (s.transport ?? "github") !== "npm").map((s) => normalizeSourceKey(s.source)));
|
|
3841
4441
|
const prunedSources = {};
|
|
3842
4442
|
for (const [key, value] of Object.entries(lock.sources)) if (sourceKeys.has(normalizeSourceKey(key))) prunedSources[key] = value;
|
|
3843
4443
|
else logger.debug(`Pruned stale lockfile entry: ${key}`);
|
|
@@ -3847,6 +4447,21 @@ function pruneStaleLockEntries(params) {
|
|
|
3847
4447
|
};
|
|
3848
4448
|
}
|
|
3849
4449
|
/**
|
|
4450
|
+
* Prune stale npm lockfile entries whose keys are not in the current
|
|
4451
|
+
* npm-transport sources (immutable — returns a fresh lock object).
|
|
4452
|
+
*/
|
|
4453
|
+
function pruneStaleNpmLockEntries(params) {
|
|
4454
|
+
const { npmLock, sources, logger } = params;
|
|
4455
|
+
const sourceKeys = new Set(sources.filter((s) => (s.transport ?? "github") === "npm").map((s) => normalizeNpmSourceKey(s.source)));
|
|
4456
|
+
const prunedSources = {};
|
|
4457
|
+
for (const [key, value] of Object.entries(npmLock.sources)) if (sourceKeys.has(normalizeNpmSourceKey(key))) prunedSources[key] = value;
|
|
4458
|
+
else logger.debug(`Pruned stale npm lockfile entry: ${key}`);
|
|
4459
|
+
return {
|
|
4460
|
+
lockfileVersion: npmLock.lockfileVersion,
|
|
4461
|
+
sources: prunedSources
|
|
4462
|
+
};
|
|
4463
|
+
}
|
|
4464
|
+
/**
|
|
3850
4465
|
* Check if all locked skills exist on disk in the curated directory.
|
|
3851
4466
|
*/
|
|
3852
4467
|
async function checkLockedSkillsExist(curatedDir, skillNames) {
|
|
@@ -3914,16 +4529,31 @@ async function writeSkillAndComputeIntegrity(params) {
|
|
|
3914
4529
|
return { integrity };
|
|
3915
4530
|
}
|
|
3916
4531
|
/**
|
|
4532
|
+
* Merge back locked skills that still exist in the remote but were skipped
|
|
4533
|
+
* during fetching (due to local precedence, already-fetched, etc.). Skills no
|
|
4534
|
+
* longer present in the remote (e.g. renamed or deleted upstream) are
|
|
4535
|
+
* intentionally dropped. Shared by the git/github and npm lock updates.
|
|
4536
|
+
*/
|
|
4537
|
+
function mergeFetchedWithLockedSkills(params) {
|
|
4538
|
+
const { fetchedSkills, lockedSkills, remoteSkillNames } = params;
|
|
4539
|
+
const remoteSet = new Set(remoteSkillNames);
|
|
4540
|
+
const mergedSkills = { ...fetchedSkills };
|
|
4541
|
+
if (lockedSkills) {
|
|
4542
|
+
for (const [skillName, skillEntry] of Object.entries(lockedSkills)) if (!(skillName in mergedSkills) && remoteSet.has(skillName)) mergedSkills[skillName] = skillEntry;
|
|
4543
|
+
}
|
|
4544
|
+
return mergedSkills;
|
|
4545
|
+
}
|
|
4546
|
+
/**
|
|
3917
4547
|
* Merge newly fetched skills with existing locked skills and update the lockfile.
|
|
3918
4548
|
*/
|
|
3919
4549
|
function buildLockUpdate(params) {
|
|
3920
4550
|
const { lock, sourceKey, fetchedSkills, locked, requestedRef, resolvedSha, remoteSkillNames, logger } = params;
|
|
3921
4551
|
const fetchedNames = Object.keys(fetchedSkills);
|
|
3922
|
-
const
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
}
|
|
4552
|
+
const mergedSkills = mergeFetchedWithLockedSkills({
|
|
4553
|
+
fetchedSkills,
|
|
4554
|
+
lockedSkills: locked?.skills,
|
|
4555
|
+
remoteSkillNames
|
|
4556
|
+
});
|
|
3927
4557
|
const updatedLock = setLockedSource(lock, sourceKey, {
|
|
3928
4558
|
requestedRef,
|
|
3929
4559
|
resolvedRef: resolvedSha,
|
|
@@ -4364,6 +4994,265 @@ async function fetchSourceViaGit(params) {
|
|
|
4364
4994
|
updatedLock: result.updatedLock
|
|
4365
4995
|
};
|
|
4366
4996
|
}
|
|
4997
|
+
/**
|
|
4998
|
+
* Select the skill files inside an extracted npm package, mirroring the git
|
|
4999
|
+
* transport's discovery: files under `skills/` (or the configured `path`) are
|
|
5000
|
+
* grouped per subdirectory; a package whose `SKILL.md` sits at the package
|
|
5001
|
+
* root is installed as a single skill (root fallback via
|
|
5002
|
+
* {@link shouldUseRootFallback}), named after the requested skill or, for
|
|
5003
|
+
* wildcard fetches, after the package's base name.
|
|
5004
|
+
*/
|
|
5005
|
+
function selectNpmSkillFiles(params) {
|
|
5006
|
+
const { allFiles, skillsPath, skillFilter, isWildcard, packageName } = params;
|
|
5007
|
+
const normalizedBase = posix.normalize(skillsPath.replace(/\\/g, "/")).replace(/\/+$/, "");
|
|
5008
|
+
if (normalizedBase === "" || normalizedBase === ".") return {
|
|
5009
|
+
remoteFiles: allFiles,
|
|
5010
|
+
skillFilter,
|
|
5011
|
+
isWildcard
|
|
5012
|
+
};
|
|
5013
|
+
const prefix = `${normalizedBase}/`;
|
|
5014
|
+
const filesUnderBase = allFiles.filter((file) => file.relativePath.startsWith(prefix)).map((file) => ({
|
|
5015
|
+
relativePath: file.relativePath.substring(prefix.length),
|
|
5016
|
+
content: file.content
|
|
5017
|
+
}));
|
|
5018
|
+
if (filesUnderBase.length > 0) return {
|
|
5019
|
+
remoteFiles: filesUnderBase,
|
|
5020
|
+
skillFilter,
|
|
5021
|
+
isWildcard
|
|
5022
|
+
};
|
|
5023
|
+
const hasRootSkillFile = allFiles.some((file) => file.relativePath === SKILL_FILE_NAME$1);
|
|
5024
|
+
const fallbackFilter = isWildcard ? [npmPackageBaseName(packageName)] : skillFilter;
|
|
5025
|
+
const [singleSkillName] = fallbackFilter;
|
|
5026
|
+
if (fallbackFilter.length === 1 && singleSkillName !== void 0 && shouldUseRootFallback({
|
|
5027
|
+
skillFilter: fallbackFilter,
|
|
5028
|
+
isWildcard: false,
|
|
5029
|
+
hasRootSkillFile,
|
|
5030
|
+
hasRequestedSkillDir: false
|
|
5031
|
+
})) return {
|
|
5032
|
+
remoteFiles: allFiles,
|
|
5033
|
+
skillFilter: fallbackFilter,
|
|
5034
|
+
isWildcard: false
|
|
5035
|
+
};
|
|
5036
|
+
return {
|
|
5037
|
+
remoteFiles: filesUnderBase,
|
|
5038
|
+
skillFilter,
|
|
5039
|
+
isWildcard
|
|
5040
|
+
};
|
|
5041
|
+
}
|
|
5042
|
+
/** Base name of an npm package: `@scope/name` -> `name`. */
|
|
5043
|
+
function npmPackageBaseName(packageName) {
|
|
5044
|
+
const slashIndex = packageName.indexOf("/");
|
|
5045
|
+
return slashIndex === -1 ? packageName : packageName.substring(slashIndex + 1);
|
|
5046
|
+
}
|
|
5047
|
+
/**
|
|
5048
|
+
* Resolve the version to fetch for an npm source: the locked version when
|
|
5049
|
+
* available (deterministic re-fetch), otherwise the declared `ref` (exact
|
|
5050
|
+
* version or dist-tag, defaulting to "latest") resolved via the packument.
|
|
5051
|
+
*/
|
|
5052
|
+
function resolveNpmFetchVersion(params) {
|
|
5053
|
+
const { sourceEntry, locked, updateSources } = params;
|
|
5054
|
+
if (locked && !updateSources) return {
|
|
5055
|
+
lockedVersion: locked.resolvedVersion,
|
|
5056
|
+
requestedVersion: locked.requestedVersion
|
|
5057
|
+
};
|
|
5058
|
+
return {
|
|
5059
|
+
lockedVersion: void 0,
|
|
5060
|
+
requestedVersion: sourceEntry.ref ?? "latest"
|
|
5061
|
+
};
|
|
5062
|
+
}
|
|
5063
|
+
/**
|
|
5064
|
+
* Resolve the package version via the registry packument, download the
|
|
5065
|
+
* tarball, and verify it against the registry (and, when re-fetching a locked
|
|
5066
|
+
* version, the locked) integrity metadata.
|
|
5067
|
+
*/
|
|
5068
|
+
async function downloadVerifiedNpmTarball(params) {
|
|
5069
|
+
const { packageName, registryUrl, token, lockedVersion, requestedVersion, locked, logger } = params;
|
|
5070
|
+
const packument = await fetchPackument({
|
|
5071
|
+
registryUrl,
|
|
5072
|
+
packageName,
|
|
5073
|
+
token
|
|
5074
|
+
});
|
|
5075
|
+
const resolvedVersion = lockedVersion ?? resolvePackumentVersion({
|
|
5076
|
+
packument,
|
|
5077
|
+
packageName,
|
|
5078
|
+
requested: requestedVersion ?? "latest"
|
|
5079
|
+
});
|
|
5080
|
+
logger.debug(`Resolved ${packageName}@${requestedVersion ?? "latest"} to ${resolvedVersion}`);
|
|
5081
|
+
const dist = getPackumentVersionDist({
|
|
5082
|
+
packument,
|
|
5083
|
+
packageName,
|
|
5084
|
+
version: resolvedVersion
|
|
5085
|
+
});
|
|
5086
|
+
const tarball = await fetchTarball({
|
|
5087
|
+
tarballUrl: dist.tarball,
|
|
5088
|
+
registryUrl,
|
|
5089
|
+
token
|
|
5090
|
+
});
|
|
5091
|
+
const context = `${packageName}@${resolvedVersion}`;
|
|
5092
|
+
verifyTarballIntegrity({
|
|
5093
|
+
tarball,
|
|
5094
|
+
integrity: dist.integrity,
|
|
5095
|
+
shasum: dist.shasum,
|
|
5096
|
+
context,
|
|
5097
|
+
logger
|
|
5098
|
+
});
|
|
5099
|
+
if (locked?.integrity && locked.resolvedVersion === resolvedVersion) verifyTarballIntegrity({
|
|
5100
|
+
tarball,
|
|
5101
|
+
integrity: locked.integrity,
|
|
5102
|
+
context,
|
|
5103
|
+
logger
|
|
5104
|
+
});
|
|
5105
|
+
return {
|
|
5106
|
+
resolvedVersion,
|
|
5107
|
+
dist,
|
|
5108
|
+
tarball
|
|
5109
|
+
};
|
|
5110
|
+
}
|
|
5111
|
+
/**
|
|
5112
|
+
* Extract a verified npm tarball in memory and convert its entries into
|
|
5113
|
+
* remote skill files, skipping any file above MAX_FILE_SIZE.
|
|
5114
|
+
*/
|
|
5115
|
+
function extractNpmRemoteFiles(params) {
|
|
5116
|
+
const { tarball, logger } = params;
|
|
5117
|
+
const extracted = extractPackageTarball({
|
|
5118
|
+
tarball,
|
|
5119
|
+
onSkippedEntry: (message) => logger.warn(message)
|
|
5120
|
+
});
|
|
5121
|
+
const allFiles = [];
|
|
5122
|
+
for (const entry of extracted) {
|
|
5123
|
+
if (entry.content.length > 10485760) {
|
|
5124
|
+
logger.warn(`Skipping file "${entry.relativePath}" (${(entry.content.length / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`);
|
|
5125
|
+
continue;
|
|
5126
|
+
}
|
|
5127
|
+
allFiles.push({
|
|
5128
|
+
relativePath: entry.relativePath,
|
|
5129
|
+
content: entry.content.toString("utf8")
|
|
5130
|
+
});
|
|
5131
|
+
}
|
|
5132
|
+
return allFiles;
|
|
5133
|
+
}
|
|
5134
|
+
/** Build the npm lockfile entry for a fetched source. */
|
|
5135
|
+
function buildNpmLockEntry(params) {
|
|
5136
|
+
const { sourceEntry, requestedVersion, resolvedVersion, dist, mergedSkills } = params;
|
|
5137
|
+
const integrity = dist.integrity ?? (dist.shasum !== void 0 ? shasumToSri(dist.shasum) : void 0);
|
|
5138
|
+
return {
|
|
5139
|
+
...sourceEntry.registry !== void 0 && { registry: sourceEntry.registry },
|
|
5140
|
+
...requestedVersion !== void 0 && { requestedVersion },
|
|
5141
|
+
resolvedVersion,
|
|
5142
|
+
...integrity !== void 0 && { integrity },
|
|
5143
|
+
resolvedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5144
|
+
skills: mergedSkills
|
|
5145
|
+
};
|
|
5146
|
+
}
|
|
5147
|
+
/**
|
|
5148
|
+
* Fetch skills from a single npm-transport source (EXPERIMENTAL): resolve the
|
|
5149
|
+
* package version via the registry packument, download and verify the
|
|
5150
|
+
* tarball, extract it in-memory with the hardened tar reader, and install the
|
|
5151
|
+
* discovered skills into the curated directory.
|
|
5152
|
+
*/
|
|
5153
|
+
async function fetchSourceViaNpm(params) {
|
|
5154
|
+
const { sourceEntry, projectRoot, npmLock, localSkillNames, alreadyFetchedSkillNames, updateSources, logger } = params;
|
|
5155
|
+
const packageName = sourceEntry.source;
|
|
5156
|
+
validateNpmPackageName(packageName);
|
|
5157
|
+
const registryUrl = sourceEntry.registry ?? "https://registry.npmjs.org";
|
|
5158
|
+
validateNpmRegistryUrl(registryUrl, { logger });
|
|
5159
|
+
const token = resolveNpmToken({ tokenEnv: sourceEntry.tokenEnv });
|
|
5160
|
+
const sourceKey = packageName;
|
|
5161
|
+
const locked = getNpmLockedSource(npmLock, sourceKey);
|
|
5162
|
+
const lockedSkillNames = locked ? getNpmLockedSkillNames(locked) : [];
|
|
5163
|
+
const curatedDir = join(projectRoot, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH);
|
|
5164
|
+
const { lockedVersion, requestedVersion } = resolveNpmFetchVersion({
|
|
5165
|
+
sourceEntry,
|
|
5166
|
+
locked,
|
|
5167
|
+
updateSources
|
|
5168
|
+
});
|
|
5169
|
+
if (lockedVersion !== void 0) {
|
|
5170
|
+
if (await checkLockedSkillsExist(curatedDir, lockedSkillNames)) {
|
|
5171
|
+
logger.debug(`Version unchanged for ${sourceKey}, skipping re-fetch.`);
|
|
5172
|
+
return {
|
|
5173
|
+
skillCount: 0,
|
|
5174
|
+
fetchedSkillNames: lockedSkillNames,
|
|
5175
|
+
updatedLock: npmLock
|
|
5176
|
+
};
|
|
5177
|
+
}
|
|
5178
|
+
}
|
|
5179
|
+
const { resolvedVersion, dist, tarball } = await downloadVerifiedNpmTarball({
|
|
5180
|
+
packageName,
|
|
5181
|
+
registryUrl,
|
|
5182
|
+
token,
|
|
5183
|
+
lockedVersion,
|
|
5184
|
+
requestedVersion,
|
|
5185
|
+
locked,
|
|
5186
|
+
logger
|
|
5187
|
+
});
|
|
5188
|
+
const allFiles = extractNpmRemoteFiles({
|
|
5189
|
+
tarball,
|
|
5190
|
+
logger
|
|
5191
|
+
});
|
|
5192
|
+
const declaredFilter = sourceEntry.skills ?? ["*"];
|
|
5193
|
+
const declaredWildcard = declaredFilter.length === 1 && declaredFilter[0] === "*";
|
|
5194
|
+
const { remoteFiles, skillFilter, isWildcard } = selectNpmSkillFiles({
|
|
5195
|
+
allFiles,
|
|
5196
|
+
skillsPath: sourceEntry.path ?? "skills",
|
|
5197
|
+
skillFilter: declaredFilter,
|
|
5198
|
+
isWildcard: declaredWildcard,
|
|
5199
|
+
packageName
|
|
5200
|
+
});
|
|
5201
|
+
const skillFileMap = groupRemoteFilesBySkillRoot({
|
|
5202
|
+
remoteFiles,
|
|
5203
|
+
skillFilter,
|
|
5204
|
+
isWildcard
|
|
5205
|
+
});
|
|
5206
|
+
const allNames = [...skillFileMap.keys()];
|
|
5207
|
+
const filteredNames = isWildcard ? allNames : allNames.filter((n) => skillFilter.includes(n));
|
|
5208
|
+
if (locked) await cleanPreviousCuratedSkills({
|
|
5209
|
+
curatedDir,
|
|
5210
|
+
lockedSkillNames,
|
|
5211
|
+
logger
|
|
5212
|
+
});
|
|
5213
|
+
const lockedForIntegrityCheck = locked ? {
|
|
5214
|
+
resolvedRef: locked.resolvedVersion,
|
|
5215
|
+
skills: locked.skills
|
|
5216
|
+
} : void 0;
|
|
5217
|
+
const fetchedSkills = {};
|
|
5218
|
+
for (const skillName of filteredNames) {
|
|
5219
|
+
if (shouldSkipSkill({
|
|
5220
|
+
skillName,
|
|
5221
|
+
sourceKey,
|
|
5222
|
+
localSkillNames,
|
|
5223
|
+
alreadyFetchedSkillNames,
|
|
5224
|
+
logger
|
|
5225
|
+
})) continue;
|
|
5226
|
+
fetchedSkills[skillName] = await writeSkillAndComputeIntegrity({
|
|
5227
|
+
skillName,
|
|
5228
|
+
files: skillFileMap.get(skillName) ?? [],
|
|
5229
|
+
curatedDir,
|
|
5230
|
+
locked: lockedForIntegrityCheck,
|
|
5231
|
+
resolvedSha: resolvedVersion,
|
|
5232
|
+
sourceKey,
|
|
5233
|
+
logger
|
|
5234
|
+
});
|
|
5235
|
+
logger.debug(`Fetched skill "${skillName}" from ${sourceKey}`);
|
|
5236
|
+
}
|
|
5237
|
+
const fetchedNames = Object.keys(fetchedSkills);
|
|
5238
|
+
const updatedLock = setNpmLockedSource(npmLock, sourceKey, buildNpmLockEntry({
|
|
5239
|
+
sourceEntry,
|
|
5240
|
+
requestedVersion,
|
|
5241
|
+
resolvedVersion,
|
|
5242
|
+
dist,
|
|
5243
|
+
mergedSkills: mergeFetchedWithLockedSkills({
|
|
5244
|
+
fetchedSkills,
|
|
5245
|
+
lockedSkills: locked?.skills,
|
|
5246
|
+
remoteSkillNames: filteredNames
|
|
5247
|
+
})
|
|
5248
|
+
}));
|
|
5249
|
+
logger.info(`Fetched ${fetchedNames.length} skill(s) from ${sourceKey}: ${fetchedNames.join(", ") || "(none)"}`);
|
|
5250
|
+
return {
|
|
5251
|
+
skillCount: fetchedNames.length,
|
|
5252
|
+
fetchedSkillNames: fetchedNames,
|
|
5253
|
+
updatedLock
|
|
5254
|
+
};
|
|
5255
|
+
}
|
|
4367
5256
|
//#endregion
|
|
4368
5257
|
//#region src/cli/commands/install.ts
|
|
4369
5258
|
const INSTALL_MODES = [
|
|
@@ -6793,7 +7682,7 @@ function wrapCommand$1({ name, errorCode, handler, getVersion, loggerFactory = c
|
|
|
6793
7682
|
}
|
|
6794
7683
|
//#endregion
|
|
6795
7684
|
//#region src/cli/index.ts
|
|
6796
|
-
const getVersion = () => "
|
|
7685
|
+
const getVersion = () => "14.0.0";
|
|
6797
7686
|
function wrapCommand(name, errorCode, handler) {
|
|
6798
7687
|
return wrapCommand$1({
|
|
6799
7688
|
name,
|