🛠️ Update various documentation, scripts, and configuration templates to enhance clarity, functionality, and maintainability across the project
This commit is contained in:
@@ -9,6 +9,9 @@ $VscodeUserDir = if ($env:VSCODE_USER_DIR) { $env:VSCODE_USER_DIR } else { Join-
|
||||
$ManagedShellEnv = $null
|
||||
$ProfilePath = $PROFILE.CurrentUserAllHosts
|
||||
$VscodeSettingsFile = Join-Path $VscodeUserDir 'settings.json'
|
||||
$VscodeMcpFile = Join-Path $VscodeUserDir 'mcp.json'
|
||||
$CopilotCliMcpFile = Join-Path $CopilotHome 'mcp-config.json'
|
||||
$LocalMcpOverridesFile = Join-Path $CanonicalHome '.local\mcp.local.jsonc'
|
||||
|
||||
function Resolve-Directory {
|
||||
param([string]$Path)
|
||||
@@ -127,6 +130,14 @@ function Write-ManagedPowerShellEnv {
|
||||
-Body "if (Test-Path -LiteralPath $QuotedEnvPath) {`n . $QuotedEnvPath`n}"
|
||||
}
|
||||
|
||||
function Ensure-LocalMcpOverrides {
|
||||
$ExampleFile = Join-Path $CanonicalHome 'config\mcp\local-overrides.example.jsonc'
|
||||
Ensure-Directory (Split-Path -Parent $LocalMcpOverridesFile)
|
||||
if (-not (Test-Path -LiteralPath $LocalMcpOverridesFile)) {
|
||||
Copy-Item -LiteralPath $ExampleFile -Destination $LocalMcpOverridesFile
|
||||
}
|
||||
}
|
||||
|
||||
function Merge-VscodeSettings {
|
||||
$NodeExecutable = Find-NodeExecutable
|
||||
if (-not $NodeExecutable) {
|
||||
@@ -138,6 +149,20 @@ function Merge-VscodeSettings {
|
||||
& $NodeExecutable (Join-Path $ScriptDir 'merge-vscode-settings.mjs') --target $VscodeSettingsFile --template (Join-Path $CanonicalHome 'config\vscode\settings.template.jsonc') --set "COPILOT_RESOURCES_HOME=$CanonicalHome"
|
||||
}
|
||||
|
||||
function Merge-ManagedMcpConfig {
|
||||
$NodeExecutable = Find-NodeExecutable
|
||||
if (-not $NodeExecutable) {
|
||||
Write-Warning 'Skipping managed MCP config merge because Node.js is not available.'
|
||||
return
|
||||
}
|
||||
|
||||
Ensure-Directory (Split-Path -Parent $VscodeMcpFile)
|
||||
Ensure-Directory (Split-Path -Parent $CopilotCliMcpFile)
|
||||
|
||||
& $NodeExecutable (Join-Path $ScriptDir 'merge-managed-mcp-config.mjs') --target $VscodeMcpFile --template (Join-Path $CanonicalHome 'config\mcp\vscode.mcp.template.jsonc') --server-key servers --overrides $LocalMcpOverridesFile --set "COPILOT_RESOURCES_HOME=$CanonicalHome"
|
||||
& $NodeExecutable (Join-Path $ScriptDir 'merge-managed-mcp-config.mjs') --target $CopilotCliMcpFile --template (Join-Path $CanonicalHome 'config\mcp\copilot-cli.mcp.template.jsonc') --server-key mcpServers --overrides $LocalMcpOverridesFile --set "COPILOT_RESOURCES_HOME=$CanonicalHome"
|
||||
}
|
||||
|
||||
Ensure-Directory (Split-Path -Parent $CanonicalHome)
|
||||
if (Test-Path -LiteralPath $CanonicalHome) {
|
||||
if ((Resolve-Directory $CanonicalHome) -ne (Resolve-Directory $RepoRoot)) {
|
||||
@@ -157,7 +182,9 @@ Ensure-Junction -Target (Join-Path $CanonicalHome 'resources\hooks') -Path (Join
|
||||
Ensure-Junction -Target (Join-Path $CanonicalHome 'resources\prompts') -Path (Join-Path $VscodeUserDir 'prompts')
|
||||
|
||||
Write-ManagedPowerShellEnv
|
||||
Ensure-LocalMcpOverrides
|
||||
Merge-VscodeSettings
|
||||
Merge-ManagedMcpConfig
|
||||
|
||||
Ensure-Directory $StateDir
|
||||
@{
|
||||
@@ -166,6 +193,9 @@ Ensure-Directory $StateDir
|
||||
copilotHome = $CopilotHome
|
||||
vscodeUserDir = $VscodeUserDir
|
||||
vscodeSettingsFile = $VscodeSettingsFile
|
||||
vscodeMcpFile = $VscodeMcpFile
|
||||
copilotCliMcpFile = $CopilotCliMcpFile
|
||||
mcpLocalOverridesFile = $LocalMcpOverridesFile
|
||||
shellRcFile = $ProfilePath
|
||||
managedShellEnv = $ManagedShellEnv
|
||||
bootstrapScript = (Join-Path $ScriptDir 'bootstrap.ps1')
|
||||
@@ -177,6 +207,9 @@ Write-Host "Repository root: $RepoRoot"
|
||||
Write-Host "Copilot home: $CopilotHome"
|
||||
Write-Host "VS Code user dir: $VscodeUserDir"
|
||||
Write-Host "Merged managed VS Code settings into: $VscodeSettingsFile"
|
||||
Write-Host "Merged managed MCP configuration into: $VscodeMcpFile"
|
||||
Write-Host "Merged managed MCP configuration into: $CopilotCliMcpFile"
|
||||
Write-Host "Installed managed Copilot CLI PowerShell environment into: $ManagedShellEnv"
|
||||
Write-Host "Linked PowerShell profile: $ProfilePath"
|
||||
Write-Host "Optional VS Code template: $(Join-Path $CanonicalHome 'config\vscode\settings.template.jsonc')"
|
||||
Write-Host "Machine-local MCP overrides live in: $LocalMcpOverridesFile"
|
||||
|
||||
56
install/bootstrap.sh
Normal file → Executable file
56
install/bootstrap.sh
Normal file → Executable file
@@ -10,6 +10,9 @@ copilot_home="${COPILOT_HOME:-$HOME/.copilot}"
|
||||
managed_shell_env=""
|
||||
shell_rc_file=""
|
||||
vscode_settings_file=""
|
||||
vscode_mcp_file=""
|
||||
copilot_cli_mcp_file=""
|
||||
local_mcp_overrides_file=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -163,6 +166,18 @@ EOF
|
||||
"$shell_block"
|
||||
}
|
||||
|
||||
ensure_local_mcp_overrides() {
|
||||
local example_file
|
||||
|
||||
local_mcp_overrides_file="$canonical_home/.local/mcp.local.jsonc"
|
||||
example_file="$canonical_home/config/mcp/local-overrides.example.jsonc"
|
||||
|
||||
ensure_parent_dir "$local_mcp_overrides_file"
|
||||
if [[ ! -f "$local_mcp_overrides_file" ]]; then
|
||||
cp -- "$example_file" "$local_mcp_overrides_file"
|
||||
fi
|
||||
}
|
||||
|
||||
merge_vscode_settings() {
|
||||
local node_bin
|
||||
vscode_settings_file="$vscode_user_dir/settings.json"
|
||||
@@ -180,6 +195,35 @@ merge_vscode_settings() {
|
||||
--set "COPILOT_RESOURCES_HOME=$canonical_home"
|
||||
}
|
||||
|
||||
merge_managed_mcp_config() {
|
||||
local node_bin
|
||||
|
||||
if ! node_bin="$(find_node_bin)"; then
|
||||
printf 'Skipping managed MCP config merge because Node.js is not available.\n' >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
vscode_mcp_file="$vscode_user_dir/mcp.json"
|
||||
copilot_cli_mcp_file="$copilot_home/mcp-config.json"
|
||||
|
||||
ensure_parent_dir "$vscode_mcp_file"
|
||||
ensure_parent_dir "$copilot_cli_mcp_file"
|
||||
|
||||
"$node_bin" "$script_dir/merge-managed-mcp-config.mjs" \
|
||||
--target "$vscode_mcp_file" \
|
||||
--template "$canonical_home/config/mcp/vscode.mcp.template.jsonc" \
|
||||
--server-key "servers" \
|
||||
--overrides "$local_mcp_overrides_file" \
|
||||
--set "COPILOT_RESOURCES_HOME=$canonical_home"
|
||||
|
||||
"$node_bin" "$script_dir/merge-managed-mcp-config.mjs" \
|
||||
--target "$copilot_cli_mcp_file" \
|
||||
--template "$canonical_home/config/mcp/copilot-cli.mcp.template.jsonc" \
|
||||
--server-key "mcpServers" \
|
||||
--overrides "$local_mcp_overrides_file" \
|
||||
--set "COPILOT_RESOURCES_HOME=$canonical_home"
|
||||
}
|
||||
|
||||
write_state() {
|
||||
mkdir -p -- "$state_dir"
|
||||
cat > "$state_dir/install-state.json" <<EOF
|
||||
@@ -189,6 +233,9 @@ write_state() {
|
||||
"copilotHome": "${copilot_home}",
|
||||
"vscodeUserDir": "${vscode_user_dir}",
|
||||
"vscodeSettingsFile": "${vscode_settings_file}",
|
||||
"vscodeMcpFile": "${vscode_mcp_file}",
|
||||
"copilotCliMcpFile": "${copilot_cli_mcp_file}",
|
||||
"mcpLocalOverridesFile": "${local_mcp_overrides_file}",
|
||||
"shellRcFile": "${shell_rc_file}",
|
||||
"managedShellEnv": "${managed_shell_env}",
|
||||
"bootstrapScript": "${script_dir}/bootstrap.sh"
|
||||
@@ -232,7 +279,9 @@ main() {
|
||||
link_path "$canonical_home/resources/prompts" "$vscode_user_dir/prompts"
|
||||
|
||||
write_managed_shell_env
|
||||
ensure_local_mcp_overrides
|
||||
merge_vscode_settings
|
||||
merge_managed_mcp_config
|
||||
|
||||
write_state
|
||||
|
||||
@@ -250,6 +299,10 @@ instructions, hooks, and prompts.
|
||||
Merged managed VS Code settings into:
|
||||
$vscode_settings_file
|
||||
|
||||
Merged managed MCP configuration into:
|
||||
$vscode_user_dir/mcp.json
|
||||
$copilot_home/mcp-config.json
|
||||
|
||||
Installed managed Copilot CLI shell environment into:
|
||||
$managed_shell_env
|
||||
|
||||
@@ -259,6 +312,9 @@ Linked shell startup file:
|
||||
Optional VS Code feature flags are available in:
|
||||
$canonical_home/config/vscode/settings.template.jsonc
|
||||
|
||||
Machine-local MCP overrides live in:
|
||||
$local_mcp_overrides_file
|
||||
|
||||
Optional Copilot CLI environment template is available in:
|
||||
$canonical_home/config/copilot-cli/env.example.sh
|
||||
EOF
|
||||
|
||||
63
install/mcp/copilot-cli-filesystem-wrapper.mjs
Normal file
63
install/mcp/copilot-cli-filesystem-wrapper.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function resolveWorkspaceDir() {
|
||||
const workspaceDir = process.env.COPILOT_MCP_FILESYSTEM_ROOT
|
||||
? path.resolve(process.env.COPILOT_MCP_FILESYSTEM_ROOT)
|
||||
: process.cwd();
|
||||
|
||||
if (!fs.existsSync(workspaceDir)) {
|
||||
throw new Error(`Filesystem MCP workspace does not exist: ${workspaceDir}`);
|
||||
}
|
||||
|
||||
if (!fs.statSync(workspaceDir).isDirectory()) {
|
||||
throw new Error(`Filesystem MCP workspace is not a directory: ${workspaceDir}`);
|
||||
}
|
||||
|
||||
return workspaceDir;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
const dockerArgs = [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"--mount",
|
||||
`type=bind,src=${workspaceDir},dst=/projects/workspace`,
|
||||
"mcp/filesystem",
|
||||
"/projects/workspace",
|
||||
];
|
||||
|
||||
const child = spawn("docker", dockerArgs, {
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`Failed to start Docker for the filesystem MCP server: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
process.on(signal, () => {
|
||||
if (!child.killed) {
|
||||
child.kill(signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`Filesystem MCP server exited with signal ${signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
823
install/merge-managed-mcp-config.mjs
Normal file
823
install/merge-managed-mcp-config.mjs
Normal file
@@ -0,0 +1,823 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
"Usage: node install/merge-managed-mcp-config.mjs --target <config.json> --template <template.jsonc> --server-key <servers|mcpServers> [--overrides <mcp.local.jsonc>] [--set NAME=value]",
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
replacements: {},
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === "--target") {
|
||||
options.target = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--template") {
|
||||
options.template = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--server-key") {
|
||||
options.serverKey = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--overrides") {
|
||||
options.overrides = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--set") {
|
||||
const assignment = argv[index + 1] ?? "";
|
||||
const equalsIndex = assignment.indexOf("=");
|
||||
if (equalsIndex <= 0) {
|
||||
throw new Error(`Invalid --set assignment: ${assignment}`);
|
||||
}
|
||||
const key = assignment.slice(0, equalsIndex);
|
||||
const value = assignment.slice(equalsIndex + 1);
|
||||
options.replacements[key] = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
if (!options.target || !options.template || !options.serverKey) {
|
||||
usage();
|
||||
throw new Error(
|
||||
"--target, --template, and --server-key are all required.",
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function stripJsonComments(input) {
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaping = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const char = input[index];
|
||||
const next = input[index + 1];
|
||||
|
||||
if (inLineComment) {
|
||||
if (char === "\n") {
|
||||
inLineComment = false;
|
||||
output += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
inBlockComment = false;
|
||||
index += 1;
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
output += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
} else if (char === "\\") {
|
||||
escaping = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
inLineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
inBlockComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function stripTrailingCommas(input) {
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaping = false;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const char = input[index];
|
||||
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
} else if (char === "\\") {
|
||||
escaping = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ",") {
|
||||
let lookahead = index + 1;
|
||||
while (lookahead < input.length && /\s/.test(input[lookahead])) {
|
||||
lookahead += 1;
|
||||
}
|
||||
if (input[lookahead] === "}" || input[lookahead] === "]") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderTemplate(input, replacements) {
|
||||
return input.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
|
||||
if (!(key in replacements)) {
|
||||
return match;
|
||||
}
|
||||
return replacements[key].replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
});
|
||||
}
|
||||
|
||||
function parseJsonc(input, label) {
|
||||
const sanitized = stripTrailingCommas(stripJsonComments(input)).trim();
|
||||
if (!sanitized) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(sanitized);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${label}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new Error(`${label} must contain a JSON object at the root.`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseJsoncAst(input, label) {
|
||||
let index = 0;
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`Failed to parse ${label}: ${message}`);
|
||||
}
|
||||
|
||||
function skipTrivia() {
|
||||
while (index < input.length) {
|
||||
const char = input[index];
|
||||
const next = input[index + 1];
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
index += 2;
|
||||
while (index < input.length && input[index] !== "\n") {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
index += 2;
|
||||
while (
|
||||
index < input.length &&
|
||||
!(input[index] === "*" && input[index + 1] === "/")
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
if (index >= input.length) {
|
||||
fail("unterminated block comment");
|
||||
}
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function parseStringNode() {
|
||||
const start = index;
|
||||
index += 1;
|
||||
|
||||
while (index < input.length) {
|
||||
const char = input[index];
|
||||
|
||||
if (char === "\\") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
index += 1;
|
||||
const raw = input.slice(start, index);
|
||||
return {
|
||||
type: "string",
|
||||
start,
|
||||
end: index,
|
||||
value: JSON.parse(raw),
|
||||
};
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
fail("unterminated string literal");
|
||||
}
|
||||
|
||||
function parseLiteralNode(expectedText, value) {
|
||||
const start = index;
|
||||
if (!input.startsWith(expectedText, index)) {
|
||||
fail(`expected ${expectedText}`);
|
||||
}
|
||||
index += expectedText.length;
|
||||
return {
|
||||
type: typeof value,
|
||||
start,
|
||||
end: index,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function parseNumberNode() {
|
||||
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(
|
||||
input.slice(index),
|
||||
);
|
||||
if (!match) {
|
||||
fail(`invalid number at offset ${index}`);
|
||||
}
|
||||
|
||||
const start = index;
|
||||
index += match[0].length;
|
||||
return {
|
||||
type: "number",
|
||||
start,
|
||||
end: index,
|
||||
value: Number(match[0]),
|
||||
};
|
||||
}
|
||||
|
||||
function parseArrayNode() {
|
||||
const start = index;
|
||||
index += 1;
|
||||
const elements = [];
|
||||
|
||||
skipTrivia();
|
||||
while (index < input.length && input[index] !== "]") {
|
||||
const valueNode = parseValueNode();
|
||||
elements.push(valueNode);
|
||||
skipTrivia();
|
||||
|
||||
if (input[index] === ",") {
|
||||
index += 1;
|
||||
skipTrivia();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input[index] !== "]") {
|
||||
fail(`expected ',' or ']' at offset ${index}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input[index] !== "]") {
|
||||
fail("unterminated array");
|
||||
}
|
||||
|
||||
index += 1;
|
||||
return {
|
||||
type: "array",
|
||||
start,
|
||||
end: index,
|
||||
elements,
|
||||
value: elements.map((element) => element.value),
|
||||
};
|
||||
}
|
||||
|
||||
function parseObjectNode() {
|
||||
const start = index;
|
||||
index += 1;
|
||||
const properties = [];
|
||||
const value = {};
|
||||
|
||||
skipTrivia();
|
||||
while (index < input.length && input[index] !== "}") {
|
||||
if (input[index] !== '"') {
|
||||
fail(`expected string property name at offset ${index}`);
|
||||
}
|
||||
|
||||
const keyNode = parseStringNode();
|
||||
const key = keyNode.value;
|
||||
skipTrivia();
|
||||
|
||||
if (input[index] !== ":") {
|
||||
fail(`expected ':' after property name at offset ${index}`);
|
||||
}
|
||||
|
||||
index += 1;
|
||||
skipTrivia();
|
||||
|
||||
const valueNode = parseValueNode();
|
||||
const property = {
|
||||
key,
|
||||
keyNode,
|
||||
value: valueNode,
|
||||
hasTrailingComma: false,
|
||||
};
|
||||
|
||||
properties.push(property);
|
||||
value[key] = valueNode.value;
|
||||
|
||||
skipTrivia();
|
||||
if (input[index] === ",") {
|
||||
property.hasTrailingComma = true;
|
||||
index += 1;
|
||||
skipTrivia();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input[index] !== "}") {
|
||||
fail(`expected ',' or '}' at offset ${index}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input[index] !== "}") {
|
||||
fail("unterminated object");
|
||||
}
|
||||
|
||||
index += 1;
|
||||
return {
|
||||
type: "object",
|
||||
start,
|
||||
end: index,
|
||||
properties,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function parseValueNode() {
|
||||
skipTrivia();
|
||||
|
||||
const char = input[index];
|
||||
if (char === "{") {
|
||||
return parseObjectNode();
|
||||
}
|
||||
if (char === "[") {
|
||||
return parseArrayNode();
|
||||
}
|
||||
if (char === '"') {
|
||||
return parseStringNode();
|
||||
}
|
||||
if (char === "t") {
|
||||
return parseLiteralNode("true", true);
|
||||
}
|
||||
if (char === "f") {
|
||||
return parseLiteralNode("false", false);
|
||||
}
|
||||
if (char === "n") {
|
||||
return parseLiteralNode("null", null);
|
||||
}
|
||||
if (char === "-" || /\d/.test(char ?? "")) {
|
||||
return parseNumberNode();
|
||||
}
|
||||
|
||||
fail(`unexpected token at offset ${index}`);
|
||||
}
|
||||
|
||||
skipTrivia();
|
||||
const root = parseValueNode();
|
||||
skipTrivia();
|
||||
|
||||
if (index !== input.length) {
|
||||
fail(`unexpected trailing content at offset ${index}`);
|
||||
}
|
||||
|
||||
if (root.type !== "object") {
|
||||
throw new Error(`${label} must contain a JSON object at the root.`);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function detectIndentUnit(input) {
|
||||
const matches = input.match(/^( +|\t+)\S/m);
|
||||
if (!matches) {
|
||||
return " ";
|
||||
}
|
||||
|
||||
return matches[1];
|
||||
}
|
||||
|
||||
function detectEol(input) {
|
||||
return input.includes("\r\n") ? "\r\n" : "\n";
|
||||
}
|
||||
|
||||
function lineStartIndex(input, index) {
|
||||
const newlineIndex = input.lastIndexOf("\n", index - 1);
|
||||
return newlineIndex === -1 ? 0 : newlineIndex + 1;
|
||||
}
|
||||
|
||||
function lineIndentAt(input, index) {
|
||||
const start = lineStartIndex(input, index);
|
||||
let end = start;
|
||||
while (end < input.length && (input[end] === " " || input[end] === "\t")) {
|
||||
end += 1;
|
||||
}
|
||||
return input.slice(start, end);
|
||||
}
|
||||
|
||||
function renderValue(value, propertyIndent, indentUnit, eol) {
|
||||
const raw = JSON.stringify(value, null, indentUnit);
|
||||
if (!raw.includes("\n")) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((line, index) => (index === 0 ? line : `${propertyIndent}${line}`))
|
||||
.join(eol);
|
||||
}
|
||||
|
||||
function renderProperty(key, value, propertyIndent, indentUnit, eol) {
|
||||
return `${JSON.stringify(key)}: ${renderValue(value, propertyIndent, indentUnit, eol)}`;
|
||||
}
|
||||
|
||||
function getObjectChildIndent(input, objectNode, indentUnit) {
|
||||
if (objectNode.properties.length > 0) {
|
||||
return lineIndentAt(input, objectNode.properties[0].keyNode.start);
|
||||
}
|
||||
|
||||
return `${lineIndentAt(input, objectNode.start)}${indentUnit}`;
|
||||
}
|
||||
|
||||
function buildMergeEdits(
|
||||
input,
|
||||
objectNode,
|
||||
managedSettings,
|
||||
indentUnit,
|
||||
eol,
|
||||
edits,
|
||||
) {
|
||||
const missingEntries = [];
|
||||
|
||||
for (const [key, managedValue] of Object.entries(managedSettings)) {
|
||||
const property = objectNode.properties.find(
|
||||
(candidate) => candidate.key === key,
|
||||
);
|
||||
|
||||
if (!property) {
|
||||
missingEntries.push([key, managedValue]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainObject(managedValue) && property.value.type === "object") {
|
||||
buildMergeEdits(
|
||||
input,
|
||||
property.value,
|
||||
managedValue,
|
||||
indentUnit,
|
||||
eol,
|
||||
edits,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isDeepStrictEqual(property.value.value, managedValue)) {
|
||||
const propertyIndent = lineIndentAt(input, property.keyNode.start);
|
||||
edits.push({
|
||||
start: property.value.start,
|
||||
end: property.value.end,
|
||||
text: renderValue(managedValue, propertyIndent, indentUnit, eol),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (missingEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertyIndent = getObjectChildIndent(input, objectNode, indentUnit);
|
||||
const closingIndent = lineIndentAt(input, objectNode.end);
|
||||
const closingBraceIndex = objectNode.end - 1;
|
||||
const renderedProperties = missingEntries
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${propertyIndent}${renderProperty(key, value, propertyIndent, indentUnit, eol)}`,
|
||||
)
|
||||
.join(`,${eol}`);
|
||||
|
||||
if (objectNode.properties.length === 0) {
|
||||
edits.push({
|
||||
start: objectNode.start + 1,
|
||||
end: closingBraceIndex,
|
||||
text: `${eol}${renderedProperties}${eol}${closingIndent}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lastProperty = objectNode.properties[objectNode.properties.length - 1];
|
||||
if (!lastProperty.hasTrailingComma) {
|
||||
edits.push({
|
||||
start: lastProperty.value.end,
|
||||
end: lastProperty.value.end,
|
||||
text: ",",
|
||||
});
|
||||
}
|
||||
|
||||
const closingLineStart = lineStartIndex(input, objectNode.end);
|
||||
const insertBeforeClosingLine = closingLineStart > lastProperty.value.end;
|
||||
|
||||
edits.push({
|
||||
start: insertBeforeClosingLine ? closingLineStart : objectNode.end,
|
||||
end: insertBeforeClosingLine ? closingLineStart : objectNode.end,
|
||||
text: insertBeforeClosingLine
|
||||
? `${renderedProperties}${eol}`
|
||||
: `${eol}${renderedProperties}${eol}${closingIndent}`,
|
||||
});
|
||||
}
|
||||
|
||||
function buildRemovalEdits(input, objectNode, keysToRemove, edits) {
|
||||
const removeSet = new Set(keysToRemove);
|
||||
if (removeSet.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const properties = objectNode.properties;
|
||||
const closingBraceIndex = objectNode.end - 1;
|
||||
|
||||
for (let index = 0; index < properties.length; index += 1) {
|
||||
if (!removeSet.has(properties[index].key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let endIndex = index;
|
||||
while (
|
||||
endIndex + 1 < properties.length &&
|
||||
removeSet.has(properties[endIndex + 1].key)
|
||||
) {
|
||||
endIndex += 1;
|
||||
}
|
||||
|
||||
const firstProperty = properties[index];
|
||||
const lastProperty = properties[endIndex];
|
||||
const previousProperty = index > 0 ? properties[index - 1] : null;
|
||||
const nextProperty =
|
||||
endIndex + 1 < properties.length ? properties[endIndex + 1] : null;
|
||||
|
||||
if (!previousProperty && !nextProperty) {
|
||||
edits.push({
|
||||
start: objectNode.start + 1,
|
||||
end: closingBraceIndex,
|
||||
text:
|
||||
closingBraceIndex > lastProperty.value.end
|
||||
? input.slice(lastProperty.value.end, closingBraceIndex)
|
||||
: "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextProperty) {
|
||||
edits.push({
|
||||
start: firstProperty.keyNode.start,
|
||||
end: nextProperty.keyNode.start,
|
||||
text: "",
|
||||
});
|
||||
index = endIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
edits.push({
|
||||
start: previousProperty.value.end,
|
||||
end: closingBraceIndex,
|
||||
text:
|
||||
closingBraceIndex > lastProperty.value.end
|
||||
? input.slice(lastProperty.value.end, closingBraceIndex)
|
||||
: "",
|
||||
});
|
||||
index = endIndex;
|
||||
}
|
||||
}
|
||||
|
||||
function applyEdits(input, edits) {
|
||||
return edits
|
||||
.sort((left, right) => right.start - left.start || right.end - left.end)
|
||||
.reduce(
|
||||
(text, edit) =>
|
||||
`${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
function readLocalOverrides(filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return parseJsonc(fs.readFileSync(filePath, "utf8"), filePath);
|
||||
}
|
||||
|
||||
function getServerOverride(overrides, name) {
|
||||
const serverMap = isPlainObject(overrides.servers) ? overrides.servers : {};
|
||||
const value = serverMap[name];
|
||||
return isPlainObject(value) ? value : {};
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function buildTemplateReplacements(replacements, overrides) {
|
||||
const giteaOverrides = getServerOverride(overrides, "gitea");
|
||||
|
||||
return {
|
||||
...replacements,
|
||||
GITEA_SERVER_URL: normalizeString(giteaOverrides.serverUrl),
|
||||
GITEA_TOKEN: normalizeString(giteaOverrides.token),
|
||||
};
|
||||
}
|
||||
|
||||
function isServerEnabled(name, overrides) {
|
||||
const serverOverride = getServerOverride(overrides, name);
|
||||
if (serverOverride.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name === "gitea") {
|
||||
return (
|
||||
serverOverride.enabled === true &&
|
||||
normalizeString(serverOverride.serverUrl) !== "" &&
|
||||
normalizeString(serverOverride.token) !== ""
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectManagedConfig(managedConfig, serverKey, overrides) {
|
||||
const managedServers = managedConfig[serverKey];
|
||||
if (!isPlainObject(managedServers)) {
|
||||
throw new Error(
|
||||
`${serverKey} in ${serverKey} template must be an object at the root.`,
|
||||
);
|
||||
}
|
||||
|
||||
const selectedServers = {};
|
||||
for (const [name, serverConfig] of Object.entries(managedServers)) {
|
||||
if (isServerEnabled(name, overrides)) {
|
||||
selectedServers[name] = serverConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...managedConfig,
|
||||
[serverKey]: selectedServers,
|
||||
};
|
||||
}
|
||||
|
||||
function removeStaleManagedServers(
|
||||
input,
|
||||
rootNode,
|
||||
serverKey,
|
||||
managedServerNames,
|
||||
desiredServerNames,
|
||||
) {
|
||||
const serverProperty = rootNode.properties.find(
|
||||
(candidate) => candidate.key === serverKey,
|
||||
);
|
||||
|
||||
if (!serverProperty || serverProperty.value.type !== "object") {
|
||||
return input;
|
||||
}
|
||||
|
||||
const removableKeys = serverProperty.value.properties
|
||||
.map((property) => property.key)
|
||||
.filter(
|
||||
(key) => managedServerNames.has(key) && !desiredServerNames.has(key),
|
||||
);
|
||||
|
||||
if (removableKeys.length === 0) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const edits = [];
|
||||
buildRemovalEdits(input, serverProperty.value, removableKeys, edits);
|
||||
return applyEdits(input, edits);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const localOverrides = readLocalOverrides(options.overrides);
|
||||
const replacements = buildTemplateReplacements(
|
||||
options.replacements,
|
||||
localOverrides,
|
||||
);
|
||||
|
||||
const templateText = fs.readFileSync(options.template, "utf8");
|
||||
const renderedTemplate = renderTemplate(templateText, replacements);
|
||||
const managedConfig = parseJsonc(renderedTemplate, options.template);
|
||||
const desiredConfig = selectManagedConfig(
|
||||
managedConfig,
|
||||
options.serverKey,
|
||||
localOverrides,
|
||||
);
|
||||
|
||||
const managedServers = desiredConfig[options.serverKey];
|
||||
const allManagedServerNames = new Set(Object.keys(managedConfig[options.serverKey]));
|
||||
const desiredServerNames = new Set(Object.keys(managedServers));
|
||||
|
||||
const targetText = fs.existsSync(options.target)
|
||||
? fs.readFileSync(options.target, "utf8")
|
||||
: "{}\n";
|
||||
const targetAst = parseJsoncAst(targetText, options.target);
|
||||
const cleanedTargetText = removeStaleManagedServers(
|
||||
targetText,
|
||||
targetAst,
|
||||
options.serverKey,
|
||||
allManagedServerNames,
|
||||
desiredServerNames,
|
||||
);
|
||||
const cleanedTargetAst = parseJsoncAst(cleanedTargetText, options.target);
|
||||
const indentUnit = detectIndentUnit(targetText);
|
||||
const eol = detectEol(targetText);
|
||||
const edits = [];
|
||||
|
||||
buildMergeEdits(
|
||||
cleanedTargetText,
|
||||
cleanedTargetAst,
|
||||
desiredConfig,
|
||||
indentUnit,
|
||||
eol,
|
||||
edits,
|
||||
);
|
||||
|
||||
const output =
|
||||
edits.length === 0 ? cleanedTargetText : applyEdits(cleanedTargetText, edits);
|
||||
|
||||
parseJsonc(output, options.target);
|
||||
fs.mkdirSync(path.dirname(options.target), { recursive: true });
|
||||
|
||||
if (output === targetText) {
|
||||
console.log(`Managed MCP config already up to date: ${options.target}`);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(options.target, output, "utf8");
|
||||
console.log(`Merged managed MCP config into: ${options.target}`);
|
||||
}
|
||||
|
||||
main();
|
||||
282
install/merge-managed-mcp-config.test.mjs
Normal file
282
install/merge-managed-mcp-config.test.mjs
Normal file
@@ -0,0 +1,282 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "..");
|
||||
const mergeScript = path.join(scriptDir, "merge-managed-mcp-config.mjs");
|
||||
const vscodeTemplateFile = path.join(
|
||||
repoRoot,
|
||||
"config",
|
||||
"mcp",
|
||||
"vscode.mcp.template.jsonc",
|
||||
);
|
||||
const copilotCliTemplateFile = path.join(
|
||||
repoRoot,
|
||||
"config",
|
||||
"mcp",
|
||||
"copilot-cli.mcp.template.jsonc",
|
||||
);
|
||||
|
||||
function stripJsonComments(input) {
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaping = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const char = input[index];
|
||||
const next = input[index + 1];
|
||||
|
||||
if (inLineComment) {
|
||||
if (char === "\n") {
|
||||
inLineComment = false;
|
||||
output += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
inBlockComment = false;
|
||||
index += 1;
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
output += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
} else if (char === "\\") {
|
||||
escaping = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
inLineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
inBlockComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function stripTrailingCommas(input) {
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaping = false;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const char = input[index];
|
||||
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
} else if (char === "\\") {
|
||||
escaping = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ",") {
|
||||
let lookahead = index + 1;
|
||||
while (lookahead < input.length && /\s/.test(input[lookahead])) {
|
||||
lookahead += 1;
|
||||
}
|
||||
if (input[lookahead] === "}" || input[lookahead] === "]") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseJsonc(input) {
|
||||
return JSON.parse(stripTrailingCommas(stripJsonComments(input)));
|
||||
}
|
||||
|
||||
function runMerge({ targetFile, templateFile, serverKey, overridesFile }) {
|
||||
const args = [
|
||||
mergeScript,
|
||||
"--target",
|
||||
targetFile,
|
||||
"--template",
|
||||
templateFile,
|
||||
"--server-key",
|
||||
serverKey,
|
||||
"--set",
|
||||
"COPILOT_RESOURCES_HOME=/repo/home",
|
||||
];
|
||||
|
||||
if (overridesFile) {
|
||||
args.push("--overrides", overridesFile);
|
||||
}
|
||||
|
||||
return spawnSync(process.execPath, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
test("preserves comments and custom servers while pruning stale managed MCP entries", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-"));
|
||||
const targetFile = path.join(tempDir, "mcp.json");
|
||||
|
||||
fs.writeFileSync(
|
||||
targetFile,
|
||||
`{
|
||||
// keep this comment
|
||||
"servers": {
|
||||
// custom server stays
|
||||
"customServer": {
|
||||
"type": "http",
|
||||
"url": "https://example.com/mcp"
|
||||
},
|
||||
"gitea": {
|
||||
"type": "stdio",
|
||||
"command": "docker",
|
||||
"args": ["stale"]
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = runMerge({
|
||||
targetFile,
|
||||
templateFile: vscodeTemplateFile,
|
||||
serverKey: "servers",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
|
||||
const output = fs.readFileSync(targetFile, "utf8");
|
||||
assert.match(output, /\/\/ keep this comment/);
|
||||
assert.match(output, /\/\/ custom server stays/);
|
||||
assert.match(output, /"customServer"/);
|
||||
assert.match(output, /"playwright"/);
|
||||
assert.match(output, /"filesystem"/);
|
||||
assert.doesNotMatch(output, /"gitea"/);
|
||||
|
||||
const parsed = parseJsonc(output);
|
||||
assert.equal(parsed.servers.customServer.type, "http");
|
||||
assert.equal(parsed.servers.playwright.command, "npx");
|
||||
assert.equal(parsed.servers.filesystem.command, "docker");
|
||||
assert.equal(parsed.servers.gitea, undefined);
|
||||
});
|
||||
|
||||
test("renders optional Gitea config when local overrides are complete", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-"));
|
||||
const targetFile = path.join(tempDir, "mcp-config.json");
|
||||
const overridesFile = path.join(tempDir, "mcp.local.jsonc");
|
||||
|
||||
fs.writeFileSync(
|
||||
overridesFile,
|
||||
`{
|
||||
"servers": {
|
||||
"gitea": {
|
||||
"enabled": true,
|
||||
"serverUrl": "https://git.example.com",
|
||||
"token": "secret-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = runMerge({
|
||||
targetFile,
|
||||
templateFile: copilotCliTemplateFile,
|
||||
serverKey: "mcpServers",
|
||||
overridesFile,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
|
||||
const parsed = parseJsonc(fs.readFileSync(targetFile, "utf8"));
|
||||
assert.equal(
|
||||
parsed.mcpServers.filesystem.args[0],
|
||||
"/repo/home/install/mcp/copilot-cli-filesystem-wrapper.mjs",
|
||||
);
|
||||
assert.equal(parsed.mcpServers.gitea.env.FORGEJOMCP_SERVER, "https://git.example.com");
|
||||
assert.equal(parsed.mcpServers.gitea.env.FORGEJOMCP_TOKEN, "secret-token");
|
||||
assert.equal(parsed.mcpServers.playwright, undefined);
|
||||
});
|
||||
|
||||
test("second run is idempotent and keeps the file text unchanged", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-"));
|
||||
const targetFile = path.join(tempDir, "mcp.json");
|
||||
|
||||
fs.writeFileSync(
|
||||
targetFile,
|
||||
`{
|
||||
// user comment
|
||||
"servers": {
|
||||
"customServer": {
|
||||
"type": "http",
|
||||
"url": "https://example.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const firstRun = runMerge({
|
||||
targetFile,
|
||||
templateFile: vscodeTemplateFile,
|
||||
serverKey: "servers",
|
||||
});
|
||||
assert.equal(firstRun.status, 0, firstRun.stderr);
|
||||
const firstOutput = fs.readFileSync(targetFile, "utf8");
|
||||
|
||||
const secondRun = runMerge({
|
||||
targetFile,
|
||||
templateFile: vscodeTemplateFile,
|
||||
serverKey: "servers",
|
||||
});
|
||||
assert.equal(secondRun.status, 0, secondRun.stderr);
|
||||
assert.match(secondRun.stdout, /already up to date/);
|
||||
|
||||
const secondOutput = fs.readFileSync(targetFile, "utf8");
|
||||
assert.equal(secondOutput, firstOutput);
|
||||
assert.match(secondOutput, /\/\/ user comment/);
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
'Usage: node install/merge-vscode-settings.mjs --target <settings.json> --template <settings.template.jsonc> [--set NAME=value]'
|
||||
"Usage: node install/merge-vscode-settings.mjs --target <settings.json> --template <settings.template.jsonc> [--set NAME=value]",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,21 +18,21 @@ function parseArgs(argv) {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === '--target') {
|
||||
if (arg === "--target") {
|
||||
options.target = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--template') {
|
||||
if (arg === "--template") {
|
||||
options.template = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--set') {
|
||||
const assignment = argv[index + 1] ?? '';
|
||||
const equalsIndex = assignment.indexOf('=');
|
||||
if (arg === "--set") {
|
||||
const assignment = argv[index + 1] ?? "";
|
||||
const equalsIndex = assignment.indexOf("=");
|
||||
if (equalsIndex <= 0) {
|
||||
throw new Error(`Invalid --set assignment: ${assignment}`);
|
||||
}
|
||||
@@ -48,14 +48,14 @@ function parseArgs(argv) {
|
||||
|
||||
if (!options.target || !options.template) {
|
||||
usage();
|
||||
throw new Error('Both --target and --template are required.');
|
||||
throw new Error("Both --target and --template are required.");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function stripJsonComments(input) {
|
||||
let output = '';
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaping = false;
|
||||
let inLineComment = false;
|
||||
@@ -66,7 +66,7 @@ function stripJsonComments(input) {
|
||||
const next = input[index + 1];
|
||||
|
||||
if (inLineComment) {
|
||||
if (char === '\n') {
|
||||
if (char === "\n") {
|
||||
inLineComment = false;
|
||||
output += char;
|
||||
}
|
||||
@@ -74,10 +74,10 @@ function stripJsonComments(input) {
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (char === '*' && next === '/') {
|
||||
if (char === "*" && next === "/") {
|
||||
inBlockComment = false;
|
||||
index += 1;
|
||||
} else if (char === '\n' || char === '\r') {
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
output += char;
|
||||
}
|
||||
continue;
|
||||
@@ -87,7 +87,7 @@ function stripJsonComments(input) {
|
||||
output += char;
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
} else if (char === '\\') {
|
||||
} else if (char === "\\") {
|
||||
escaping = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
@@ -101,13 +101,13 @@ function stripJsonComments(input) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '/') {
|
||||
if (char === "/" && next === "/") {
|
||||
inLineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '*') {
|
||||
if (char === "/" && next === "*") {
|
||||
inBlockComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
@@ -120,7 +120,7 @@ function stripJsonComments(input) {
|
||||
}
|
||||
|
||||
function stripTrailingCommas(input) {
|
||||
let output = '';
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaping = false;
|
||||
|
||||
@@ -131,7 +131,7 @@ function stripTrailingCommas(input) {
|
||||
output += char;
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
} else if (char === '\\') {
|
||||
} else if (char === "\\") {
|
||||
escaping = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
@@ -145,12 +145,12 @@ function stripTrailingCommas(input) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ',') {
|
||||
if (char === ",") {
|
||||
let lookahead = index + 1;
|
||||
while (lookahead < input.length && /\s/.test(input[lookahead])) {
|
||||
lookahead += 1;
|
||||
}
|
||||
if (input[lookahead] === '}' || input[lookahead] === ']') {
|
||||
if (input[lookahead] === "}" || input[lookahead] === "]") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -166,9 +166,7 @@ function renderTemplate(input, replacements) {
|
||||
if (!(key in replacements)) {
|
||||
return match;
|
||||
}
|
||||
return replacements[key]
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"');
|
||||
return replacements[key].replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,21 +207,24 @@ function parseJsoncAst(input, label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '/') {
|
||||
if (char === "/" && next === "/") {
|
||||
index += 2;
|
||||
while (index < input.length && input[index] !== '\n') {
|
||||
while (index < input.length && input[index] !== "\n") {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '*') {
|
||||
if (char === "/" && next === "*") {
|
||||
index += 2;
|
||||
while (index < input.length && !(input[index] === '*' && input[index + 1] === '/')) {
|
||||
while (
|
||||
index < input.length &&
|
||||
!(input[index] === "*" && input[index + 1] === "/")
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
if (index >= input.length) {
|
||||
fail('unterminated block comment');
|
||||
fail("unterminated block comment");
|
||||
}
|
||||
index += 2;
|
||||
continue;
|
||||
@@ -240,7 +241,7 @@ function parseJsoncAst(input, label) {
|
||||
while (index < input.length) {
|
||||
const char = input[index];
|
||||
|
||||
if (char === '\\') {
|
||||
if (char === "\\") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
@@ -249,7 +250,7 @@ function parseJsoncAst(input, label) {
|
||||
index += 1;
|
||||
const raw = input.slice(start, index);
|
||||
return {
|
||||
type: 'string',
|
||||
type: "string",
|
||||
start,
|
||||
end: index,
|
||||
value: JSON.parse(raw),
|
||||
@@ -259,7 +260,7 @@ function parseJsoncAst(input, label) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
fail('unterminated string literal');
|
||||
fail("unterminated string literal");
|
||||
}
|
||||
|
||||
function parseLiteralNode(expectedText, value) {
|
||||
@@ -277,7 +278,9 @@ function parseJsoncAst(input, label) {
|
||||
}
|
||||
|
||||
function parseNumberNode() {
|
||||
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(input.slice(index));
|
||||
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(
|
||||
input.slice(index),
|
||||
);
|
||||
if (!match) {
|
||||
fail(`invalid number at offset ${index}`);
|
||||
}
|
||||
@@ -285,7 +288,7 @@ function parseJsoncAst(input, label) {
|
||||
const start = index;
|
||||
index += match[0].length;
|
||||
return {
|
||||
type: 'number',
|
||||
type: "number",
|
||||
start,
|
||||
end: index,
|
||||
value: Number(match[0]),
|
||||
@@ -298,29 +301,29 @@ function parseJsoncAst(input, label) {
|
||||
const elements = [];
|
||||
|
||||
skipTrivia();
|
||||
while (index < input.length && input[index] !== ']') {
|
||||
while (index < input.length && input[index] !== "]") {
|
||||
const valueNode = parseValueNode();
|
||||
elements.push(valueNode);
|
||||
skipTrivia();
|
||||
|
||||
if (input[index] === ',') {
|
||||
if (input[index] === ",") {
|
||||
index += 1;
|
||||
skipTrivia();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input[index] !== ']') {
|
||||
if (input[index] !== "]") {
|
||||
fail(`expected ',' or ']' at offset ${index}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input[index] !== ']') {
|
||||
fail('unterminated array');
|
||||
if (input[index] !== "]") {
|
||||
fail("unterminated array");
|
||||
}
|
||||
|
||||
index += 1;
|
||||
return {
|
||||
type: 'array',
|
||||
type: "array",
|
||||
start,
|
||||
end: index,
|
||||
elements,
|
||||
@@ -335,7 +338,7 @@ function parseJsoncAst(input, label) {
|
||||
const value = {};
|
||||
|
||||
skipTrivia();
|
||||
while (index < input.length && input[index] !== '}') {
|
||||
while (index < input.length && input[index] !== "}") {
|
||||
if (input[index] !== '"') {
|
||||
fail(`expected string property name at offset ${index}`);
|
||||
}
|
||||
@@ -344,7 +347,7 @@ function parseJsoncAst(input, label) {
|
||||
const key = keyNode.value;
|
||||
skipTrivia();
|
||||
|
||||
if (input[index] !== ':') {
|
||||
if (input[index] !== ":") {
|
||||
fail(`expected ':' after property name at offset ${index}`);
|
||||
}
|
||||
|
||||
@@ -363,25 +366,25 @@ function parseJsoncAst(input, label) {
|
||||
value[key] = valueNode.value;
|
||||
|
||||
skipTrivia();
|
||||
if (input[index] === ',') {
|
||||
if (input[index] === ",") {
|
||||
property.hasTrailingComma = true;
|
||||
index += 1;
|
||||
skipTrivia();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input[index] !== '}') {
|
||||
if (input[index] !== "}") {
|
||||
fail(`expected ',' or '}' at offset ${index}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input[index] !== '}') {
|
||||
fail('unterminated object');
|
||||
if (input[index] !== "}") {
|
||||
fail("unterminated object");
|
||||
}
|
||||
|
||||
index += 1;
|
||||
return {
|
||||
type: 'object',
|
||||
type: "object",
|
||||
start,
|
||||
end: index,
|
||||
properties,
|
||||
@@ -393,25 +396,25 @@ function parseJsoncAst(input, label) {
|
||||
skipTrivia();
|
||||
|
||||
const char = input[index];
|
||||
if (char === '{') {
|
||||
if (char === "{") {
|
||||
return parseObjectNode();
|
||||
}
|
||||
if (char === '[') {
|
||||
if (char === "[") {
|
||||
return parseArrayNode();
|
||||
}
|
||||
if (char === '"') {
|
||||
return parseStringNode();
|
||||
}
|
||||
if (char === 't') {
|
||||
return parseLiteralNode('true', true);
|
||||
if (char === "t") {
|
||||
return parseLiteralNode("true", true);
|
||||
}
|
||||
if (char === 'f') {
|
||||
return parseLiteralNode('false', false);
|
||||
if (char === "f") {
|
||||
return parseLiteralNode("false", false);
|
||||
}
|
||||
if (char === 'n') {
|
||||
return parseLiteralNode('null', null);
|
||||
if (char === "n") {
|
||||
return parseLiteralNode("null", null);
|
||||
}
|
||||
if (char === '-' || /\d/.test(char ?? '')) {
|
||||
if (char === "-" || /\d/.test(char ?? "")) {
|
||||
return parseNumberNode();
|
||||
}
|
||||
|
||||
@@ -426,7 +429,7 @@ function parseJsoncAst(input, label) {
|
||||
fail(`unexpected trailing content at offset ${index}`);
|
||||
}
|
||||
|
||||
if (root.type !== 'object') {
|
||||
if (root.type !== "object") {
|
||||
throw new Error(`${label} must contain a JSON object at the root.`);
|
||||
}
|
||||
|
||||
@@ -434,31 +437,31 @@ function parseJsoncAst(input, label) {
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function detectIndentUnit(input) {
|
||||
const matches = input.match(/^( +|\t+)\S/m);
|
||||
if (!matches) {
|
||||
return ' ';
|
||||
return " ";
|
||||
}
|
||||
|
||||
return matches[1];
|
||||
}
|
||||
|
||||
function detectEol(input) {
|
||||
return input.includes('\r\n') ? '\r\n' : '\n';
|
||||
return input.includes("\r\n") ? "\r\n" : "\n";
|
||||
}
|
||||
|
||||
function lineStartIndex(input, index) {
|
||||
const newlineIndex = input.lastIndexOf('\n', index - 1);
|
||||
const newlineIndex = input.lastIndexOf("\n", index - 1);
|
||||
return newlineIndex === -1 ? 0 : newlineIndex + 1;
|
||||
}
|
||||
|
||||
function lineIndentAt(input, index) {
|
||||
const start = lineStartIndex(input, index);
|
||||
let end = start;
|
||||
while (end < input.length && (input[end] === ' ' || input[end] === '\t')) {
|
||||
while (end < input.length && (input[end] === " " || input[end] === "\t")) {
|
||||
end += 1;
|
||||
}
|
||||
return input.slice(start, end);
|
||||
@@ -466,12 +469,12 @@ function lineIndentAt(input, index) {
|
||||
|
||||
function renderValue(value, propertyIndent, indentUnit, eol) {
|
||||
const raw = JSON.stringify(value, null, indentUnit);
|
||||
if (!raw.includes('\n')) {
|
||||
if (!raw.includes("\n")) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return raw
|
||||
.split('\n')
|
||||
.split("\n")
|
||||
.map((line, index) => (index === 0 ? line : `${propertyIndent}${line}`))
|
||||
.join(eol);
|
||||
}
|
||||
@@ -488,19 +491,35 @@ function getObjectChildIndent(input, objectNode, indentUnit) {
|
||||
return `${lineIndentAt(input, objectNode.start)}${indentUnit}`;
|
||||
}
|
||||
|
||||
function buildMergeEdits(input, objectNode, managedSettings, indentUnit, eol, edits) {
|
||||
function buildMergeEdits(
|
||||
input,
|
||||
objectNode,
|
||||
managedSettings,
|
||||
indentUnit,
|
||||
eol,
|
||||
edits,
|
||||
) {
|
||||
const missingEntries = [];
|
||||
|
||||
for (const [key, managedValue] of Object.entries(managedSettings)) {
|
||||
const property = objectNode.properties.find((candidate) => candidate.key === key);
|
||||
const property = objectNode.properties.find(
|
||||
(candidate) => candidate.key === key,
|
||||
);
|
||||
|
||||
if (!property) {
|
||||
missingEntries.push([key, managedValue]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainObject(managedValue) && property.value.type === 'object') {
|
||||
buildMergeEdits(input, property.value, managedValue, indentUnit, eol, edits);
|
||||
if (isPlainObject(managedValue) && property.value.type === "object") {
|
||||
buildMergeEdits(
|
||||
input,
|
||||
property.value,
|
||||
managedValue,
|
||||
indentUnit,
|
||||
eol,
|
||||
edits,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -520,14 +539,18 @@ function buildMergeEdits(input, objectNode, managedSettings, indentUnit, eol, ed
|
||||
|
||||
const propertyIndent = getObjectChildIndent(input, objectNode, indentUnit);
|
||||
const closingIndent = lineIndentAt(input, objectNode.end);
|
||||
const closingBraceIndex = objectNode.end - 1;
|
||||
const renderedProperties = missingEntries
|
||||
.map(([key, value]) => `${propertyIndent}${renderProperty(key, value, propertyIndent, indentUnit, eol)}`)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${propertyIndent}${renderProperty(key, value, propertyIndent, indentUnit, eol)}`,
|
||||
)
|
||||
.join(`,${eol}`);
|
||||
|
||||
if (objectNode.properties.length === 0) {
|
||||
edits.push({
|
||||
start: objectNode.start + 1,
|
||||
end: objectNode.end,
|
||||
end: closingBraceIndex,
|
||||
text: `${eol}${renderedProperties}${eol}${closingIndent}`,
|
||||
});
|
||||
return;
|
||||
@@ -538,7 +561,7 @@ function buildMergeEdits(input, objectNode, managedSettings, indentUnit, eol, ed
|
||||
edits.push({
|
||||
start: lastProperty.value.end,
|
||||
end: lastProperty.value.end,
|
||||
text: ',',
|
||||
text: ",",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -558,27 +581,36 @@ function applyEdits(input, edits) {
|
||||
return edits
|
||||
.sort((left, right) => right.start - left.start || right.end - left.end)
|
||||
.reduce(
|
||||
(text, edit) => `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`,
|
||||
input
|
||||
(text, edit) =>
|
||||
`${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const templateText = fs.readFileSync(options.template, 'utf8');
|
||||
const templateText = fs.readFileSync(options.template, "utf8");
|
||||
const renderedTemplate = renderTemplate(templateText, options.replacements);
|
||||
const managedSettings = parseJsonc(renderedTemplate, options.template);
|
||||
|
||||
const targetText = fs.existsSync(options.target)
|
||||
? fs.readFileSync(options.target, 'utf8')
|
||||
: '{}\n';
|
||||
? fs.readFileSync(options.target, "utf8")
|
||||
: "{}\n";
|
||||
const targetAst = parseJsoncAst(targetText, options.target);
|
||||
const indentUnit = detectIndentUnit(targetText);
|
||||
const eol = detectEol(targetText);
|
||||
const edits = [];
|
||||
|
||||
buildMergeEdits(targetText, targetAst, managedSettings, indentUnit, eol, edits);
|
||||
const output = edits.length === 0 ? targetText : applyEdits(targetText, edits);
|
||||
buildMergeEdits(
|
||||
targetText,
|
||||
targetAst,
|
||||
managedSettings,
|
||||
indentUnit,
|
||||
eol,
|
||||
edits,
|
||||
);
|
||||
const output =
|
||||
edits.length === 0 ? targetText : applyEdits(targetText, edits);
|
||||
|
||||
parseJsonc(output, options.target);
|
||||
|
||||
@@ -589,8 +621,8 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(options.target, output, 'utf8');
|
||||
fs.writeFileSync(options.target, output, "utf8");
|
||||
console.log(`Merged managed VS Code settings into: ${options.target}`);
|
||||
}
|
||||
|
||||
main();
|
||||
main();
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const mergeScript = path.join(scriptDir, 'merge-vscode-settings.mjs');
|
||||
const templateFile = path.join(repoRoot, 'config', 'vscode', 'settings.template.jsonc');
|
||||
const repoRoot = path.resolve(scriptDir, "..");
|
||||
const mergeScript = path.join(scriptDir, "merge-vscode-settings.mjs");
|
||||
const templateFile = path.join(
|
||||
repoRoot,
|
||||
"config",
|
||||
"vscode",
|
||||
"settings.template.jsonc",
|
||||
);
|
||||
|
||||
function stripJsonComments(input) {
|
||||
let output = '';
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaping = false;
|
||||
let inLineComment = false;
|
||||
@@ -23,7 +28,7 @@ function stripJsonComments(input) {
|
||||
const next = input[index + 1];
|
||||
|
||||
if (inLineComment) {
|
||||
if (char === '\n') {
|
||||
if (char === "\n") {
|
||||
inLineComment = false;
|
||||
output += char;
|
||||
}
|
||||
@@ -31,10 +36,10 @@ function stripJsonComments(input) {
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (char === '*' && next === '/') {
|
||||
if (char === "*" && next === "/") {
|
||||
inBlockComment = false;
|
||||
index += 1;
|
||||
} else if (char === '\n' || char === '\r') {
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
output += char;
|
||||
}
|
||||
continue;
|
||||
@@ -44,7 +49,7 @@ function stripJsonComments(input) {
|
||||
output += char;
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
} else if (char === '\\') {
|
||||
} else if (char === "\\") {
|
||||
escaping = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
@@ -58,13 +63,13 @@ function stripJsonComments(input) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '/') {
|
||||
if (char === "/" && next === "/") {
|
||||
inLineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '*') {
|
||||
if (char === "/" && next === "*") {
|
||||
inBlockComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
@@ -77,7 +82,7 @@ function stripJsonComments(input) {
|
||||
}
|
||||
|
||||
function stripTrailingCommas(input) {
|
||||
let output = '';
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaping = false;
|
||||
|
||||
@@ -88,7 +93,7 @@ function stripTrailingCommas(input) {
|
||||
output += char;
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
} else if (char === '\\') {
|
||||
} else if (char === "\\") {
|
||||
escaping = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
@@ -102,12 +107,12 @@ function stripTrailingCommas(input) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ',') {
|
||||
if (char === ",") {
|
||||
let lookahead = index + 1;
|
||||
while (lookahead < input.length && /\s/.test(input[lookahead])) {
|
||||
lookahead += 1;
|
||||
}
|
||||
if (input[lookahead] === '}' || input[lookahead] === ']') {
|
||||
if (input[lookahead] === "}" || input[lookahead] === "]") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -127,23 +132,23 @@ function runMerge(targetFile) {
|
||||
process.execPath,
|
||||
[
|
||||
mergeScript,
|
||||
'--target',
|
||||
"--target",
|
||||
targetFile,
|
||||
'--template',
|
||||
"--template",
|
||||
templateFile,
|
||||
'--set',
|
||||
'COPILOT_RESOURCES_HOME=/repo/home',
|
||||
"--set",
|
||||
"COPILOT_RESOURCES_HOME=/repo/home",
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
}
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('preserves comments and custom nested entries while inserting managed settings', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-settings-'));
|
||||
const targetFile = path.join(tempDir, 'settings.json');
|
||||
test("preserves comments and custom nested entries while inserting managed settings", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-settings-"));
|
||||
const targetFile = path.join(tempDir, "settings.json");
|
||||
|
||||
fs.writeFileSync(
|
||||
targetFile,
|
||||
@@ -163,13 +168,13 @@ test('preserves comments and custom nested entries while inserting managed setti
|
||||
}
|
||||
}
|
||||
`,
|
||||
'utf8'
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = runMerge(targetFile);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
|
||||
const output = fs.readFileSync(targetFile, 'utf8');
|
||||
const output = fs.readFileSync(targetFile, "utf8");
|
||||
assert.match(output, /\/\/ keep this comment/);
|
||||
assert.match(output, /\/\/ preserve nested comments/);
|
||||
assert.match(output, /\/\/ custom agents stay/);
|
||||
@@ -177,18 +182,32 @@ test('preserves comments and custom nested entries while inserting managed setti
|
||||
assert.match(output, /"\/repo\/home\/resources\/agents": true/);
|
||||
|
||||
const parsed = parseJsonc(output);
|
||||
assert.equal(parsed['workbench.colorTheme'], 'GitHub Dark Mode');
|
||||
assert.equal(parsed['chat.agentFilesLocations']['/custom/agents'], true);
|
||||
assert.equal(parsed['chat.agentFilesLocations']['/repo/home/resources/agents'], true);
|
||||
assert.equal(parsed['chat.agentFilesLocations']['~/.copilot/agents'], true);
|
||||
assert.equal(parsed['chat.instructionsFilesLocations']['/old/instructions'], true);
|
||||
assert.equal(parsed['chat.instructionsFilesLocations']['/repo/home/resources/instructions'], true);
|
||||
assert.equal(parsed['chat.instructionsFilesLocations']['~/.claude/rules'], true);
|
||||
assert.equal(parsed["workbench.colorTheme"], "GitHub Dark Mode");
|
||||
assert.equal(parsed["chat.agentFilesLocations"]["/custom/agents"], true);
|
||||
assert.equal(
|
||||
parsed["chat.agentFilesLocations"]["/repo/home/resources/agents"],
|
||||
true,
|
||||
);
|
||||
assert.equal(parsed["chat.agentFilesLocations"]["~/.copilot/agents"], true);
|
||||
assert.equal(
|
||||
parsed["chat.instructionsFilesLocations"]["/old/instructions"],
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
parsed["chat.instructionsFilesLocations"][
|
||||
"/repo/home/resources/instructions"
|
||||
],
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
parsed["chat.instructionsFilesLocations"]["~/.claude/rules"],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('second run is idempotent and keeps the file text unchanged', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-settings-'));
|
||||
const targetFile = path.join(tempDir, 'settings.json');
|
||||
test("second run is idempotent and keeps the file text unchanged", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-settings-"));
|
||||
const targetFile = path.join(tempDir, "settings.json");
|
||||
|
||||
fs.writeFileSync(
|
||||
targetFile,
|
||||
@@ -199,18 +218,40 @@ test('second run is idempotent and keeps the file text unchanged', () => {
|
||||
}
|
||||
}
|
||||
`,
|
||||
'utf8'
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const firstRun = runMerge(targetFile);
|
||||
assert.equal(firstRun.status, 0, firstRun.stderr);
|
||||
const firstOutput = fs.readFileSync(targetFile, 'utf8');
|
||||
const firstOutput = fs.readFileSync(targetFile, "utf8");
|
||||
|
||||
const secondRun = runMerge(targetFile);
|
||||
assert.equal(secondRun.status, 0, secondRun.stderr);
|
||||
assert.match(secondRun.stdout, /already up to date/);
|
||||
|
||||
const secondOutput = fs.readFileSync(targetFile, 'utf8');
|
||||
const secondOutput = fs.readFileSync(targetFile, "utf8");
|
||||
assert.equal(secondOutput, firstOutput);
|
||||
assert.match(secondOutput, /\/\/ user comment/);
|
||||
});
|
||||
});
|
||||
|
||||
test("creates a valid settings file from an empty target", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-settings-"));
|
||||
const targetFile = path.join(tempDir, "settings.json");
|
||||
|
||||
fs.writeFileSync(targetFile, "{}\n", "utf8");
|
||||
|
||||
const result = runMerge(targetFile);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
|
||||
const parsed = parseJsonc(fs.readFileSync(targetFile, "utf8"));
|
||||
assert.equal(
|
||||
parsed["chat.agentFilesLocations"]["/repo/home/resources/agents"],
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
parsed["chat.instructionsFilesLocations"][
|
||||
"/repo/home/resources/instructions"
|
||||
],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
0
install/publish.sh
Normal file → Executable file
0
install/publish.sh
Normal file → Executable file
@@ -9,4 +9,5 @@ if (Test-Path -LiteralPath (Join-Path $RepoRoot '.git')) {
|
||||
Write-Host 'Skipping git pull because this repository is not initialized as a git repository yet.'
|
||||
}
|
||||
|
||||
& (Join-Path $ScriptDir 'bootstrap.ps1')
|
||||
& (Join-Path $ScriptDir 'verify.ps1') -Quick
|
||||
|
||||
1
install/update.sh
Normal file → Executable file
1
install/update.sh
Normal file → Executable file
@@ -12,6 +12,7 @@ main() {
|
||||
printf 'Skipping git pull because this repository is not initialized as a git repository yet.\n'
|
||||
fi
|
||||
|
||||
"$script_dir/bootstrap.sh"
|
||||
"$script_dir/verify.sh" --quick
|
||||
}
|
||||
|
||||
|
||||
@@ -23,15 +23,52 @@ function Assert-Path {
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Command {
|
||||
param(
|
||||
[string]$Label,
|
||||
[string]$CommandName
|
||||
)
|
||||
|
||||
if (Get-Command $CommandName -ErrorAction SilentlyContinue) {
|
||||
Write-Host "[ok] $Label: $CommandName"
|
||||
} else {
|
||||
throw "Missing $Label: $CommandName"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ReadableFile {
|
||||
param(
|
||||
[string]$Label,
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if ((Test-Path -LiteralPath $Path -PathType Leaf) -and (Get-Item -LiteralPath $Path).PSIsContainer -eq $false) {
|
||||
Write-Host "[ok] $Label: $Path"
|
||||
} else {
|
||||
throw "Unreadable $Label: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
Assert-Path -Label 'repo root' -Path $RepoRoot
|
||||
Assert-Path -Label 'canonical home' -Path $CanonicalHome
|
||||
Assert-Path -Label 'skills link' -Path (Join-Path $CopilotHome 'skills')
|
||||
Assert-Path -Label 'agents link' -Path (Join-Path $CopilotHome 'agents')
|
||||
Assert-Path -Label 'instructions link' -Path (Join-Path $CopilotHome 'instructions')
|
||||
Assert-Path -Label 'hooks link' -Path (Join-Path $CopilotHome 'hooks')
|
||||
Assert-Path -Label 'session start hook' -Path (Join-Path $CanonicalHome 'resources\hooks\session-audit.json')
|
||||
Assert-Path -Label 'session start hook script' -Path (Join-Path $CanonicalHome 'resources\scripts\report-hook-event.sh')
|
||||
Assert-ReadableFile -Label 'session start hook script' -Path (Join-Path $CanonicalHome 'resources\scripts\report-hook-event.sh')
|
||||
Assert-Command -Label 'session start hook shell' -CommandName 'bash'
|
||||
Assert-Path -Label 'prompts link' -Path (Join-Path $VscodeUserDir 'prompts')
|
||||
Assert-Path -Label 'VS Code MCP config' -Path (Join-Path $VscodeUserDir 'mcp.json')
|
||||
Assert-Path -Label 'Copilot CLI MCP config' -Path (Join-Path $CopilotHome 'mcp-config.json')
|
||||
Assert-Path -Label 'local MCP overrides' -Path (Join-Path $CanonicalHome '.local\mcp.local.jsonc')
|
||||
|
||||
if (-not $Quick) {
|
||||
Assert-Path -Label 'VS Code settings template' -Path (Join-Path $RepoRoot 'config\vscode\settings.template.jsonc')
|
||||
Assert-Path -Label 'CLI env template' -Path (Join-Path $RepoRoot 'config\copilot-cli\env.example.ps1')
|
||||
Assert-Path -Label 'VS Code MCP template' -Path (Join-Path $RepoRoot 'config\mcp\vscode.mcp.template.jsonc')
|
||||
Assert-Path -Label 'Copilot CLI MCP template' -Path (Join-Path $RepoRoot 'config\mcp\copilot-cli.mcp.template.jsonc')
|
||||
Assert-Path -Label 'MCP local override example' -Path (Join-Path $RepoRoot 'config\mcp\local-overrides.example.jsonc')
|
||||
Assert-Path -Label 'Copilot CLI filesystem wrapper' -Path (Join-Path $RepoRoot 'install\mcp\copilot-cli-filesystem-wrapper.mjs')
|
||||
}
|
||||
|
||||
40
install/verify.sh
Normal file → Executable file
40
install/verify.sh
Normal file → Executable file
@@ -39,6 +39,30 @@ check_path() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_command() {
|
||||
local label="$1"
|
||||
local command_name="$2"
|
||||
|
||||
if command -v "$command_name" >/dev/null 2>&1; then
|
||||
printf '[ok] %s: %s\n' "$label" "$command_name"
|
||||
else
|
||||
printf '[missing] %s: %s\n' "$label" "$command_name" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_readable_file() {
|
||||
local label="$1"
|
||||
local path="$2"
|
||||
|
||||
if [[ -r "$path" ]]; then
|
||||
printf '[ok] %s: %s\n' "$label" "$path"
|
||||
else
|
||||
printf '[unreadable] %s: %s\n' "$label" "$path" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ "${1:-}" == "--quick" ]]; then
|
||||
quick="true"
|
||||
@@ -46,6 +70,11 @@ main() {
|
||||
|
||||
local vscode_user_dir
|
||||
vscode_user_dir="$(detect_vscode_user_dir)"
|
||||
local vscode_mcp_file="$vscode_user_dir/mcp.json"
|
||||
local copilot_cli_mcp_file="$copilot_home/mcp-config.json"
|
||||
local local_mcp_overrides_file="$canonical_home/.local/mcp.local.jsonc"
|
||||
local session_start_hook_file="$canonical_home/resources/hooks/session-audit.json"
|
||||
local session_start_hook_script="$canonical_home/resources/scripts/report-hook-event.sh"
|
||||
|
||||
check_path "repo root" "$repo_root"
|
||||
check_path "canonical home" "$canonical_home"
|
||||
@@ -53,11 +82,22 @@ main() {
|
||||
check_path "agents link" "$copilot_home/agents"
|
||||
check_path "instructions link" "$copilot_home/instructions"
|
||||
check_path "hooks link" "$copilot_home/hooks"
|
||||
check_path "session start hook" "$session_start_hook_file"
|
||||
check_path "session start hook script" "$session_start_hook_script"
|
||||
check_readable_file "session start hook script" "$session_start_hook_script"
|
||||
check_command "session start hook shell" "bash"
|
||||
check_path "prompts link" "$vscode_user_dir/prompts"
|
||||
check_path "VS Code MCP config" "$vscode_mcp_file"
|
||||
check_path "Copilot CLI MCP config" "$copilot_cli_mcp_file"
|
||||
check_path "local MCP overrides" "$local_mcp_overrides_file"
|
||||
|
||||
if [[ "$quick" != "true" ]]; then
|
||||
check_path "VS Code settings template" "$repo_root/config/vscode/settings.template.jsonc"
|
||||
check_path "CLI env template" "$repo_root/config/copilot-cli/env.example.sh"
|
||||
check_path "VS Code MCP template" "$repo_root/config/mcp/vscode.mcp.template.jsonc"
|
||||
check_path "Copilot CLI MCP template" "$repo_root/config/mcp/copilot-cli.mcp.template.jsonc"
|
||||
check_path "MCP local override example" "$repo_root/config/mcp/local-overrides.example.jsonc"
|
||||
check_path "Copilot CLI filesystem wrapper" "$repo_root/install/mcp/copilot-cli-filesystem-wrapper.mjs"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user