@jenga-ai/agent 1.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/LICENSE +201 -0
- package/README.md +340 -0
- package/agents/ai_engineer.md +113 -0
- package/agents/developer.md +236 -0
- package/agents/scrum-master.md +349 -0
- package/agents/scrutiny-agent.md +137 -0
- package/agents/solution-assessor.md +185 -0
- package/agents/tester.md +339 -0
- package/bin/jenga.js +70 -0
- package/hooks/copilot_session_end.sh +29 -0
- package/hooks/on_session_end.sh +238 -0
- package/hooks/prompt_router.sh +11 -0
- package/hooks/prompt_router_helper.js +52 -0
- package/hooks/session_end_helper.js +29 -0
- package/hooks/session_end_watcher.sh +24 -0
- package/lib/commands/attach.js +47 -0
- package/lib/commands/init.js +207 -0
- package/lib/commands/start.js +16 -0
- package/lib/commands/status.js +53 -0
- package/lib/config-schema.js +72 -0
- package/lib/inject-settings.js +61 -0
- package/lib/mirror.js +244 -0
- package/lib/resolve-project-dir.sh +47 -0
- package/mcp/execute-ticket/index.js +10 -0
- package/mcp/execute-ticket/package.json +5 -0
- package/mcp/help/index.js +79 -0
- package/mcp/help/package.json +14 -0
- package/mcp/router/README.md +19 -0
- package/mcp/router/embedder.js +23 -0
- package/mcp/router/index.js +204 -0
- package/mcp/router/matcher.js +87 -0
- package/mcp/router/package-lock.json +1048 -0
- package/mcp/router/package.json +11 -0
- package/mcp/router/skill-index.js +104 -0
- package/package.json +47 -0
- package/scripts/board_resolver.sh +46 -0
- package/scripts/e25_s01_extract_board_graph.py +292 -0
- package/scripts/e25_s01_generate_synthetic_board.py +90 -0
- package/scripts/measurement-10x.json +50 -0
- package/scripts/measurement-10x.txt +4 -0
- package/scripts/measurement-real.json +50 -0
- package/scripts/measurement-real.txt +4 -0
- package/scripts/postinstall.js +165 -0
- package/scripts/todo_cleanup.sh +22 -0
- package/scripts/todo_manager.sh +86 -0
- package/scripts/validate-board.sh +190 -0
- package/scripts/validate-story-format.sh +53 -0
- package/skills/brainstorm/SKILL.md +47 -0
- package/skills/btw/SKILL.md +42 -0
- package/skills/commit/SKILL.md +29 -0
- package/skills/commit/assets/user_instructions_template.md +22 -0
- package/skills/continue/SKILL.md +29 -0
- package/skills/convert/SKILL.md +124 -0
- package/skills/convert/convert_cli.py +235 -0
- package/skills/convert/tests/sample.csv +4 -0
- package/skills/convert/tests/sample.json +5 -0
- package/skills/convert/tests/sample.jsonl +3 -0
- package/skills/convert/tests/sample.yaml +18 -0
- package/skills/convert/tests/sample_obj.csv +2 -0
- package/skills/convert/tests/sample_obj.json +9 -0
- package/skills/deep-dive/SKILL.md +167 -0
- package/skills/do/SKILL.md +88 -0
- package/skills/do/assets/sender_template.json +12 -0
- package/skills/doc/SKILL.md +314 -0
- package/skills/doc/assets/path-objectives.yaml +38 -0
- package/skills/doc-sync/SKILL.md +167 -0
- package/skills/doc-sync/assets/default_excludes.txt +21 -0
- package/skills/doc-sync/assets/doc_targets.md +14 -0
- package/skills/dooo/SKILL.md +60 -0
- package/skills/error/SKILL.md +29 -0
- package/skills/evaluate/SKILL.md +45 -0
- package/skills/evaluate/assets/evaluation_invokation_template.yml +3 -0
- package/skills/evaluate/assets/evaluation_rapport_template.md +24 -0
- package/skills/examplify/SKILL.md +42 -0
- package/skills/help/SKILL.md +36 -0
- package/skills/improve/SKILL.md +55 -0
- package/skills/index/scripts/board-index +4 -0
- package/skills/index/scripts/board_index.py +615 -0
- package/skills/index/scripts/smoke_test.sh +86 -0
- package/skills/init/SKILL.md +44 -0
- package/skills/init/assets/.gitignore_template +15 -0
- package/skills/init/assets/PROJECT_SUMMARY_template.md +13 -0
- package/skills/init/assets/directory_structure.txt +13 -0
- package/skills/init/assets/test-config_template.json +4 -0
- package/skills/init/assets/workflow_template.json +30 -0
- package/skills/init/scripts/init.sh +48 -0
- package/skills/jbp/SKILL.md +25 -0
- package/skills/jenga/SKILL.md +68 -0
- package/skills/lgtm/SKILL.md +21 -0
- package/skills/mirror-public/SKILL.md +237 -0
- package/skills/mirror-public/assets/config.json +5 -0
- package/skills/mirror-public/scripts/mirror.sh +374 -0
- package/skills/pi-plan/SKILL.md +62 -0
- package/skills/pi-plan/assets/epic.json +7 -0
- package/skills/pi-plan/assets/story_template.md +18 -0
- package/skills/proceed/SKILL.md +29 -0
- package/skills/publish/SKILL.md +351 -0
- package/skills/publish/adapters/droplet.md +200 -0
- package/skills/publish/adapters/mobile-ios.md +114 -0
- package/skills/publish/adapters/npm-ci.md +223 -0
- package/skills/publish/adapters/npm.md +121 -0
- package/skills/publish/assets/ExportOptions.plist.template +19 -0
- package/skills/publish/assets/ci-contract.md +111 -0
- package/skills/publish/assets/ownership-matrix.md +17 -0
- package/skills/publish/assets/publish.example.json +85 -0
- package/skills/publish/assets/publish.example.npm-ci.json +40 -0
- package/skills/publish/assets/publish.example.npm.json +41 -0
- package/skills/publish/assets/secrets-guide.md +104 -0
- package/skills/publish/schemas/fixtures/npm-ci-minimal.json +17 -0
- package/skills/publish/schemas/fixtures/npm-ci-with-empty-secrets.json +18 -0
- package/skills/publish/schemas/fixtures/npm-ci-with-workflow-path.json +18 -0
- package/skills/publish/schemas/publish.schema.json +428 -0
- package/skills/publish/scripts/check_target_config.sh +96 -0
- package/skills/publish/scripts/droplet_pipeline.sh +208 -0
- package/skills/publish/scripts/generate_release_notes.sh +200 -0
- package/skills/publish/scripts/ios_pipeline.sh +486 -0
- package/skills/publish/scripts/npm_ci_pipeline.sh +225 -0
- package/skills/publish/scripts/npm_pipeline.sh +249 -0
- package/skills/publish/scripts/publish_common.sh +253 -0
- package/skills/publish/scripts/publish_deploy.sh +538 -0
- package/skills/publish/scripts/reconcile_tags.sh +135 -0
- package/skills/publish/scripts/run_gates.sh +616 -0
- package/skills/publish/scripts/setup_wizard.sh +394 -0
- package/skills/publish/scripts/show_history.sh +95 -0
- package/skills/publish/scripts/suggest_semver_bump.sh +105 -0
- package/skills/publish/scripts/validate_config.sh +163 -0
- package/skills/publish/scripts/validate_droplet_env.sh +45 -0
- package/skills/publish/scripts/validate_ios_env.sh +68 -0
- package/skills/publish/scripts/validate_npm_ci_env.sh +71 -0
- package/skills/publish/scripts/validate_npm_env.sh +22 -0
- package/skills/publish/scripts/write_ledger_entry.sh +126 -0
- package/skills/publish/wizards/droplet.md +275 -0
- package/skills/publish/wizards/mobile-ios.md +157 -0
- package/skills/publish/wizards/npm-ci.md +240 -0
- package/skills/publish/wizards/npm.md +224 -0
- package/skills/reconcile/SKILL.md +93 -0
- package/skills/reconcile/assets/report_format.md +44 -0
- package/skills/reconcile-origin/SKILL.md +75 -0
- package/skills/reconcile-origin/scripts/reconcile-origin.sh +372 -0
- package/skills/redo/SKILL.md +70 -0
- package/skills/route/SKILL.md +180 -0
- package/skills/self-sync/SKILL.md +73 -0
- package/skills/self-sync/scripts/run.js +136 -0
- package/skills/skillify/SKILL.md +68 -0
- package/skills/skillify/assets/init-new/SKILL.md +35 -0
- package/skills/skillify/assets/init-new/assets/.gitignore_template +15 -0
- package/skills/skillify/assets/init-new/assets/PROJECT_SUMMARY_template.md +13 -0
- package/skills/skillify/assets/init-new/assets/directory_structure.txt +10 -0
- package/skills/skillify/assets/init-new/assets/test-config_template.json +4 -0
- package/skills/skillify/assets/init-new/assets/workflow_template.json +17 -0
- package/skills/skillify/assets/init-new/scripts/init.sh +48 -0
- package/skills/skillify/assets/init-old/SKILL.md +124 -0
- package/skills/spinoff/SKILL.md +48 -0
- package/skills/status/SKILL.md +33 -0
- package/skills/status/assets/output_format.md +41 -0
- package/skills/todo/SKILL.md +46 -0
- package/skills/todo/assets/todo_handoff_template.md +22 -0
- package/skills/todo/assets/todo_template.md +3 -0
- package/skills/train/SKILL.md +116 -0
- package/skills/train/assets/dashboard-templates/classifiers.html +106 -0
- package/skills/train/assets/dashboard-templates/nlp.html +102 -0
- package/skills/train/assets/dashboard-templates/transformers.html +98 -0
- package/skills/train/assets/results-parsers/__init__.py +9 -0
- package/skills/train/assets/results-parsers/classifiers.py +84 -0
- package/skills/train/assets/results-parsers/nlp.py +88 -0
- package/skills/train/assets/results-parsers/reporter.py +154 -0
- package/skills/train/assets/results-parsers/transformers.py +120 -0
- package/skills/train/train_cli.py +786 -0
- package/templates/EXECUTION_PLAN_TEMPLATE.md +43 -0
- package/templates/EXECUTION_SUMMARY_TEMPLATE.md +50 -0
- package/templates/JENGA_CONFIG_TEMPLATE.json +23 -0
- package/templates/PROBLEM_RAPPORT_TEMPLATE.md +88 -0
- package/templates/SCRUM_BOARD_SCHEMA.md +311 -0
- package/templates/SKILL.md +16 -0
- package/templates/SKILL_TEMPLATE.md +28 -0
- package/templates/USER_INSTRUCTIONS_TEMPLATE.md +22 -0
- package/templates/copilot-instructions.md.tpl +55 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { createInterface } from "readline";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, readdirSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { validateConfig } from "../config-schema.js";
|
|
6
|
+
import { injectSettings } from "../inject-settings.js";
|
|
7
|
+
|
|
8
|
+
const CONFIG_FILE = "jenga.cli.json";
|
|
9
|
+
|
|
10
|
+
// This file lives at <package>/lib/commands/init.js — two levels up is the
|
|
11
|
+
// installed jenga-agent package root, which holds templates/ and other
|
|
12
|
+
// framework assets sourced directly from node_modules.
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const PACKAGE_ROOT = join(__dirname, "..", "..");
|
|
15
|
+
|
|
16
|
+
async function prompt(rl, question, defaultValue) {
|
|
17
|
+
return new Promise(resolve => {
|
|
18
|
+
const hint = defaultValue !== undefined ? ` (default: ${defaultValue})` : "";
|
|
19
|
+
rl.question(`${question}${hint}: `, answer => {
|
|
20
|
+
resolve(answer.trim() || String(defaultValue ?? ""));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runInit(args, projectRoot = process.cwd()) {
|
|
26
|
+
const configPath = join(projectRoot, CONFIG_FILE);
|
|
27
|
+
|
|
28
|
+
// Load existing config as defaults if re-running
|
|
29
|
+
let existing = {};
|
|
30
|
+
if (existsSync(configPath)) {
|
|
31
|
+
try {
|
|
32
|
+
existing = JSON.parse(readFileSync(configPath, "utf8"));
|
|
33
|
+
console.log(`Found existing ${CONFIG_FILE} — showing current values as defaults.\n`);
|
|
34
|
+
} catch {
|
|
35
|
+
console.warn(`Warning: Could not parse existing ${CONFIG_FILE}. Starting fresh.\n`);
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
console.log("Welcome to Jenga! Let's set up your configuration.\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// 1. Agent target (supports comma-separated for multiple targets)
|
|
45
|
+
const validTargets = ["claude", "copilot", "custom"];
|
|
46
|
+
const existingTargets = existing.agentTarget
|
|
47
|
+
? (Array.isArray(existing.agentTarget) ? existing.agentTarget.join(", ") : existing.agentTarget)
|
|
48
|
+
: "claude";
|
|
49
|
+
console.log("Agent target options: claude, copilot, custom (comma-separate for multiple, e.g. claude, copilot)");
|
|
50
|
+
let agentTargetRaw;
|
|
51
|
+
let parsedTargets;
|
|
52
|
+
do {
|
|
53
|
+
agentTargetRaw = await prompt(rl, "Agent target", existingTargets);
|
|
54
|
+
parsedTargets = agentTargetRaw.split(",").map(s => s.trim()).filter(Boolean);
|
|
55
|
+
if (!parsedTargets.every(t => validTargets.includes(t))) {
|
|
56
|
+
console.log(' Please enter valid targets: claude, copilot, custom');
|
|
57
|
+
parsedTargets = null;
|
|
58
|
+
}
|
|
59
|
+
} while (!parsedTargets);
|
|
60
|
+
const agentTarget = parsedTargets.length === 1 ? parsedTargets[0] : parsedTargets;
|
|
61
|
+
|
|
62
|
+
// 2. Skills path — postinstall mirrors skills/ into .claude/skills/ (Claude)
|
|
63
|
+
// and .agents/skills/ (Copilot / custom). Default reflects the chosen target(s):
|
|
64
|
+
// a single string for one target, an array for multi-target installs.
|
|
65
|
+
const skillPathForTarget = (t) => t === "claude" ? ".claude/skills/" : ".agents/skills/";
|
|
66
|
+
const derivedDefault = (() => {
|
|
67
|
+
const unique = [...new Set(parsedTargets.map(skillPathForTarget))];
|
|
68
|
+
return unique.length === 1 ? unique[0] : unique;
|
|
69
|
+
})();
|
|
70
|
+
const existingSkillsPath = existing.skillsPath ?? derivedDefault;
|
|
71
|
+
const skillsDefaultHint = Array.isArray(existingSkillsPath)
|
|
72
|
+
? existingSkillsPath.join(", ")
|
|
73
|
+
: existingSkillsPath;
|
|
74
|
+
const skillsRaw = await prompt(rl, "Skills directory path(s) (comma-separate for multiple)", skillsDefaultHint);
|
|
75
|
+
const skillsParsed = skillsRaw.split(",").map(s => s.trim()).filter(Boolean);
|
|
76
|
+
const skillsPath = skillsParsed.length === 1 ? skillsParsed[0] : skillsParsed;
|
|
77
|
+
|
|
78
|
+
// 3. Match threshold
|
|
79
|
+
const thresholdDefault = existing.matchThreshold ?? 0.75;
|
|
80
|
+
let matchThreshold;
|
|
81
|
+
do {
|
|
82
|
+
const raw = await prompt(rl, "Match confidence threshold (0–1)", thresholdDefault);
|
|
83
|
+
matchThreshold = parseFloat(raw);
|
|
84
|
+
} while (isNaN(matchThreshold) || matchThreshold < 0 || matchThreshold > 1);
|
|
85
|
+
|
|
86
|
+
// 4. Session timeout (minutes in wizard → ms in config)
|
|
87
|
+
const timeoutMinutesDefault = existing.sessionTimeout ? existing.sessionTimeout / 60000 : 5;
|
|
88
|
+
let sessionTimeoutMinutes;
|
|
89
|
+
do {
|
|
90
|
+
const raw = await prompt(rl, "Session timeout (minutes)", timeoutMinutesDefault);
|
|
91
|
+
sessionTimeoutMinutes = parseFloat(raw);
|
|
92
|
+
} while (isNaN(sessionTimeoutMinutes) || sessionTimeoutMinutes <= 0);
|
|
93
|
+
|
|
94
|
+
const config = {
|
|
95
|
+
agentTarget,
|
|
96
|
+
skillsPath,
|
|
97
|
+
matchThreshold,
|
|
98
|
+
sessionTimeout: Math.round(sessionTimeoutMinutes * 60000),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
102
|
+
|
|
103
|
+
// Assert the written config passes schema validation
|
|
104
|
+
const validation = validateConfig(config);
|
|
105
|
+
if (!validation.valid) {
|
|
106
|
+
throw new Error(`Config validation failed: ${validation.error}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`\n✓ Configuration written to ${CONFIG_FILE}`);
|
|
110
|
+
|
|
111
|
+
// Ensure .jenga/ is in .gitignore (idempotent)
|
|
112
|
+
const gitignorePath = join(projectRoot, ".gitignore");
|
|
113
|
+
const gitignoreEntry = ".jenga/";
|
|
114
|
+
let gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
|
|
115
|
+
if (!gitignoreContent.split("\n").some(line => line.trim() === gitignoreEntry)) {
|
|
116
|
+
const prefix = gitignoreContent.length > 0 && !gitignoreContent.endsWith("\n") ? "\n" : "";
|
|
117
|
+
appendFileSync(gitignorePath, `${prefix}${gitignoreEntry}\n`, "utf8");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await injectSettings(projectRoot);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.warn(`Warning: Could not register hook in settings.json — ${e.message}`);
|
|
124
|
+
console.warn("You can register it manually later by running jenga init again.");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Generate .github/copilot-instructions.md from template.
|
|
128
|
+
// Templates ship inside the jenga-agent package (node_modules/jenga-agent/templates/).
|
|
129
|
+
// Fall back to a bare `templates/` at the project root only for the dev-repo case.
|
|
130
|
+
try {
|
|
131
|
+
const tplCandidates = [
|
|
132
|
+
join(PACKAGE_ROOT, "templates", "copilot-instructions.md.tpl"),
|
|
133
|
+
join(projectRoot, "templates", "copilot-instructions.md.tpl"),
|
|
134
|
+
];
|
|
135
|
+
const tplPath = tplCandidates.find(existsSync);
|
|
136
|
+
if (tplPath) {
|
|
137
|
+
const tpl = readFileSync(tplPath, "utf8");
|
|
138
|
+
|
|
139
|
+
// Build skill list from skillsPath. skillsPath may be a string or an
|
|
140
|
+
// array (multi-target installs); pick the first existing directory —
|
|
141
|
+
// mirrored copies hold identical content.
|
|
142
|
+
const skillsPathList = Array.isArray(config.skillsPath) ? config.skillsPath : [config.skillsPath];
|
|
143
|
+
const skillsDir = skillsPathList
|
|
144
|
+
.map(p => join(projectRoot, p))
|
|
145
|
+
.find(existsSync);
|
|
146
|
+
let skillLines = [];
|
|
147
|
+
if (skillsDir) {
|
|
148
|
+
for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
|
|
149
|
+
if (!entry.isDirectory()) continue;
|
|
150
|
+
const skillMdPath = join(skillsDir, entry.name, "SKILL.md");
|
|
151
|
+
let description = "";
|
|
152
|
+
if (existsSync(skillMdPath)) {
|
|
153
|
+
const content = readFileSync(skillMdPath, "utf8");
|
|
154
|
+
const match = content.match(/^description:\s*(.+)$/m);
|
|
155
|
+
if (match) description = match[1].trim();
|
|
156
|
+
}
|
|
157
|
+
skillLines.push(`- **${entry.name}**${description ? `: ${description}` : ""}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const skillList = skillLines.length > 0 ? skillLines.join("\n") : "_No skills found._";
|
|
161
|
+
const rendered = tpl.replace("{{SKILL_LIST}}", skillList);
|
|
162
|
+
|
|
163
|
+
const githubDir = join(projectRoot, ".github");
|
|
164
|
+
const copilotInstructionsPath = join(githubDir, "copilot-instructions.md");
|
|
165
|
+
|
|
166
|
+
if (!existsSync(githubDir)) mkdirSync(githubDir, { recursive: true });
|
|
167
|
+
|
|
168
|
+
if (!existsSync(copilotInstructionsPath)) {
|
|
169
|
+
writeFileSync(copilotInstructionsPath, rendered, "utf8");
|
|
170
|
+
} else {
|
|
171
|
+
// Replace only the JENGA block; preserve content outside the markers
|
|
172
|
+
const existing = readFileSync(copilotInstructionsPath, "utf8");
|
|
173
|
+
const startMarker = "<!-- JENGA:START -->";
|
|
174
|
+
const endMarker = "<!-- JENGA:END -->";
|
|
175
|
+
const startIdx = existing.indexOf(startMarker);
|
|
176
|
+
const endIdx = existing.indexOf(endMarker);
|
|
177
|
+
|
|
178
|
+
// Extract the new JENGA block from rendered template
|
|
179
|
+
const renderedStart = rendered.indexOf(startMarker);
|
|
180
|
+
const renderedEnd = rendered.indexOf(endMarker);
|
|
181
|
+
const newBlock = rendered.slice(renderedStart, renderedEnd + endMarker.length);
|
|
182
|
+
|
|
183
|
+
let updated;
|
|
184
|
+
if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
|
|
185
|
+
updated =
|
|
186
|
+
existing.slice(0, startIdx) +
|
|
187
|
+
newBlock +
|
|
188
|
+
existing.slice(endIdx + endMarker.length);
|
|
189
|
+
} else {
|
|
190
|
+
// No existing markers — append the block
|
|
191
|
+
updated = existing + (existing.endsWith("\n") ? "" : "\n") + newBlock + "\n";
|
|
192
|
+
}
|
|
193
|
+
writeFileSync(copilotInstructionsPath, updated, "utf8");
|
|
194
|
+
}
|
|
195
|
+
console.log("✓ .github/copilot-instructions.md written");
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.warn(`Warning: Could not write .github/copilot-instructions.md — ${e.message}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log('\nRun `jenga start` to start the router.');
|
|
202
|
+
|
|
203
|
+
return config;
|
|
204
|
+
} finally {
|
|
205
|
+
rl.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { join, dirname } from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const projectRoot = join(__dirname, "..", "..");
|
|
6
|
+
|
|
7
|
+
export async function runStart(args, root = projectRoot) {
|
|
8
|
+
console.log(
|
|
9
|
+
"The Jenga Router is now started automatically by your AI client (e.g. Claude) when it opens a session.\n" +
|
|
10
|
+
"\n" +
|
|
11
|
+
"To set this up for your project, run:\n" +
|
|
12
|
+
" jenga attach\n" +
|
|
13
|
+
"\n" +
|
|
14
|
+
"Then open a new AI session in this project — the router will start automatically via stdio."
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const projectRoot = join(__dirname, "..", "..");
|
|
7
|
+
|
|
8
|
+
function formatDuration(ms) {
|
|
9
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
10
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
11
|
+
const seconds = totalSeconds % 60;
|
|
12
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
13
|
+
return `${seconds}s`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readSessionFile() {
|
|
17
|
+
const sessionFile = join(process.cwd(), ".jenga", "session.json");
|
|
18
|
+
try {
|
|
19
|
+
if (!existsSync(sessionFile)) return { skill: null, startedAt: null };
|
|
20
|
+
return JSON.parse(readFileSync(sessionFile, "utf8"));
|
|
21
|
+
} catch {
|
|
22
|
+
return { skill: null, startedAt: null };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runStatus(args, root = projectRoot) {
|
|
27
|
+
const pidFile = join(process.cwd(), ".jenga", "router.pid");
|
|
28
|
+
|
|
29
|
+
// Check if router is running
|
|
30
|
+
if (!existsSync(pidFile)) {
|
|
31
|
+
console.log("Router is not running. Run `jenga start` to start it.");
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let pid;
|
|
36
|
+
try {
|
|
37
|
+
pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10);
|
|
38
|
+
process.kill(pid, 0); // throws if not alive
|
|
39
|
+
} catch {
|
|
40
|
+
console.log("Router is not running. Run `jenga start` to start it.");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Read real session state from .jenga/session.json written by the router
|
|
45
|
+
const session = readSessionFile();
|
|
46
|
+
|
|
47
|
+
if (session.skill && session.startedAt) {
|
|
48
|
+
const elapsed = formatDuration(Date.now() - new Date(session.startedAt).getTime());
|
|
49
|
+
console.log(`✓ Router running (PID: ${pid}) | Active session: ${session.skill} (${elapsed})`);
|
|
50
|
+
} else {
|
|
51
|
+
console.log(`✓ Router running (PID: ${pid}) | No active session`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export const configSchema = {
|
|
5
|
+
type: "object",
|
|
6
|
+
required: ["skillsPath", "matchThreshold", "sessionTimeout", "agentTarget"],
|
|
7
|
+
properties: {
|
|
8
|
+
// Single path (string) or multiple discovery paths (array of strings).
|
|
9
|
+
// Multi-target installs (e.g. claude + copilot) use an array holding
|
|
10
|
+
// both .claude/skills/ and .agents/skills/.
|
|
11
|
+
skillsPath: {
|
|
12
|
+
oneOf: [
|
|
13
|
+
{ type: "string" },
|
|
14
|
+
{ type: "array", items: { type: "string" }, minItems: 1 }
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
matchThreshold: { type: "number", minimum: 0, maximum: 1 },
|
|
18
|
+
sessionTimeout: { type: "number", minimum: 0 },
|
|
19
|
+
agentTarget: {
|
|
20
|
+
oneOf: [
|
|
21
|
+
{ type: "string", enum: ["claude", "copilot", "custom"] },
|
|
22
|
+
{ type: "array", items: { type: "string", enum: ["claude", "copilot", "custom"] }, minItems: 1 }
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
additionalProperties: false
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function validateConfig(config) {
|
|
30
|
+
for (const field of configSchema.required) {
|
|
31
|
+
if (config[field] === undefined || config[field] === null) {
|
|
32
|
+
return { valid: false, error: `Missing required field: ${field}` };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const skillsPathIsString = typeof config.skillsPath === "string";
|
|
36
|
+
const skillsPathIsArray = Array.isArray(config.skillsPath)
|
|
37
|
+
&& config.skillsPath.length > 0
|
|
38
|
+
&& config.skillsPath.every(p => typeof p === "string");
|
|
39
|
+
if (!skillsPathIsString && !skillsPathIsArray) {
|
|
40
|
+
return { valid: false, error: "skillsPath must be a string or a non-empty array of strings" };
|
|
41
|
+
}
|
|
42
|
+
if (typeof config.matchThreshold !== "number" || config.matchThreshold < 0 || config.matchThreshold > 1) {
|
|
43
|
+
return { valid: false, error: "matchThreshold must be a number between 0 and 1" };
|
|
44
|
+
}
|
|
45
|
+
if (typeof config.sessionTimeout !== "number" || config.sessionTimeout < 0) {
|
|
46
|
+
return { valid: false, error: "sessionTimeout must be a non-negative number" };
|
|
47
|
+
}
|
|
48
|
+
const validTargets = ["claude", "copilot", "custom"];
|
|
49
|
+
const targets = Array.isArray(config.agentTarget) ? config.agentTarget : [config.agentTarget];
|
|
50
|
+
if (targets.length === 0 || !targets.every(t => validTargets.includes(t))) {
|
|
51
|
+
return { valid: false, error: 'agentTarget must be one of: "claude", "copilot", "custom" (or an array of those values)' };
|
|
52
|
+
}
|
|
53
|
+
return { valid: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function loadConfig(projectRoot = process.cwd()) {
|
|
57
|
+
const configPath = join(projectRoot, "jenga.cli.json");
|
|
58
|
+
if (!existsSync(configPath)) {
|
|
59
|
+
throw new Error(`jenga.cli.json not found at ${configPath}. Run 'jenga init' to create it.`);
|
|
60
|
+
}
|
|
61
|
+
let config;
|
|
62
|
+
try {
|
|
63
|
+
config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
throw new Error(`Failed to parse jenga.cli.json: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
const result = validateConfig(config);
|
|
68
|
+
if (!result.valid) {
|
|
69
|
+
throw new Error(result.error);
|
|
70
|
+
}
|
|
71
|
+
return config;
|
|
72
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { join, resolve, dirname } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const HOOK_TYPE = "command";
|
|
7
|
+
// This file lives at <package>/lib/inject-settings.js — one level up is the
|
|
8
|
+
// installed jenga-agent package root, which is where hooks/prompt_router.sh
|
|
9
|
+
// ships. Consumers never need a local copy of the hook script.
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const PACKAGE_ROOT = join(__dirname, "..");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Injects the Jenga UserPromptSubmit hook into settings.json.
|
|
15
|
+
* Idempotent — running twice does not duplicate the entry.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} projectRoot - project root directory (default: cwd)
|
|
18
|
+
* @param {boolean} global - if true, target ~/.claude/settings.json instead
|
|
19
|
+
*/
|
|
20
|
+
export async function injectSettings(projectRoot = process.cwd(), global = false) {
|
|
21
|
+
const settingsDir = global
|
|
22
|
+
? join(homedir(), ".claude")
|
|
23
|
+
: join(projectRoot, ".claude");
|
|
24
|
+
const settingsPath = join(settingsDir, "settings.json");
|
|
25
|
+
|
|
26
|
+
const hookCommand = resolve(PACKAGE_ROOT, "hooks", "prompt_router.sh");
|
|
27
|
+
|
|
28
|
+
// Load or create settings
|
|
29
|
+
let settings = {};
|
|
30
|
+
if (existsSync(settingsPath)) {
|
|
31
|
+
try {
|
|
32
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
33
|
+
} catch (e) {
|
|
34
|
+
throw new Error(`Failed to parse ${settingsPath}: ${e.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Ensure hooks.UserPromptSubmit exists
|
|
39
|
+
if (!settings.hooks) settings.hooks = {};
|
|
40
|
+
if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
|
|
41
|
+
|
|
42
|
+
const hooks = settings.hooks.UserPromptSubmit;
|
|
43
|
+
|
|
44
|
+
// Check for existing Jenga entry (idempotency)
|
|
45
|
+
const alreadyRegistered = hooks.some(
|
|
46
|
+
(entry) =>
|
|
47
|
+
Array.isArray(entry.hooks) &&
|
|
48
|
+
entry.hooks.some((h) => h.type === HOOK_TYPE && h.command === hookCommand)
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (!alreadyRegistered) {
|
|
52
|
+
hooks.push({
|
|
53
|
+
matcher: "",
|
|
54
|
+
hooks: [{ type: HOOK_TYPE, command: hookCommand }],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
59
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
60
|
+
console.log(`✓ Hook registered in ${settingsPath}`);
|
|
61
|
+
}
|
package/lib/mirror.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/mirror.js — Shared directory-mirroring helper
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for copying an explicit set of top-level entries
|
|
5
|
+
* (files or directories) from a source root into a destination root. Used
|
|
6
|
+
* by:
|
|
7
|
+
* - scripts/postinstall.js (consumer install; reconcileDeletes = false)
|
|
8
|
+
* - skills/self-sync/... (in-repo dev mirror; reconcileDeletes = true) [wired in T02]
|
|
9
|
+
*
|
|
10
|
+
* Design constraints:
|
|
11
|
+
* - ESM, Node built-ins only (node:fs / node:fs/promises / node:path).
|
|
12
|
+
* - Never writes outside `destRoot` — every destination path is boundary-
|
|
13
|
+
* checked with a resolved-path comparison.
|
|
14
|
+
* - Additive by default. Delete reconciliation only fires when the caller
|
|
15
|
+
* explicitly passes `reconcileDeletes: true`.
|
|
16
|
+
* - `dryRun` computes the plan (added / overwritten / deleted / skipped)
|
|
17
|
+
* without touching the filesystem.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import fsp from 'node:fs/promises';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
// ── boundary check ────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Assert that `child` resolves to a path inside `parent`.
|
|
28
|
+
* Rejects `..` traversal, symlink escapes, and absolute overrides.
|
|
29
|
+
* Throws if the invariant is violated.
|
|
30
|
+
*/
|
|
31
|
+
function assertInside(parent, child, label) {
|
|
32
|
+
const resolvedParent = path.resolve(parent);
|
|
33
|
+
const resolvedChild = path.resolve(child);
|
|
34
|
+
const rel = path.relative(resolvedParent, resolvedChild);
|
|
35
|
+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
|
|
36
|
+
return; // inside (rel === '' means child === parent, which is fine)
|
|
37
|
+
}
|
|
38
|
+
throw new Error(
|
|
39
|
+
`mirror: refusing to write outside destRoot (${label}): ${resolvedChild} is outside ${resolvedParent}`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── content comparison ────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Byte-compare two files. Returns true iff both exist and have identical
|
|
47
|
+
* contents. Any read error → false (treated as "differs, needs copy").
|
|
48
|
+
*/
|
|
49
|
+
function filesEqual(a, b) {
|
|
50
|
+
try {
|
|
51
|
+
const statA = fs.statSync(a);
|
|
52
|
+
const statB = fs.statSync(b);
|
|
53
|
+
if (statA.size !== statB.size) return false;
|
|
54
|
+
const bufA = fs.readFileSync(a);
|
|
55
|
+
const bufB = fs.readFileSync(b);
|
|
56
|
+
return bufA.equals(bufB);
|
|
57
|
+
} catch (_) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── walk source, classify + copy ──────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Recursively walk `src` and mirror into `dst`, classifying each file.
|
|
66
|
+
* Mutates `result` in place. Honors `destRoot` boundary via assertInside.
|
|
67
|
+
*/
|
|
68
|
+
function mirrorEntry(src, dst, destRoot, dryRun, excludeNames, result) {
|
|
69
|
+
if (!fs.existsSync(src)) return;
|
|
70
|
+
|
|
71
|
+
const srcStat = fs.statSync(src);
|
|
72
|
+
|
|
73
|
+
if (srcStat.isFile()) {
|
|
74
|
+
assertInside(destRoot, dst, 'file target');
|
|
75
|
+
const exists = fs.existsSync(dst);
|
|
76
|
+
if (!exists) {
|
|
77
|
+
result.added.push(dst);
|
|
78
|
+
if (!dryRun) {
|
|
79
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
80
|
+
fs.copyFileSync(src, dst);
|
|
81
|
+
}
|
|
82
|
+
} else if (!filesEqual(src, dst)) {
|
|
83
|
+
result.overwritten.push(dst);
|
|
84
|
+
if (!dryRun) {
|
|
85
|
+
fs.copyFileSync(src, dst);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
result.skipped.push(dst);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (srcStat.isDirectory()) {
|
|
94
|
+
assertInside(destRoot, dst, 'dir target');
|
|
95
|
+
if (!dryRun) fs.mkdirSync(dst, { recursive: true });
|
|
96
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (excludeNames.has(entry.name)) continue;
|
|
99
|
+
mirrorEntry(
|
|
100
|
+
path.join(src, entry.name),
|
|
101
|
+
path.join(dst, entry.name),
|
|
102
|
+
destRoot,
|
|
103
|
+
dryRun,
|
|
104
|
+
excludeNames,
|
|
105
|
+
result
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// symlinks and other node types are intentionally ignored (parity with
|
|
110
|
+
// the old bash + node behavior, both of which used plain copy).
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── delete reconciliation ─────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* For each top-level entry in `copySet`, walk the destination subtree and
|
|
117
|
+
* delete any file/dir that is not present in the source subtree. Records
|
|
118
|
+
* the deletions in `result.deleted`. Boundary-checked. No-op unless
|
|
119
|
+
* `reconcileDeletes` was requested by the caller.
|
|
120
|
+
*/
|
|
121
|
+
function reconcileEntry(src, dst, destRoot, dryRun, excludeNames, result) {
|
|
122
|
+
if (!fs.existsSync(dst)) return;
|
|
123
|
+
|
|
124
|
+
const dstStat = fs.statSync(dst);
|
|
125
|
+
|
|
126
|
+
// If the source is entirely absent, the whole destination entry is orphaned.
|
|
127
|
+
if (!fs.existsSync(src)) {
|
|
128
|
+
assertInside(destRoot, dst, 'orphan delete');
|
|
129
|
+
result.deleted.push(dst);
|
|
130
|
+
if (!dryRun) fs.rmSync(dst, { recursive: true, force: true });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!dstStat.isDirectory()) {
|
|
135
|
+
// File-vs-file mismatches are handled by mirrorEntry; nothing to reconcile.
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const srcExists = fs.existsSync(src) && fs.statSync(src).isDirectory();
|
|
140
|
+
if (!srcExists) {
|
|
141
|
+
// src changed type (was a dir, now a file). Delete the whole mirror dir.
|
|
142
|
+
assertInside(destRoot, dst, 'type-change delete');
|
|
143
|
+
result.deleted.push(dst);
|
|
144
|
+
if (!dryRun) fs.rmSync(dst, { recursive: true, force: true });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const dstEntries = fs.readdirSync(dst, { withFileTypes: true });
|
|
149
|
+
for (const entry of dstEntries) {
|
|
150
|
+
// Excluded names in the destination are treated as orphans and pruned —
|
|
151
|
+
// this is how the mirror recovers from prior copies made before the
|
|
152
|
+
// exclusion list existed (e.g. legacy `.agents/` nested inside `agents/`).
|
|
153
|
+
const childDst = path.join(dst, entry.name);
|
|
154
|
+
const childSrc = path.join(src, entry.name);
|
|
155
|
+
if (excludeNames.has(entry.name) || !fs.existsSync(childSrc)) {
|
|
156
|
+
assertInside(destRoot, childDst, 'orphan child delete');
|
|
157
|
+
result.deleted.push(childDst);
|
|
158
|
+
if (!dryRun) fs.rmSync(childDst, { recursive: true, force: true });
|
|
159
|
+
} else if (entry.isDirectory()) {
|
|
160
|
+
reconcileEntry(childSrc, childDst, destRoot, dryRun, excludeNames, result);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── public API ────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Mirror an explicit set of top-level entries from `sourceRoot` into `destRoot`.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} opts
|
|
171
|
+
* @param {string} opts.sourceRoot Absolute path to source root.
|
|
172
|
+
* @param {string} opts.destRoot Absolute path to destination root.
|
|
173
|
+
* @param {string[]} opts.copySet Relative paths (files or dirs).
|
|
174
|
+
* @param {boolean} [opts.dryRun=false] If true, compute plan without writing.
|
|
175
|
+
* @param {boolean} [opts.reconcileDeletes=false] If true, remove dest files/dirs
|
|
176
|
+
* not present in source (within copySet).
|
|
177
|
+
* @param {string[]} [opts.exclude=[]] Basenames to skip anywhere in the walk
|
|
178
|
+
* (e.g. ['.agents', '.claude', 'node_modules']).
|
|
179
|
+
* When reconcileDeletes is on, excluded
|
|
180
|
+
* basenames present in the destination
|
|
181
|
+
* are pruned as orphans.
|
|
182
|
+
* @returns {{added: string[], overwritten: string[], deleted: string[], skipped: string[]}}
|
|
183
|
+
* Absolute destination paths grouped by outcome.
|
|
184
|
+
*/
|
|
185
|
+
export function mirror({
|
|
186
|
+
sourceRoot,
|
|
187
|
+
destRoot,
|
|
188
|
+
copySet,
|
|
189
|
+
dryRun = false,
|
|
190
|
+
reconcileDeletes = false,
|
|
191
|
+
exclude = [],
|
|
192
|
+
} = {}) {
|
|
193
|
+
if (!sourceRoot || typeof sourceRoot !== 'string') {
|
|
194
|
+
throw new TypeError('mirror: `sourceRoot` must be a non-empty string');
|
|
195
|
+
}
|
|
196
|
+
if (!destRoot || typeof destRoot !== 'string') {
|
|
197
|
+
throw new TypeError('mirror: `destRoot` must be a non-empty string');
|
|
198
|
+
}
|
|
199
|
+
if (!Array.isArray(copySet)) {
|
|
200
|
+
throw new TypeError('mirror: `copySet` must be an array of relative paths');
|
|
201
|
+
}
|
|
202
|
+
if (!Array.isArray(exclude)) {
|
|
203
|
+
throw new TypeError('mirror: `exclude` must be an array of basenames');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const resolvedSource = path.resolve(sourceRoot);
|
|
207
|
+
const resolvedDest = path.resolve(destRoot);
|
|
208
|
+
const excludeNames = new Set(exclude);
|
|
209
|
+
|
|
210
|
+
const result = {
|
|
211
|
+
added: [],
|
|
212
|
+
overwritten: [],
|
|
213
|
+
deleted: [],
|
|
214
|
+
skipped: [],
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (!dryRun) {
|
|
218
|
+
fs.mkdirSync(resolvedDest, { recursive: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const entry of copySet) {
|
|
222
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
223
|
+
throw new TypeError(`mirror: copySet entries must be non-empty strings (got ${JSON.stringify(entry)})`);
|
|
224
|
+
}
|
|
225
|
+
// Reject entries that would escape either root.
|
|
226
|
+
const src = path.join(resolvedSource, entry);
|
|
227
|
+
const dst = path.join(resolvedDest, entry);
|
|
228
|
+
assertInside(resolvedSource, src, `copySet source entry "${entry}"`);
|
|
229
|
+
assertInside(resolvedDest, dst, `copySet dest entry "${entry}"`);
|
|
230
|
+
|
|
231
|
+
mirrorEntry(src, dst, resolvedDest, dryRun, excludeNames, result);
|
|
232
|
+
|
|
233
|
+
if (reconcileDeletes) {
|
|
234
|
+
reconcileEntry(src, dst, resolvedDest, dryRun, excludeNames, result);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Also export helpers for advanced callers / tests. Not part of the stable API.
|
|
242
|
+
export const __internal = { assertInside, filesEqual };
|
|
243
|
+
|
|
244
|
+
export default mirror;
|