🛠️ Update various documentation, scripts, and configuration templates to enhance clarity, functionality, and maintainability across the project

This commit is contained in:
2026-05-04 10:56:41 +00:00
parent 1a2f1510bf
commit 31975e3088
41 changed files with 4184 additions and 133 deletions

View File

@@ -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
View 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

View 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();

View 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();

View 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/);
});

View File

@@ -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();

View File

@@ -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
View File

View 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
View 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
}

View File

@@ -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
View 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
}