rulesync 16.1.0 → 16.3.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 +3 -3
- package/dist/cli/index.cjs +859 -5
- package/dist/cli/index.js +860 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/{import-BmXT7mRS.js → import-Bj5HxY9p.js} +1348 -393
- package/dist/import-Bj5HxY9p.js.map +1 -0
- package/dist/{import-eG7fZPXI.cjs → import-DD-GRmB9.cjs} +1379 -406
- 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-BmXT7mRS.js.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
2
|
+
import { $ as warnOnConflictingFlags, $t as RULESYNC_RELATIVE_DIR_PATH, A as RulesyncMcp, At as MAX_FILE_SIZE, B as stringifyFrontmatter, Bt as RULESYNC_HOOKS_LEGACY_FILE_NAME, C as RulesyncSubagent, Ct as runWithDirectoryRollback, D as RulesyncRule, Dt as ALL_TOOL_TARGETS_WITH_WILDCARD, E as RulesyncSkillFrontmatterSchema, Et as ALL_TOOL_TARGETS, F as parseJsonc, Ft as RULESYNC_CONFIG_RELATIVE_FILE_PATH, G as CONFLICTING_TARGET_PAIRS, Gt as RULESYNC_MCP_LEGACY_FILE_NAME, H as SHARED_USER_MANAGED_CONFIG_PATHS, Ht as RULESYNC_IGNORE_RELATIVE_FILE_PATH, I as RulesyncCommand, It as RULESYNC_CONFIG_SCHEMA_URL, J as SourceEntrySchema, Jt as RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH, K as ConfigFileSchema, Kt as RULESYNC_MCP_RELATIVE_FILE_PATH, L as RulesyncCommandFrontmatterSchema, Lt as RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH, M as RulesyncHooks, Mt as RULESYNC_AIIGNORE_RELATIVE_FILE_PATH, N as getRulesyncSourceCandidates, Nt as RULESYNC_CHECKS_RELATIVE_DIR_PATH, O as RulesyncRuleFrontmatterSchema, Ot as PACKAGING_TOOL_TARGETS, P as resolveRulesyncSourceWritePath, Pt as RULESYNC_COMMANDS_RELATIVE_DIR_PATH, Q as fallbackLogger, Qt as RULESYNC_PERMISSIONS_SCHEMA_URL, R as RulesyncCheck, Rt as RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH, S as getLocalSkillDirNames, St as resolvePath, T as RulesyncSkill, Tt as writeFileContent, U as SKILL_FILE_NAME$1, Ut as RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH, V as loadYaml, Vt as RULESYNC_HOOKS_RELATIVE_FILE_PATH, W as ConfigResolver, Wt as RULESYNC_MCP_FILE_NAME, X as ConsoleLogger, Xt as RULESYNC_PERMISSIONS_LEGACY_FILE_NAME, Y as findControlCharacter, Yt as RULESYNC_PERMISSIONS_FILE_NAME, Z as JsonLogger, Zt as RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH, _ as CLAUDECODE_SETTINGS_LOCAL_FILE_NAME, _t as removeDirectory, a as convertFromTool, an as ALL_FEATURES_WITH_WILDCARD, at as checkPathTraversal, b as CODEXCLI_BASH_RULES_FILE_NAME, bt as removeFileStrict, c as SubagentsProcessor, ct as ensureDir, d as IgnoreProcessor, dt as getFileSize, en as RULESYNC_RULES_RELATIVE_DIR_PATH, et as CLIError, f as HooksProcessor, ft as getHomeDirectory, g as CLAUDECODE_MEMORIES_DIR_NAME, gt as readFileContentOrNull, h as CLAUDECODE_LOCAL_RULE_FILE_NAME, ht as readFileContent, i as getProcessorRegistryEntry, in as ALL_FEATURES, it as assertWritablePathInsideRoot, j as RulesyncIgnore, jt as RULESYNC_AIIGNORE_FILE_NAME, k as RulesyncPermissions, kt as ToolTargetSchema, l as SkillsProcessor, lt as fileExists, m as CLAUDECODE_DIR, mt as listDirectoryFiles, n as checkRulesyncDirExists, nn as RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH, nt as assertDirectoryIfExists, o as isPackagingToolTarget, on as DEPRECATED_FEATURE_REPLACEMENTS, ot as createTempDirectory, p as CommandsProcessor, pt as isSymlink, q as GITIGNORE_DESTINATION_KEY, qt as RULESYNC_MCP_SCHEMA_URL, r as generate, rn as RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, rt as assertTreeContainsNoSymlinks, s as RulesProcessor, sn as formatError, st as directoryExists, t as importFromTool, tn as RULESYNC_SKILLS_RELATIVE_DIR_PATH, tt as ErrorCodes, u as McpProcessor, ut as findFilesByGlobs, v as CLAUDECODE_SKILLS_DIR_PATH, vt as removeDirectoryStrict, w as RulesyncSubagentFrontmatterSchema, wt as toPosixPath, x as CODEXCLI_DIR, xt as removeTempDirectory, y as ChecksProcessor, yt as removeFile, z as RulesyncCheckFrontmatterSchema, zt as RULESYNC_HOOKS_FILE_NAME } from "../import-Bj5HxY9p.js";
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { nonnegative, optional, refine, z } from "zod/mini";
|
|
5
5
|
import { cp, mkdtemp, realpath, rm } from "node:fs/promises";
|
|
@@ -17,8 +17,9 @@ import { promisify } from "node:util";
|
|
|
17
17
|
import { RequestError } from "@octokit/request-error";
|
|
18
18
|
import { Octokit } from "@octokit/rest";
|
|
19
19
|
import { gunzipSync } from "node:zlib";
|
|
20
|
-
import { FastMCP } from "fastmcp";
|
|
21
20
|
import * as fs from "node:fs";
|
|
21
|
+
import { existsSync, watch } from "node:fs";
|
|
22
|
+
import { FastMCP } from "fastmcp";
|
|
22
23
|
import { Readable, Transform } from "node:stream";
|
|
23
24
|
import { pipeline } from "node:stream/promises";
|
|
24
25
|
//#region src/utils/parse-comma-separated-list.ts
|
|
@@ -3859,6 +3860,564 @@ async function convertCommand(logger, options) {
|
|
|
3859
3860
|
else logger.success(summary);
|
|
3860
3861
|
}
|
|
3861
3862
|
//#endregion
|
|
3863
|
+
//#region src/cli/commands/doctor.ts
|
|
3864
|
+
/** Per-feature object form also accepts a gitignore destination override. */
|
|
3865
|
+
const PER_FEATURE_EXTRA_KEYS = [GITIGNORE_DESTINATION_KEY];
|
|
3866
|
+
const KNOWN_CONFIG_KEYS = Object.keys(ConfigFileSchema.shape);
|
|
3867
|
+
/**
|
|
3868
|
+
* Classic dynamic-programming Levenshtein distance; inputs are short config
|
|
3869
|
+
* keys and tool names, so the O(a.b) cost is negligible.
|
|
3870
|
+
*/
|
|
3871
|
+
function levenshteinDistance({ a, b }) {
|
|
3872
|
+
const rows = a.length + 1;
|
|
3873
|
+
const cols = b.length + 1;
|
|
3874
|
+
let previous = Array.from({ length: cols }, (_, i) => i);
|
|
3875
|
+
for (let i = 1; i < rows; i++) {
|
|
3876
|
+
const current = [i, ...Array.from({ length: cols - 1 }, () => 0)];
|
|
3877
|
+
for (let j = 1; j < cols; j++) {
|
|
3878
|
+
const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
3879
|
+
current[j] = Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, (previous[j - 1] ?? 0) + substitutionCost);
|
|
3880
|
+
}
|
|
3881
|
+
previous = current;
|
|
3882
|
+
}
|
|
3883
|
+
return previous[cols - 1] ?? 0;
|
|
3884
|
+
}
|
|
3885
|
+
/**
|
|
3886
|
+
* Returns the closest candidate to `input`, or undefined when nothing is close
|
|
3887
|
+
* enough to be a plausible typo. The threshold scales with input length so
|
|
3888
|
+
* short keys don't produce far-fetched suggestions.
|
|
3889
|
+
*/
|
|
3890
|
+
function suggestNearest({ input, candidates }) {
|
|
3891
|
+
const maxDistance = Math.max(2, Math.floor(input.length / 3));
|
|
3892
|
+
let best;
|
|
3893
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
3894
|
+
for (const candidate of candidates) {
|
|
3895
|
+
const distance = levenshteinDistance({
|
|
3896
|
+
a: input.toLowerCase(),
|
|
3897
|
+
b: candidate.toLowerCase()
|
|
3898
|
+
});
|
|
3899
|
+
if (distance < bestDistance) {
|
|
3900
|
+
bestDistance = distance;
|
|
3901
|
+
best = candidate;
|
|
3902
|
+
}
|
|
3903
|
+
}
|
|
3904
|
+
return bestDistance <= maxDistance ? best : void 0;
|
|
3905
|
+
}
|
|
3906
|
+
function didYouMean({ input, candidates }) {
|
|
3907
|
+
const suggestion = suggestNearest({
|
|
3908
|
+
input,
|
|
3909
|
+
candidates
|
|
3910
|
+
});
|
|
3911
|
+
return suggestion === void 0 ? void 0 : `Did you mean '${suggestion}'?`;
|
|
3912
|
+
}
|
|
3913
|
+
/** Converts a character offset into a 1-based line/column pair. */
|
|
3914
|
+
function offsetToPosition({ content, offset }) {
|
|
3915
|
+
let line = 1;
|
|
3916
|
+
let lineStart = 0;
|
|
3917
|
+
const end = Math.min(offset, content.length);
|
|
3918
|
+
for (let i = 0; i < end; i++) if (content[i] === "\n") {
|
|
3919
|
+
line++;
|
|
3920
|
+
lineStart = i + 1;
|
|
3921
|
+
}
|
|
3922
|
+
return {
|
|
3923
|
+
line,
|
|
3924
|
+
column: end - lineStart + 1
|
|
3925
|
+
};
|
|
3926
|
+
}
|
|
3927
|
+
function isPlainObject(value) {
|
|
3928
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3929
|
+
}
|
|
3930
|
+
function checkTargetName({ name, file, context }) {
|
|
3931
|
+
if (ALL_TOOL_TARGETS.includes(name)) return void 0;
|
|
3932
|
+
return {
|
|
3933
|
+
severity: "error",
|
|
3934
|
+
code: "config/unknown-target",
|
|
3935
|
+
file,
|
|
3936
|
+
message: `Unknown tool target '${name}' in ${context}.`,
|
|
3937
|
+
hint: didYouMean({
|
|
3938
|
+
input: name,
|
|
3939
|
+
candidates: ALL_TOOL_TARGETS
|
|
3940
|
+
}) ?? `Valid targets: ${ALL_TOOL_TARGETS.join(", ")}.`
|
|
3941
|
+
};
|
|
3942
|
+
}
|
|
3943
|
+
function checkFeatureName({ name, file, context }) {
|
|
3944
|
+
const replacement = DEPRECATED_FEATURE_REPLACEMENTS[name];
|
|
3945
|
+
if (replacement !== void 0) return {
|
|
3946
|
+
severity: "warning",
|
|
3947
|
+
code: "config/deprecated-feature",
|
|
3948
|
+
file,
|
|
3949
|
+
message: `Feature '${name}' in ${context} is deprecated.`,
|
|
3950
|
+
hint: `Use the '${replacement}' feature instead.`
|
|
3951
|
+
};
|
|
3952
|
+
if (ALL_FEATURES.includes(name)) return void 0;
|
|
3953
|
+
return {
|
|
3954
|
+
severity: "error",
|
|
3955
|
+
code: "config/unknown-feature",
|
|
3956
|
+
file,
|
|
3957
|
+
message: `Unknown feature '${name}' in ${context}.`,
|
|
3958
|
+
hint: didYouMean({
|
|
3959
|
+
input: name,
|
|
3960
|
+
candidates: ALL_FEATURES
|
|
3961
|
+
}) ?? `Valid features: ${ALL_FEATURES.join(", ")}.`
|
|
3962
|
+
};
|
|
3963
|
+
}
|
|
3964
|
+
function checkTargetsValue({ targets, file }) {
|
|
3965
|
+
const diagnostics = [];
|
|
3966
|
+
if (Array.isArray(targets)) {
|
|
3967
|
+
for (const entry of targets) {
|
|
3968
|
+
if (typeof entry !== "string") {
|
|
3969
|
+
diagnostics.push({
|
|
3970
|
+
severity: "error",
|
|
3971
|
+
code: "config/invalid-value",
|
|
3972
|
+
file,
|
|
3973
|
+
message: `'targets' entries must be strings, found ${JSON.stringify(entry)}.`
|
|
3974
|
+
});
|
|
3975
|
+
continue;
|
|
3976
|
+
}
|
|
3977
|
+
if (entry === "*") continue;
|
|
3978
|
+
const diagnostic = checkTargetName({
|
|
3979
|
+
name: entry,
|
|
3980
|
+
file,
|
|
3981
|
+
context: "'targets'"
|
|
3982
|
+
});
|
|
3983
|
+
if (diagnostic) diagnostics.push(diagnostic);
|
|
3984
|
+
}
|
|
3985
|
+
return diagnostics;
|
|
3986
|
+
}
|
|
3987
|
+
if (isPlainObject(targets)) {
|
|
3988
|
+
for (const [key, value] of Object.entries(targets)) {
|
|
3989
|
+
if (key === "*") {
|
|
3990
|
+
diagnostics.push({
|
|
3991
|
+
severity: "error",
|
|
3992
|
+
code: "config/invalid-value",
|
|
3993
|
+
file,
|
|
3994
|
+
message: "Wildcard '*' is not supported as a key in the object form of 'targets'; per-target options cannot be attached to a wildcard.",
|
|
3995
|
+
hint: "Use the array form `\"targets\": [\"*\"]` instead."
|
|
3996
|
+
});
|
|
3997
|
+
continue;
|
|
3998
|
+
}
|
|
3999
|
+
const diagnostic = checkTargetName({
|
|
4000
|
+
name: key,
|
|
4001
|
+
file,
|
|
4002
|
+
context: "the 'targets' object"
|
|
4003
|
+
});
|
|
4004
|
+
if (diagnostic) {
|
|
4005
|
+
diagnostics.push(diagnostic);
|
|
4006
|
+
continue;
|
|
4007
|
+
}
|
|
4008
|
+
diagnostics.push(...checkPerTargetFeaturesValue({
|
|
4009
|
+
target: key,
|
|
4010
|
+
value,
|
|
4011
|
+
file
|
|
4012
|
+
}));
|
|
4013
|
+
}
|
|
4014
|
+
return diagnostics;
|
|
4015
|
+
}
|
|
4016
|
+
if (targets !== void 0) diagnostics.push({
|
|
4017
|
+
severity: "error",
|
|
4018
|
+
code: "config/invalid-value",
|
|
4019
|
+
file,
|
|
4020
|
+
message: `'targets' must be an array of tool names or a per-target object, found ${JSON.stringify(targets)}.`
|
|
4021
|
+
});
|
|
4022
|
+
return diagnostics;
|
|
4023
|
+
}
|
|
4024
|
+
function checkPerTargetFeaturesValue({ target, value, file }) {
|
|
4025
|
+
const diagnostics = [];
|
|
4026
|
+
if (Array.isArray(value)) {
|
|
4027
|
+
for (const entry of value) {
|
|
4028
|
+
if (typeof entry !== "string") {
|
|
4029
|
+
diagnostics.push({
|
|
4030
|
+
severity: "error",
|
|
4031
|
+
code: "config/invalid-value",
|
|
4032
|
+
file,
|
|
4033
|
+
message: `Features for target '${target}' must be strings, found ${JSON.stringify(entry)}.`
|
|
4034
|
+
});
|
|
4035
|
+
continue;
|
|
4036
|
+
}
|
|
4037
|
+
if (entry === "*") continue;
|
|
4038
|
+
const diagnostic = checkFeatureName({
|
|
4039
|
+
name: entry,
|
|
4040
|
+
file,
|
|
4041
|
+
context: `'targets.${target}'`
|
|
4042
|
+
});
|
|
4043
|
+
if (diagnostic) diagnostics.push(diagnostic);
|
|
4044
|
+
}
|
|
4045
|
+
return diagnostics;
|
|
4046
|
+
}
|
|
4047
|
+
if (isPlainObject(value)) {
|
|
4048
|
+
for (const key of Object.keys(value)) {
|
|
4049
|
+
if (key === "*" || PER_FEATURE_EXTRA_KEYS.includes(key)) continue;
|
|
4050
|
+
const diagnostic = checkFeatureName({
|
|
4051
|
+
name: key,
|
|
4052
|
+
file,
|
|
4053
|
+
context: `'targets.${target}'`
|
|
4054
|
+
});
|
|
4055
|
+
if (diagnostic) diagnostics.push(diagnostic);
|
|
4056
|
+
}
|
|
4057
|
+
return diagnostics;
|
|
4058
|
+
}
|
|
4059
|
+
diagnostics.push({
|
|
4060
|
+
severity: "error",
|
|
4061
|
+
code: "config/invalid-value",
|
|
4062
|
+
file,
|
|
4063
|
+
message: `Value for target '${target}' must be a feature array or a per-feature object, found ${JSON.stringify(value)}.`
|
|
4064
|
+
});
|
|
4065
|
+
return diagnostics;
|
|
4066
|
+
}
|
|
4067
|
+
function checkFeaturesValue({ features, file }) {
|
|
4068
|
+
const diagnostics = [];
|
|
4069
|
+
if (features === void 0) return diagnostics;
|
|
4070
|
+
if (!Array.isArray(features)) {
|
|
4071
|
+
diagnostics.push({
|
|
4072
|
+
severity: "error",
|
|
4073
|
+
code: "config/invalid-value",
|
|
4074
|
+
file,
|
|
4075
|
+
message: `'features' must be an array of feature names, found ${JSON.stringify(features)}.`,
|
|
4076
|
+
hint: "To configure features per target, use the object form of 'targets' instead."
|
|
4077
|
+
});
|
|
4078
|
+
return diagnostics;
|
|
4079
|
+
}
|
|
4080
|
+
for (const entry of features) {
|
|
4081
|
+
if (typeof entry !== "string") {
|
|
4082
|
+
diagnostics.push({
|
|
4083
|
+
severity: "error",
|
|
4084
|
+
code: "config/invalid-value",
|
|
4085
|
+
file,
|
|
4086
|
+
message: `'features' entries must be strings, found ${JSON.stringify(entry)}.`
|
|
4087
|
+
});
|
|
4088
|
+
continue;
|
|
4089
|
+
}
|
|
4090
|
+
if (entry === "*") continue;
|
|
4091
|
+
const diagnostic = checkFeatureName({
|
|
4092
|
+
name: entry,
|
|
4093
|
+
file,
|
|
4094
|
+
context: "'features'"
|
|
4095
|
+
});
|
|
4096
|
+
if (diagnostic) diagnostics.push(diagnostic);
|
|
4097
|
+
}
|
|
4098
|
+
return diagnostics;
|
|
4099
|
+
}
|
|
4100
|
+
function checkConflictingTargets({ targets, file }) {
|
|
4101
|
+
const has = (target) => {
|
|
4102
|
+
if (Array.isArray(targets)) return targets.includes(target);
|
|
4103
|
+
if (isPlainObject(targets)) return Object.prototype.hasOwnProperty.call(targets, target);
|
|
4104
|
+
return false;
|
|
4105
|
+
};
|
|
4106
|
+
const diagnostics = [];
|
|
4107
|
+
for (const [target1, target2] of CONFLICTING_TARGET_PAIRS) if (has(target1) && has(target2)) diagnostics.push({
|
|
4108
|
+
severity: "error",
|
|
4109
|
+
code: "config/conflicting-targets",
|
|
4110
|
+
file,
|
|
4111
|
+
message: `Targets '${target1}' and '${target2}' cannot be used together.`,
|
|
4112
|
+
hint: "Remove one of the two from 'targets'."
|
|
4113
|
+
});
|
|
4114
|
+
return diagnostics;
|
|
4115
|
+
}
|
|
4116
|
+
function checkSchemaProperty({ config, file }) {
|
|
4117
|
+
const schema = config.$schema;
|
|
4118
|
+
if (schema === void 0) return [{
|
|
4119
|
+
severity: "info",
|
|
4120
|
+
code: "config/missing-schema",
|
|
4121
|
+
file,
|
|
4122
|
+
message: "No '$schema' property; editors cannot offer completion and validation.",
|
|
4123
|
+
hint: `Add "$schema": "${RULESYNC_CONFIG_SCHEMA_URL}".`
|
|
4124
|
+
}];
|
|
4125
|
+
if (typeof schema === "string" && schema !== "https://github.com/dyoshikawa/rulesync/releases/latest/download/config-schema.json") return [{
|
|
4126
|
+
severity: "warning",
|
|
4127
|
+
code: "config/outdated-schema",
|
|
4128
|
+
file,
|
|
4129
|
+
message: `'$schema' does not point at the current rulesync config schema.`,
|
|
4130
|
+
hint: `Update it to "${RULESYNC_CONFIG_SCHEMA_URL}".`
|
|
4131
|
+
}];
|
|
4132
|
+
return [];
|
|
4133
|
+
}
|
|
4134
|
+
function checkUnknownTopLevelKeys({ config, file }) {
|
|
4135
|
+
const diagnostics = [];
|
|
4136
|
+
for (const key of Object.keys(config)) {
|
|
4137
|
+
if (KNOWN_CONFIG_KEYS.includes(key)) continue;
|
|
4138
|
+
diagnostics.push({
|
|
4139
|
+
severity: "error",
|
|
4140
|
+
code: "config/unknown-key",
|
|
4141
|
+
file,
|
|
4142
|
+
message: `Unknown key '${key}'. It is silently ignored by 'rulesync generate'.`,
|
|
4143
|
+
hint: didYouMean({
|
|
4144
|
+
input: key,
|
|
4145
|
+
candidates: KNOWN_CONFIG_KEYS
|
|
4146
|
+
}) ?? `Known keys: ${KNOWN_CONFIG_KEYS.join(", ")}.`
|
|
4147
|
+
});
|
|
4148
|
+
}
|
|
4149
|
+
return diagnostics;
|
|
4150
|
+
}
|
|
4151
|
+
function checkTargetsFeaturesExclusivity({ config, file }) {
|
|
4152
|
+
if (!isPlainObject(config.targets) || config.features === void 0) return [];
|
|
4153
|
+
return [{
|
|
4154
|
+
severity: "error",
|
|
4155
|
+
code: "config/targets-features-conflict",
|
|
4156
|
+
file,
|
|
4157
|
+
message: "When 'targets' is in object form, 'features' must be omitted.",
|
|
4158
|
+
hint: "Declare per-target features inside the 'targets' object instead."
|
|
4159
|
+
}];
|
|
4160
|
+
}
|
|
4161
|
+
function checkTokenEnvVars({ config, file, env }) {
|
|
4162
|
+
if (!Array.isArray(config.sources)) return [];
|
|
4163
|
+
const diagnostics = [];
|
|
4164
|
+
for (const source of config.sources) {
|
|
4165
|
+
if (!isPlainObject(source)) continue;
|
|
4166
|
+
const tokenEnv = source.tokenEnv;
|
|
4167
|
+
if (typeof tokenEnv !== "string" || tokenEnv.length === 0) continue;
|
|
4168
|
+
if (env[tokenEnv] === void 0 || env[tokenEnv] === "") diagnostics.push({
|
|
4169
|
+
severity: "warning",
|
|
4170
|
+
code: "config/token-env-not-set",
|
|
4171
|
+
file,
|
|
4172
|
+
message: `Source '${String(source.source ?? "<unnamed>")}' references environment variable '${tokenEnv}', which is not set.`,
|
|
4173
|
+
hint: `Export ${tokenEnv} before running commands that fetch from this source.`
|
|
4174
|
+
});
|
|
4175
|
+
}
|
|
4176
|
+
return diagnostics;
|
|
4177
|
+
}
|
|
4178
|
+
/**
|
|
4179
|
+
* Structural validation via the same Zod schema `ConfigResolver` uses.
|
|
4180
|
+
* `targets` / `features` issues are skipped because the dedicated checks above
|
|
4181
|
+
* already reported them with better messages and suggestions.
|
|
4182
|
+
*/
|
|
4183
|
+
function checkAgainstConfigFileSchema({ config, file }) {
|
|
4184
|
+
const result = ConfigFileSchema.safeParse(config);
|
|
4185
|
+
if (result.success) return [];
|
|
4186
|
+
const diagnostics = [];
|
|
4187
|
+
for (const issue of result.error.issues) {
|
|
4188
|
+
const topLevelKey = issue.path[0];
|
|
4189
|
+
if (topLevelKey === "targets" || topLevelKey === "features") continue;
|
|
4190
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
4191
|
+
diagnostics.push({
|
|
4192
|
+
severity: "error",
|
|
4193
|
+
code: "config/invalid-value",
|
|
4194
|
+
file,
|
|
4195
|
+
message: `Invalid value at '${path}': ${issue.message}`
|
|
4196
|
+
});
|
|
4197
|
+
}
|
|
4198
|
+
return diagnostics;
|
|
4199
|
+
}
|
|
4200
|
+
/**
|
|
4201
|
+
* Runs every per-file check against one configuration file's raw content.
|
|
4202
|
+
* Pure with respect to the filesystem so each check is unit-testable; only the
|
|
4203
|
+
* `tokenEnv` check consults the provided environment map.
|
|
4204
|
+
*/
|
|
4205
|
+
function collectConfigFileDiagnostics({ file, content, env = process.env }) {
|
|
4206
|
+
if (content.trim() === "") return [{
|
|
4207
|
+
severity: "warning",
|
|
4208
|
+
code: "config/empty-file",
|
|
4209
|
+
file,
|
|
4210
|
+
message: "Configuration file is empty; rulesync will run with built-in defaults."
|
|
4211
|
+
}];
|
|
4212
|
+
const parseErrors = [];
|
|
4213
|
+
const parsed = parse(content, parseErrors, { allowTrailingComma: true });
|
|
4214
|
+
if (parseErrors.length > 0) return parseErrors.map((parseError) => {
|
|
4215
|
+
const { line, column } = offsetToPosition({
|
|
4216
|
+
content,
|
|
4217
|
+
offset: parseError.offset
|
|
4218
|
+
});
|
|
4219
|
+
return {
|
|
4220
|
+
severity: "error",
|
|
4221
|
+
code: "config/parse-error",
|
|
4222
|
+
file,
|
|
4223
|
+
message: `JSONC parse error: ${printParseErrorCode(parseError.error)}.`,
|
|
4224
|
+
line,
|
|
4225
|
+
column
|
|
4226
|
+
};
|
|
4227
|
+
});
|
|
4228
|
+
if (!isPlainObject(parsed)) return [{
|
|
4229
|
+
severity: "error",
|
|
4230
|
+
code: "config/not-an-object",
|
|
4231
|
+
file,
|
|
4232
|
+
message: `Configuration file must contain a JSON object, found ${JSON.stringify(parsed)}.`
|
|
4233
|
+
}];
|
|
4234
|
+
return [
|
|
4235
|
+
...checkUnknownTopLevelKeys({
|
|
4236
|
+
config: parsed,
|
|
4237
|
+
file
|
|
4238
|
+
}),
|
|
4239
|
+
...checkSchemaProperty({
|
|
4240
|
+
config: parsed,
|
|
4241
|
+
file
|
|
4242
|
+
}),
|
|
4243
|
+
...checkTargetsValue({
|
|
4244
|
+
targets: parsed.targets,
|
|
4245
|
+
file
|
|
4246
|
+
}),
|
|
4247
|
+
...checkFeaturesValue({
|
|
4248
|
+
features: parsed.features,
|
|
4249
|
+
file
|
|
4250
|
+
}),
|
|
4251
|
+
...checkTargetsFeaturesExclusivity({
|
|
4252
|
+
config: parsed,
|
|
4253
|
+
file
|
|
4254
|
+
}),
|
|
4255
|
+
...checkConflictingTargets({
|
|
4256
|
+
targets: parsed.targets,
|
|
4257
|
+
file
|
|
4258
|
+
}),
|
|
4259
|
+
...checkTokenEnvVars({
|
|
4260
|
+
config: parsed,
|
|
4261
|
+
file,
|
|
4262
|
+
env
|
|
4263
|
+
}),
|
|
4264
|
+
...checkAgainstConfigFileSchema({
|
|
4265
|
+
config: parsed,
|
|
4266
|
+
file
|
|
4267
|
+
})
|
|
4268
|
+
];
|
|
4269
|
+
}
|
|
4270
|
+
/**
|
|
4271
|
+
* A base file and a local file can each be valid in isolation yet merge into
|
|
4272
|
+
* the invalid `{ targets: object, features: array }` state — the same
|
|
4273
|
+
* cross-file rule `ConfigResolver` enforces at generate time.
|
|
4274
|
+
*/
|
|
4275
|
+
function collectMergedConfigDiagnostics({ baseConfig, localConfig, baseFile, localFile }) {
|
|
4276
|
+
if (baseConfig === void 0 || localConfig === void 0) return [];
|
|
4277
|
+
const mergedTargets = localConfig.targets ?? baseConfig.targets;
|
|
4278
|
+
const mergedFeatures = localConfig.features ?? baseConfig.features;
|
|
4279
|
+
if (!isPlainObject(mergedTargets) || mergedFeatures === void 0) return [];
|
|
4280
|
+
if (isPlainObject(baseConfig.targets) && baseConfig.features !== void 0 || isPlainObject(localConfig.targets) && localConfig.features !== void 0) return [];
|
|
4281
|
+
return [{
|
|
4282
|
+
severity: "error",
|
|
4283
|
+
code: "config/targets-features-conflict",
|
|
4284
|
+
file: localFile,
|
|
4285
|
+
message: `Merging '${baseFile}' with '${localFile}' combines object-form 'targets' with 'features', which is invalid.`,
|
|
4286
|
+
hint: "Remove the conflicting field from one of the two files."
|
|
4287
|
+
}];
|
|
4288
|
+
}
|
|
4289
|
+
function severityRank(severity) {
|
|
4290
|
+
return severity === "error" ? 0 : severity === "warning" ? 1 : 2;
|
|
4291
|
+
}
|
|
4292
|
+
/**
|
|
4293
|
+
* Strips control characters (including ANSI escape sequences) so key names and
|
|
4294
|
+
* values copied out of an untrusted config file cannot inject terminal escape
|
|
4295
|
+
* codes into the diagnostic output.
|
|
4296
|
+
*/
|
|
4297
|
+
function stripControlCharacters(text) {
|
|
4298
|
+
return text.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, "");
|
|
4299
|
+
}
|
|
4300
|
+
function formatDiagnostic(diagnostic) {
|
|
4301
|
+
const position = diagnostic.line !== void 0 ? `:${diagnostic.line}${diagnostic.column !== void 0 ? `:${diagnostic.column}` : ""}` : "";
|
|
4302
|
+
const label = diagnostic.severity === "error" ? "✖" : diagnostic.severity === "warning" ? "⚠" : "ℹ";
|
|
4303
|
+
const hint = diagnostic.hint === void 0 ? "" : `\n ↳ ${stripControlCharacters(diagnostic.hint)}`;
|
|
4304
|
+
return `${label} ${stripControlCharacters(diagnostic.file)}${position} [${diagnostic.code}] ${stripControlCharacters(diagnostic.message)}${hint}`;
|
|
4305
|
+
}
|
|
4306
|
+
/**
|
|
4307
|
+
* Re-parses an already-read config file's content for the cross-file checks.
|
|
4308
|
+
* Returns undefined when the content is unparseable or not an object — the
|
|
4309
|
+
* per-file checks have already reported those states.
|
|
4310
|
+
*/
|
|
4311
|
+
function parseConfigObjectForMerge(content) {
|
|
4312
|
+
if (content === void 0) return void 0;
|
|
4313
|
+
const errors = [];
|
|
4314
|
+
const parsed = parse(content, errors, { allowTrailingComma: true });
|
|
4315
|
+
if (errors.length > 0 || !isPlainObject(parsed)) return void 0;
|
|
4316
|
+
return parsed;
|
|
4317
|
+
}
|
|
4318
|
+
/**
|
|
4319
|
+
* Path sanity: an `inputRoot` that does not exist means every command will
|
|
4320
|
+
* fail (or silently read nothing); local config wins, mirroring the merge
|
|
4321
|
+
* order in `ConfigResolver`.
|
|
4322
|
+
*/
|
|
4323
|
+
async function checkInputRootExists({ baseConfig, localConfig, baseFile, localFile }) {
|
|
4324
|
+
const mergedInputRoot = localConfig?.inputRoot ?? baseConfig?.inputRoot;
|
|
4325
|
+
if (typeof mergedInputRoot !== "string" || mergedInputRoot.length === 0) return [];
|
|
4326
|
+
if (await directoryExists(resolve(mergedInputRoot))) return [];
|
|
4327
|
+
return [{
|
|
4328
|
+
severity: "error",
|
|
4329
|
+
code: "config/input-root-not-found",
|
|
4330
|
+
file: localConfig?.inputRoot !== void 0 ? localFile : baseFile,
|
|
4331
|
+
message: `'inputRoot' points at '${mergedInputRoot}', which is not an existing directory.`,
|
|
4332
|
+
hint: "Create the directory or fix the 'inputRoot' path."
|
|
4333
|
+
}];
|
|
4334
|
+
}
|
|
4335
|
+
function reportDiagnostics({ logger, diagnostics }) {
|
|
4336
|
+
if (logger.jsonMode) return;
|
|
4337
|
+
for (const diagnostic of diagnostics) {
|
|
4338
|
+
const formatted = formatDiagnostic(diagnostic);
|
|
4339
|
+
if (diagnostic.severity === "error") logger.error(formatted);
|
|
4340
|
+
else if (diagnostic.severity === "warning") logger.warn(formatted);
|
|
4341
|
+
else logger.info(formatted);
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
/**
|
|
4345
|
+
* `rulesync doctor` — read-only diagnostics for the configuration files.
|
|
4346
|
+
* Never writes; exits non-zero when errors (or, with --strict, warnings) are
|
|
4347
|
+
* found.
|
|
4348
|
+
*/
|
|
4349
|
+
async function doctorCommand(logger, options) {
|
|
4350
|
+
const cwd = process.cwd();
|
|
4351
|
+
const validatedConfigPath = resolvePath(options.config ?? "rulesync.jsonc", cwd);
|
|
4352
|
+
const localConfigPath = join(dirname(validatedConfigPath), RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH);
|
|
4353
|
+
const toDisplayPath = (absolutePath) => {
|
|
4354
|
+
const relativePath = relative(cwd, absolutePath);
|
|
4355
|
+
return relativePath === "" || relativePath.startsWith("..") ? absolutePath : relativePath;
|
|
4356
|
+
};
|
|
4357
|
+
const diagnostics = [];
|
|
4358
|
+
if (!await fileExists(validatedConfigPath)) diagnostics.push({
|
|
4359
|
+
severity: "info",
|
|
4360
|
+
code: "config/no-config-file",
|
|
4361
|
+
file: toDisplayPath(validatedConfigPath),
|
|
4362
|
+
message: "No configuration file found; rulesync will run with built-in defaults.",
|
|
4363
|
+
hint: "Run 'rulesync init' to scaffold one."
|
|
4364
|
+
});
|
|
4365
|
+
const fileContents = /* @__PURE__ */ new Map();
|
|
4366
|
+
for (const filePath of [validatedConfigPath, localConfigPath]) {
|
|
4367
|
+
if (!await fileExists(filePath)) continue;
|
|
4368
|
+
const content = await readFileContent(filePath);
|
|
4369
|
+
fileContents.set(filePath, content);
|
|
4370
|
+
diagnostics.push(...collectConfigFileDiagnostics({
|
|
4371
|
+
file: toDisplayPath(filePath),
|
|
4372
|
+
content
|
|
4373
|
+
}));
|
|
4374
|
+
}
|
|
4375
|
+
const baseConfig = parseConfigObjectForMerge(fileContents.get(validatedConfigPath));
|
|
4376
|
+
const localConfig = parseConfigObjectForMerge(fileContents.get(localConfigPath));
|
|
4377
|
+
diagnostics.push(...collectMergedConfigDiagnostics({
|
|
4378
|
+
baseConfig,
|
|
4379
|
+
localConfig,
|
|
4380
|
+
baseFile: toDisplayPath(validatedConfigPath),
|
|
4381
|
+
localFile: toDisplayPath(localConfigPath)
|
|
4382
|
+
}));
|
|
4383
|
+
diagnostics.push(...await checkInputRootExists({
|
|
4384
|
+
baseConfig,
|
|
4385
|
+
localConfig,
|
|
4386
|
+
baseFile: toDisplayPath(validatedConfigPath),
|
|
4387
|
+
localFile: toDisplayPath(localConfigPath)
|
|
4388
|
+
}));
|
|
4389
|
+
diagnostics.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
|
4390
|
+
const errorCount = diagnostics.filter((d) => d.severity === "error").length;
|
|
4391
|
+
const warningCount = diagnostics.filter((d) => d.severity === "warning").length;
|
|
4392
|
+
const infoCount = diagnostics.filter((d) => d.severity === "info").length;
|
|
4393
|
+
reportDiagnostics({
|
|
4394
|
+
logger,
|
|
4395
|
+
diagnostics
|
|
4396
|
+
});
|
|
4397
|
+
if (logger.jsonMode) {
|
|
4398
|
+
logger.captureData("diagnostics", diagnostics);
|
|
4399
|
+
logger.captureData("summary", {
|
|
4400
|
+
errors: errorCount,
|
|
4401
|
+
warnings: warningCount,
|
|
4402
|
+
infos: infoCount
|
|
4403
|
+
});
|
|
4404
|
+
}
|
|
4405
|
+
const summary = `${errorCount} error(s), ${warningCount} warning(s), ${infoCount} info(s)`;
|
|
4406
|
+
if (errorCount > 0 || options.strict === true && warningCount > 0) throw new CLIError(`Doctor found problems: ${summary}.`, ErrorCodes.DOCTOR_FAILED, 1, {
|
|
4407
|
+
diagnostics,
|
|
4408
|
+
summary: {
|
|
4409
|
+
errors: errorCount,
|
|
4410
|
+
warnings: warningCount,
|
|
4411
|
+
infos: infoCount
|
|
4412
|
+
}
|
|
4413
|
+
});
|
|
4414
|
+
if (warningCount > 0) {
|
|
4415
|
+
logger.warn(`Doctor finished with ${summary}.`);
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4418
|
+
logger.success(`✓ No problems found (${summary}).`);
|
|
4419
|
+
}
|
|
4420
|
+
//#endregion
|
|
3862
4421
|
//#region src/lib/fetch.ts
|
|
3863
4422
|
/**
|
|
3864
4423
|
* Feature to path mapping for filtering (rulesync format)
|
|
@@ -4374,6 +4933,216 @@ async function fetchCommand(logger, options) {
|
|
|
4374
4933
|
throw error;
|
|
4375
4934
|
}
|
|
4376
4935
|
}
|
|
4936
|
+
/**
|
|
4937
|
+
* Coalesces file-system change notifications into debounced, non-overlapping
|
|
4938
|
+
* runs.
|
|
4939
|
+
*
|
|
4940
|
+
* Guarantees:
|
|
4941
|
+
* - At most one `run` is in flight at any time.
|
|
4942
|
+
* - Every notified path is reported to exactly one `run` as a trigger.
|
|
4943
|
+
* - Notifications that arrive while a run is in flight schedule exactly one
|
|
4944
|
+
* follow-up run after it finishes, so a change is never lost and never
|
|
4945
|
+
* causes a run per event.
|
|
4946
|
+
*/
|
|
4947
|
+
var WatchScheduler = class {
|
|
4948
|
+
run;
|
|
4949
|
+
onError;
|
|
4950
|
+
debounceMs;
|
|
4951
|
+
pending = /* @__PURE__ */ new Set();
|
|
4952
|
+
timer;
|
|
4953
|
+
running;
|
|
4954
|
+
closed = false;
|
|
4955
|
+
constructor({ run, onError, debounceMs = 300 }) {
|
|
4956
|
+
this.run = run;
|
|
4957
|
+
this.onError = onError;
|
|
4958
|
+
this.debounceMs = debounceMs;
|
|
4959
|
+
}
|
|
4960
|
+
notify({ path }) {
|
|
4961
|
+
if (this.closed) return;
|
|
4962
|
+
this.pending.add(path);
|
|
4963
|
+
this.schedule();
|
|
4964
|
+
}
|
|
4965
|
+
/**
|
|
4966
|
+
* Stops accepting notifications and waits for an in-flight run to settle.
|
|
4967
|
+
* Pending (not yet started) changes are dropped.
|
|
4968
|
+
*/
|
|
4969
|
+
async close() {
|
|
4970
|
+
this.closed = true;
|
|
4971
|
+
this.clearTimer();
|
|
4972
|
+
this.pending.clear();
|
|
4973
|
+
await this.running;
|
|
4974
|
+
}
|
|
4975
|
+
clearTimer() {
|
|
4976
|
+
if (this.timer !== void 0) {
|
|
4977
|
+
clearTimeout(this.timer);
|
|
4978
|
+
this.timer = void 0;
|
|
4979
|
+
}
|
|
4980
|
+
}
|
|
4981
|
+
schedule() {
|
|
4982
|
+
this.clearTimer();
|
|
4983
|
+
this.timer = setTimeout(() => {
|
|
4984
|
+
this.timer = void 0;
|
|
4985
|
+
this.flush();
|
|
4986
|
+
}, this.debounceMs);
|
|
4987
|
+
}
|
|
4988
|
+
async flush() {
|
|
4989
|
+
if (this.closed || this.running !== void 0 || this.pending.size === 0) return;
|
|
4990
|
+
const triggers = [...this.pending];
|
|
4991
|
+
this.pending.clear();
|
|
4992
|
+
const running = (async () => {
|
|
4993
|
+
try {
|
|
4994
|
+
await this.run({ triggers });
|
|
4995
|
+
} catch (error) {
|
|
4996
|
+
this.onError({
|
|
4997
|
+
error,
|
|
4998
|
+
triggers
|
|
4999
|
+
});
|
|
5000
|
+
}
|
|
5001
|
+
})();
|
|
5002
|
+
this.running = running;
|
|
5003
|
+
await running;
|
|
5004
|
+
this.running = void 0;
|
|
5005
|
+
if (!this.closed && this.pending.size > 0) this.schedule();
|
|
5006
|
+
}
|
|
5007
|
+
};
|
|
5008
|
+
/**
|
|
5009
|
+
* Watches one directory, re-attaching the underlying `fs.watch` if the
|
|
5010
|
+
* directory is deleted and later recreated.
|
|
5011
|
+
*
|
|
5012
|
+
* Without this, a `git checkout` to a branch without `.rulesync/` (or any
|
|
5013
|
+
* tool that replaces the directory rather than its contents) would silently
|
|
5014
|
+
* kill the watcher: the deleted inode emits no further events and no error,
|
|
5015
|
+
* so watch mode would keep running while never regenerating again.
|
|
5016
|
+
*
|
|
5017
|
+
* The first attach is not guarded — a missing directory at startup is a real
|
|
5018
|
+
* configuration error and must surface to the caller.
|
|
5019
|
+
*/
|
|
5020
|
+
function watchTargetWithRearm({ target, onChange, onError, rearmIntervalMs }) {
|
|
5021
|
+
let watcher;
|
|
5022
|
+
let rearmTimer;
|
|
5023
|
+
let closed = false;
|
|
5024
|
+
const attach = () => {
|
|
5025
|
+
const created = watch(target.directory, {
|
|
5026
|
+
recursive: target.recursive,
|
|
5027
|
+
persistent: true
|
|
5028
|
+
}, (_eventType, filename) => {
|
|
5029
|
+
if (filename === null || filename === void 0) {
|
|
5030
|
+
onChange({ path: target.directory });
|
|
5031
|
+
verifyStillWatching();
|
|
5032
|
+
return;
|
|
5033
|
+
}
|
|
5034
|
+
const relativePath = filename.toString();
|
|
5035
|
+
if (target.include && !target.include(relativePath)) {
|
|
5036
|
+
verifyStillWatching();
|
|
5037
|
+
return;
|
|
5038
|
+
}
|
|
5039
|
+
onChange({ path: join(target.directory, relativePath) });
|
|
5040
|
+
verifyStillWatching();
|
|
5041
|
+
});
|
|
5042
|
+
created.on("error", (error) => {
|
|
5043
|
+
onError({
|
|
5044
|
+
error,
|
|
5045
|
+
directory: target.directory
|
|
5046
|
+
});
|
|
5047
|
+
verifyStillWatching();
|
|
5048
|
+
});
|
|
5049
|
+
watcher = created;
|
|
5050
|
+
};
|
|
5051
|
+
const scheduleRearm = () => {
|
|
5052
|
+
if (closed || rearmTimer !== void 0) return;
|
|
5053
|
+
rearmTimer = setInterval(() => {
|
|
5054
|
+
if (closed || !existsSync(target.directory)) return;
|
|
5055
|
+
clearInterval(rearmTimer);
|
|
5056
|
+
rearmTimer = void 0;
|
|
5057
|
+
try {
|
|
5058
|
+
attach();
|
|
5059
|
+
} catch (error) {
|
|
5060
|
+
onError({
|
|
5061
|
+
error,
|
|
5062
|
+
directory: target.directory
|
|
5063
|
+
});
|
|
5064
|
+
scheduleRearm();
|
|
5065
|
+
return;
|
|
5066
|
+
}
|
|
5067
|
+
onChange({ path: target.directory });
|
|
5068
|
+
}, rearmIntervalMs);
|
|
5069
|
+
};
|
|
5070
|
+
const verifyStillWatching = () => {
|
|
5071
|
+
if (closed || watcher === void 0 || existsSync(target.directory)) return;
|
|
5072
|
+
watcher.close();
|
|
5073
|
+
watcher = void 0;
|
|
5074
|
+
scheduleRearm();
|
|
5075
|
+
};
|
|
5076
|
+
attach();
|
|
5077
|
+
return { close: () => {
|
|
5078
|
+
closed = true;
|
|
5079
|
+
if (rearmTimer !== void 0) {
|
|
5080
|
+
clearInterval(rearmTimer);
|
|
5081
|
+
rearmTimer = void 0;
|
|
5082
|
+
}
|
|
5083
|
+
watcher?.close();
|
|
5084
|
+
watcher = void 0;
|
|
5085
|
+
} };
|
|
5086
|
+
}
|
|
5087
|
+
/**
|
|
5088
|
+
* Starts one watcher per target and forwards matching events to `onChange` as
|
|
5089
|
+
* absolute paths. If any target fails to attach, the watchers started so far
|
|
5090
|
+
* are closed before the error propagates, so no descriptor is leaked.
|
|
5091
|
+
*/
|
|
5092
|
+
function watchTargets({ targets, onChange, onError, rearmIntervalMs = 500 }) {
|
|
5093
|
+
const handles = [];
|
|
5094
|
+
const closeAll = () => {
|
|
5095
|
+
for (const handle of handles) handle.close();
|
|
5096
|
+
};
|
|
5097
|
+
try {
|
|
5098
|
+
for (const target of targets) handles.push(watchTargetWithRearm({
|
|
5099
|
+
target,
|
|
5100
|
+
onChange,
|
|
5101
|
+
onError,
|
|
5102
|
+
rearmIntervalMs
|
|
5103
|
+
}));
|
|
5104
|
+
} catch (error) {
|
|
5105
|
+
closeAll();
|
|
5106
|
+
throw error;
|
|
5107
|
+
}
|
|
5108
|
+
return { close: closeAll };
|
|
5109
|
+
}
|
|
5110
|
+
/**
|
|
5111
|
+
* Builds the set of directories watch mode observes: the `.rulesync/` source
|
|
5112
|
+
* tree (recursively) and, filtered down to the configuration files themselves,
|
|
5113
|
+
* the directory holding `rulesync.jsonc`.
|
|
5114
|
+
*
|
|
5115
|
+
* Only input paths are watched. Generated output lives outside `.rulesync/`, so
|
|
5116
|
+
* a regeneration cannot re-trigger the watcher.
|
|
5117
|
+
*/
|
|
5118
|
+
function buildWatchTargets({ inputRoot, configFilePath }) {
|
|
5119
|
+
const configFilePaths = buildConfigFilePaths({ configFilePath });
|
|
5120
|
+
return [{
|
|
5121
|
+
directory: join(inputRoot, RULESYNC_RELATIVE_DIR_PATH),
|
|
5122
|
+
recursive: true
|
|
5123
|
+
}, {
|
|
5124
|
+
directory: dirname(configFilePath),
|
|
5125
|
+
recursive: false,
|
|
5126
|
+
include: (relativePath) => configFilePaths.has(join(dirname(configFilePath), relativePath))
|
|
5127
|
+
}];
|
|
5128
|
+
}
|
|
5129
|
+
/**
|
|
5130
|
+
* The absolute paths of the configuration files watch mode observes: the base
|
|
5131
|
+
* configuration file and the `rulesync.local.jsonc` sitting next to it, which
|
|
5132
|
+
* is exactly what `ConfigResolver` loads.
|
|
5133
|
+
*/
|
|
5134
|
+
function buildConfigFilePaths({ configFilePath }) {
|
|
5135
|
+
return /* @__PURE__ */ new Set([configFilePath, join(dirname(configFilePath), RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH)]);
|
|
5136
|
+
}
|
|
5137
|
+
/**
|
|
5138
|
+
* Renders trigger paths relative to `baseDir` for logging, truncating long
|
|
5139
|
+
* bursts so a `git checkout` does not flood the terminal.
|
|
5140
|
+
*/
|
|
5141
|
+
function formatTriggerPaths({ triggers, baseDir, max = 5 }) {
|
|
5142
|
+
const displayed = triggers.slice(0, max).map((trigger) => relative(baseDir, trigger) || trigger);
|
|
5143
|
+
const remaining = triggers.length - displayed.length;
|
|
5144
|
+
return remaining > 0 ? `${displayed.join(", ")} (+${remaining} more)` : displayed.join(", ");
|
|
5145
|
+
}
|
|
4377
5146
|
//#endregion
|
|
4378
5147
|
//#region src/cli/commands/generate.ts
|
|
4379
5148
|
/**
|
|
@@ -4463,7 +5232,20 @@ function buildSummaryParts(result) {
|
|
|
4463
5232
|
return parts;
|
|
4464
5233
|
}
|
|
4465
5234
|
async function generateCommand(logger, options) {
|
|
4466
|
-
|
|
5235
|
+
if (options.watch) {
|
|
5236
|
+
await generateWatchCommand(logger, options);
|
|
5237
|
+
return;
|
|
5238
|
+
}
|
|
5239
|
+
await generateOnce(logger, options);
|
|
5240
|
+
}
|
|
5241
|
+
/**
|
|
5242
|
+
* Runs one generation. `resolvedConfig` lets a caller that already resolved
|
|
5243
|
+
* the configuration (watch mode's startup validation) reuse it instead of
|
|
5244
|
+
* paying for a second resolution — and, more importantly, instead of emitting
|
|
5245
|
+
* the resolver's warnings twice.
|
|
5246
|
+
*/
|
|
5247
|
+
async function generateOnce(logger, options, { resolvedConfig } = {}) {
|
|
5248
|
+
const config = resolvedConfig ?? await ConfigResolver.resolve(options, { logger });
|
|
4467
5249
|
const check = config.getCheck();
|
|
4468
5250
|
const isPreview = config.isPreviewMode();
|
|
4469
5251
|
const modePrefix = isPreview ? "[DRY RUN]" : "";
|
|
@@ -4558,6 +5340,76 @@ async function generateCommand(logger, options) {
|
|
|
4558
5340
|
if (isPreview) logger.info(`${modePrefix} Would write ${totalGenerated} file(s) total (${parts.join(" + ")})`);
|
|
4559
5341
|
else logger.success(`🎉 All done! Written ${totalGenerated} file(s) total (${parts.join(" + ")})`);
|
|
4560
5342
|
}
|
|
5343
|
+
/**
|
|
5344
|
+
* Rejects flag combinations that contradict a long-running watch: `--check`
|
|
5345
|
+
* and `--dry-run` are one-shot verification modes (the former is meant to exit
|
|
5346
|
+
* non-zero), and `--json` buffers a single result document until the command
|
|
5347
|
+
* returns, which never happens while watching.
|
|
5348
|
+
*/
|
|
5349
|
+
function assertWatchModeCompatible({ isCheck, isDryRun, isJsonMode }) {
|
|
5350
|
+
const conflicts = [
|
|
5351
|
+
isCheck ? "--check" : void 0,
|
|
5352
|
+
isDryRun ? "--dry-run" : void 0,
|
|
5353
|
+
isJsonMode ? "--json" : void 0
|
|
5354
|
+
].filter((flag) => flag !== void 0);
|
|
5355
|
+
if (conflicts.length > 0) throw new CLIError(`--watch cannot be combined with ${conflicts.join(", ")}.`, ErrorCodes.VALIDATION_FAILED);
|
|
5356
|
+
}
|
|
5357
|
+
async function generateWatchCommand(logger, options) {
|
|
5358
|
+
const config = await ConfigResolver.resolve(options, { logger });
|
|
5359
|
+
assertWatchModeCompatible({
|
|
5360
|
+
isCheck: config.getCheck(),
|
|
5361
|
+
isDryRun: config.getDryRun(),
|
|
5362
|
+
isJsonMode: logger.jsonMode
|
|
5363
|
+
});
|
|
5364
|
+
const inputRoot = config.getInputRoot();
|
|
5365
|
+
const configFilePath = config.getConfigFilePath();
|
|
5366
|
+
const configFilePaths = buildConfigFilePaths({ configFilePath });
|
|
5367
|
+
await generateOnce(logger, options, { resolvedConfig: config });
|
|
5368
|
+
const targets = buildWatchTargets({
|
|
5369
|
+
inputRoot,
|
|
5370
|
+
configFilePath
|
|
5371
|
+
});
|
|
5372
|
+
const scheduler = new WatchScheduler({
|
|
5373
|
+
run: async ({ triggers }) => {
|
|
5374
|
+
logger.info(`\nChange detected: ${formatTriggerPaths({
|
|
5375
|
+
triggers,
|
|
5376
|
+
baseDir: inputRoot
|
|
5377
|
+
})}`);
|
|
5378
|
+
if (triggers.some((trigger) => configFilePaths.has(trigger))) logger.warn("Configuration file changed. The set of watched paths is fixed at startup — restart 'rulesync generate --watch' if you changed 'inputRoot' or the configuration file location.");
|
|
5379
|
+
await generateOnce(logger, options);
|
|
5380
|
+
},
|
|
5381
|
+
onError: ({ error }) => {
|
|
5382
|
+
logger.error(`Generation failed: ${formatError(error)}`);
|
|
5383
|
+
logger.info("Still watching for changes...");
|
|
5384
|
+
}
|
|
5385
|
+
});
|
|
5386
|
+
const handle = watchTargets({
|
|
5387
|
+
targets,
|
|
5388
|
+
onChange: ({ path }) => {
|
|
5389
|
+
scheduler.notify({ path });
|
|
5390
|
+
},
|
|
5391
|
+
onError: ({ error, directory }) => {
|
|
5392
|
+
logger.error(`Watch error on ${directory}: ${formatError(error)}`);
|
|
5393
|
+
}
|
|
5394
|
+
});
|
|
5395
|
+
logger.info(`\nWatching for changes in:\n${targets.map((target) => ` ${target.directory}`).join("\n")}`);
|
|
5396
|
+
logger.info("Press Ctrl+C to stop.");
|
|
5397
|
+
await new Promise((resolveShutdown) => {
|
|
5398
|
+
const shutdown = () => {
|
|
5399
|
+
process.off("SIGINT", shutdown);
|
|
5400
|
+
process.off("SIGTERM", shutdown);
|
|
5401
|
+
handle.close();
|
|
5402
|
+
scheduler.close().catch((error) => {
|
|
5403
|
+
logger.error(`Failed to stop the watcher cleanly: ${formatError(error)}`);
|
|
5404
|
+
}).finally(() => {
|
|
5405
|
+
logger.info("\nStopped watching.");
|
|
5406
|
+
resolveShutdown();
|
|
5407
|
+
});
|
|
5408
|
+
};
|
|
5409
|
+
process.once("SIGINT", shutdown);
|
|
5410
|
+
process.once("SIGTERM", shutdown);
|
|
5411
|
+
});
|
|
5412
|
+
}
|
|
4561
5413
|
//#endregion
|
|
4562
5414
|
//#region src/cli/commands/gitignore-derive.ts
|
|
4563
5415
|
const TARGETS_NOT_DERIVED = /* @__PURE__ */ new Set([
|
|
@@ -9002,7 +9854,7 @@ function wrapCommand$1({ name, errorCode, handler, getVersion, loggerFactory = c
|
|
|
9002
9854
|
}
|
|
9003
9855
|
//#endregion
|
|
9004
9856
|
//#region src/cli/program.ts
|
|
9005
|
-
const getVersion = () => "16.
|
|
9857
|
+
const getVersion = () => "16.3.0";
|
|
9006
9858
|
const FEATURES_HELP = `${ALL_FEATURES.join(",")}; ignore is deprecated, use permissions`;
|
|
9007
9859
|
function wrapCommand(name, errorCode, handler) {
|
|
9008
9860
|
return wrapCommand$1({
|
|
@@ -9071,9 +9923,12 @@ function createProgram() {
|
|
|
9071
9923
|
silent: options.silent
|
|
9072
9924
|
});
|
|
9073
9925
|
}));
|
|
9074
|
-
program.command("generate").description("Generate configuration files for AI tools").option("-t, --targets <tools>", "Comma-separated list of tools to generate for (e.g., 'copilot,cursor,cline' or '*' for all)", parseCommaSeparatedList).option("-f, --features <features>", `Comma-separated list of features to generate (${FEATURES_HELP}) or '*' for all`, parseCommaSeparatedList).option("--delete", "Delete all existing files in output directories before generating").option("-o, --output-roots <paths>", "Output root directories to generate files into (comma-separated for multiple paths)", parseCommaSeparatedList).option("-V, --verbose", "Verbose output").option("-s, --silent", "Suppress all output").option("-c, --config <path>", "Path to configuration file").option("-g, --global", "Generate for global(user scope) configuration files").option("--simulate-commands", "Generate simulated commands. This feature is only available for copilot, cursor and codexcli.").option("--simulate-subagents", "Generate simulated subagents. This feature is only available for copilot and codexcli.").option("--simulate-skills", "Generate simulated skills. This feature is only available for copilot, cursor and codexcli.").option("--input-root <path>", "Path to the directory containing .rulesync/ (parent of .rulesync/)").option("--dry-run", "Dry run: show changes without writing files").option("--check", "Check if files are up to date (exits with code 1 if changes needed)").action(wrapCommand("generate", "GENERATION_FAILED", async (logger, options) => {
|
|
9926
|
+
program.command("generate").description("Generate configuration files for AI tools").option("-t, --targets <tools>", "Comma-separated list of tools to generate for (e.g., 'copilot,cursor,cline' or '*' for all)", parseCommaSeparatedList).option("-f, --features <features>", `Comma-separated list of features to generate (${FEATURES_HELP}) or '*' for all`, parseCommaSeparatedList).option("--delete", "Delete all existing files in output directories before generating").option("-o, --output-roots <paths>", "Output root directories to generate files into (comma-separated for multiple paths)", parseCommaSeparatedList).option("-V, --verbose", "Verbose output").option("-s, --silent", "Suppress all output").option("-c, --config <path>", "Path to configuration file").option("-g, --global", "Generate for global(user scope) configuration files").option("--simulate-commands", "Generate simulated commands. This feature is only available for copilot, cursor and codexcli.").option("--simulate-subagents", "Generate simulated subagents. This feature is only available for copilot and codexcli.").option("--simulate-skills", "Generate simulated skills. This feature is only available for copilot, cursor and codexcli.").option("--input-root <path>", "Path to the directory containing .rulesync/ (parent of .rulesync/)").option("--dry-run", "Dry run: show changes without writing files").option("--check", "Check if files are up to date (exits with code 1 if changes needed)").option("-w, --watch", "Keep running and regenerate whenever rulesync source files change (cannot be combined with --check, --dry-run or --json)").action(wrapCommand("generate", "GENERATION_FAILED", async (logger, options) => {
|
|
9075
9927
|
await generateCommand(logger, options);
|
|
9076
9928
|
}));
|
|
9929
|
+
program.command("doctor").description("Diagnose the rulesync configuration for common problems (read-only, never writes files)").option("-c, --config <path>", "Path to configuration file").option("--strict", "Treat warnings as errors (exit with code 1)").option("-V, --verbose", "Verbose output").option("-s, --silent", "Suppress all output").action(wrapCommand("doctor", "DOCTOR_FAILED", async (logger, options) => {
|
|
9930
|
+
await doctorCommand(logger, options);
|
|
9931
|
+
}));
|
|
9077
9932
|
program.command("update").description("Update rulesync to the latest version").option("--check", "Check for updates without installing").option("--force", "Force update even if already at latest version").option("--token <token>", "GitHub token for API access").option("-V, --verbose", "Verbose output").option("-s, --silent", "Suppress all output").action(wrapCommand("update", "UPDATE_FAILED", async (logger, options) => {
|
|
9078
9933
|
await updateCommand(logger, version, options);
|
|
9079
9934
|
}));
|