Compare commits
2 Commits
1a2f1510bf
...
107f8a2691
| Author | SHA1 | Date | |
|---|---|---|---|
| 107f8a2691 | |||
| 31975e3088 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.local/
|
||||
|
||||
15
README.md
15
README.md
@@ -20,6 +20,7 @@ This initial implementation provides:
|
||||
- Publish, update, and verify scripts
|
||||
- Scripted VS Code settings merge for managed Copilot-related keys
|
||||
- Scripted Copilot CLI environment wiring through a managed sourced fragment
|
||||
- Scripted managed MCP config generation for VS Code and Copilot CLI
|
||||
|
||||
## Operating Model
|
||||
|
||||
@@ -51,6 +52,20 @@ per-machine copies. They also merge the managed VS Code settings keys and add a
|
||||
small managed shell or PowerShell profile block for Copilot CLI environment
|
||||
variables without replacing the rest of the user config.
|
||||
|
||||
Bootstrap also generates managed user-level MCP configuration for VS Code and
|
||||
Copilot CLI from the tracked templates in `config/mcp/`. Machine-local MCP
|
||||
values live in `.local/mcp.local.jsonc`, which bootstrap creates from the
|
||||
tracked example file on first run.
|
||||
|
||||
Today the managed MCP set is:
|
||||
|
||||
- Playwright for VS Code
|
||||
- Filesystem for VS Code and Copilot CLI
|
||||
- Gitea/Forgejo for VS Code and Copilot CLI when enabled locally
|
||||
|
||||
`install/update.*` now reruns bootstrap after pulling so those managed config
|
||||
files propagate when this repository changes.
|
||||
|
||||
## Next Docs
|
||||
|
||||
The rest of the handbook will live in `docs/` and will cover architecture,
|
||||
|
||||
66
config/mcp/copilot-cli.mcp.template.jsonc
Normal file
66
config/mcp/copilot-cli.mcp.template.jsonc
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
// Managed MCP servers for the GitHub Copilot CLI user profile.
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"{{COPILOT_RESOURCES_HOME}}/install/mcp/copilot-cli-filesystem-wrapper.mjs"
|
||||
],
|
||||
"timeout": 30000
|
||||
},
|
||||
"gitea": {
|
||||
"type": "stdio",
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"FORGEJOMCP_SERVER",
|
||||
"-e",
|
||||
"FORGEJOMCP_TOKEN",
|
||||
"ronmi/forgejo-mcp",
|
||||
"stdio"
|
||||
],
|
||||
"env": {
|
||||
"FORGEJOMCP_SERVER": "{{GITEA_SERVER_URL}}",
|
||||
"FORGEJOMCP_TOKEN": "{{GITEA_TOKEN}}"
|
||||
},
|
||||
"tools": [
|
||||
"search_repositories",
|
||||
"list_my_repositories",
|
||||
"list_org_repositories",
|
||||
"get_repository",
|
||||
"list_repo_issues",
|
||||
"get_issue",
|
||||
"create_issue",
|
||||
"edit_issue",
|
||||
"list_issue_comments",
|
||||
"add_issue_labels",
|
||||
"remove_issue_label",
|
||||
"replace_issue_labels",
|
||||
"list_repo_labels",
|
||||
"create_label",
|
||||
"edit_label",
|
||||
"delete_label",
|
||||
"list_repo_milestones",
|
||||
"create_milestone",
|
||||
"edit_milestone",
|
||||
"delete_milestone",
|
||||
"list_releases",
|
||||
"create_release",
|
||||
"edit_release",
|
||||
"delete_release",
|
||||
"list_release_attachments",
|
||||
"list_pull_requests",
|
||||
"get_pull_request",
|
||||
"create_pull_request",
|
||||
"list_wiki_pages",
|
||||
"get_wiki_page",
|
||||
"list_action_tasks"
|
||||
],
|
||||
"timeout": 30000
|
||||
}
|
||||
}
|
||||
}
|
||||
17
config/mcp/local-overrides.example.jsonc
Normal file
17
config/mcp/local-overrides.example.jsonc
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
// Copy this file to .local/mcp.local.jsonc and replace the placeholder values.
|
||||
// The generated Gitea server is omitted unless enabled is true and both values are set.
|
||||
"servers": {
|
||||
"playwright": {
|
||||
"enabled": true
|
||||
},
|
||||
"filesystem": {
|
||||
"enabled": true
|
||||
},
|
||||
"gitea": {
|
||||
"enabled": false,
|
||||
"serverUrl": "https://git.example.com",
|
||||
"token": "replace-with-a-local-access-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
42
config/mcp/vscode.mcp.template.jsonc
Normal file
42
config/mcp/vscode.mcp.template.jsonc
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// Managed MCP servers for the VS Code user profile.
|
||||
"servers": {
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
},
|
||||
"filesystem": {
|
||||
"type": "stdio",
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"--mount",
|
||||
"type=bind,src=${workspaceFolder},dst=/projects/workspace",
|
||||
"mcp/filesystem",
|
||||
"/projects/workspace"
|
||||
]
|
||||
},
|
||||
"gitea": {
|
||||
"type": "stdio",
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"FORGEJOMCP_SERVER",
|
||||
"-e",
|
||||
"FORGEJOMCP_TOKEN",
|
||||
"ronmi/forgejo-mcp",
|
||||
"stdio"
|
||||
],
|
||||
"env": {
|
||||
"FORGEJOMCP_SERVER": "{{GITEA_SERVER_URL}}",
|
||||
"FORGEJOMCP_TOKEN": "{{GITEA_TOKEN}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
"chat.useAgentsMdFile": true,
|
||||
"chat.useClaudeMdFile": true,
|
||||
"chat.useCustomizationsInParentRepositories": true,
|
||||
|
||||
// Direct-path fallback configuration in case symlinked defaults are not preferred.
|
||||
"chat.instructionsFilesLocations": {
|
||||
"{{COPILOT_RESOURCES_HOME}}/resources/instructions": true,
|
||||
|
||||
@@ -13,6 +13,9 @@ Git-based after a resource is published.
|
||||
- `resources/instructions/`: shared instruction packs
|
||||
- `resources/agents/`: shared custom agents for local chat and overlays
|
||||
- `resources/hooks/`: shared hook definitions
|
||||
- `config/mcp/`: tracked MCP templates and example machine-local overrides
|
||||
- `install/merge-managed-mcp-config.mjs`: managed MCP merge and prune logic for user config files
|
||||
- `install/mcp/`: wrapper scripts for MCP servers that need runtime adaptation
|
||||
- `templates/repo-overlay/`: files that can be copied into another repository for
|
||||
repository-scoped behavior
|
||||
|
||||
@@ -29,6 +32,14 @@ Bootstrap prefers linking default discovery paths back to this repository:
|
||||
This keeps the repository authoritative while still using default discovery
|
||||
locations whenever possible.
|
||||
|
||||
For MCP, bootstrap uses generated user-level config instead of links:
|
||||
|
||||
- VS Code user `mcp.json`
|
||||
- Copilot CLI user `~/.copilot/mcp-config.json`
|
||||
|
||||
Those generated files come from tracked templates plus machine-local data in
|
||||
`.local/mcp.local.jsonc`.
|
||||
|
||||
## Propagation Model
|
||||
|
||||
There are only two supported creation paths:
|
||||
@@ -38,3 +49,11 @@ There are only two supported creation paths:
|
||||
|
||||
Once a resource lands in the repository, commit and push it. Other systems pick
|
||||
it up through `install/update.*` or future scheduled sync.
|
||||
|
||||
Managed MCP propagation follows the same rule, but with a split between shared
|
||||
and local inputs:
|
||||
|
||||
- Shared defaults and server definitions are tracked in the repo
|
||||
- Machine-local secrets and enablement stay in `.local/`
|
||||
- `install/update.*` pulls the repo and reruns bootstrap so the generated MCP
|
||||
files refresh from the latest templates on every machine
|
||||
|
||||
127
docs/audit-workflow.md
Normal file
127
docs/audit-workflow.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Audit Workflow
|
||||
|
||||
## Purpose
|
||||
|
||||
The audit workflow reviews the last 30 days of persisted local Copilot
|
||||
artifacts and produces a shortlist of reusable patterns worth promoting into the
|
||||
shared Copilot resource repository.
|
||||
|
||||
## Current Scope
|
||||
|
||||
- macOS first
|
||||
- read-only audit execution
|
||||
- file-based review interface
|
||||
- local per-machine audit history stored inside the repo checkout but excluded
|
||||
from git
|
||||
|
||||
## Command
|
||||
|
||||
```bash
|
||||
resources/scripts/audit-copilot-usage.sh --days 30
|
||||
```
|
||||
|
||||
The runner excludes the current `copilot-resources` repo root from workspace
|
||||
indexing by default so you do not review patterns that already live in the
|
||||
shared repository.
|
||||
|
||||
Optional filters:
|
||||
|
||||
```bash
|
||||
resources/scripts/audit-copilot-usage.sh --days 14 --workspace copilot-resources
|
||||
```
|
||||
|
||||
```bash
|
||||
resources/scripts/audit-copilot-usage.sh --days 30 --include-sources publish-log,repo-memories
|
||||
```
|
||||
|
||||
```bash
|
||||
resources/scripts/audit-copilot-usage.sh --days 30 --exclude-workspace /path/to/another/repo
|
||||
```
|
||||
|
||||
## Storage Model
|
||||
|
||||
Audit runs are written under:
|
||||
|
||||
```text
|
||||
.local/audits/<machine-id>/<timestamp>/
|
||||
```
|
||||
|
||||
The runner creates the machine directory automatically on first use. The entire
|
||||
`.local/` tree is git-ignored so each machine can keep its own audit history
|
||||
without creating commit noise.
|
||||
|
||||
## Inputs
|
||||
|
||||
The first implementation reads from these local sources when they exist:
|
||||
|
||||
- VS Code workspace transcripts under `~/Library/Application Support/Code/User/workspaceStorage/*/GitHub.copilot-chat/transcripts/`
|
||||
- Repo memories under `.../GitHub.copilot-chat/memory-tool/memories/repo/`
|
||||
- Shared resource publish history under `~/.copilot-resources-state/publish-log.tsv`
|
||||
|
||||
## Outputs
|
||||
|
||||
- `audit-summary.md` — high-level inventory and top candidates
|
||||
- `workspace-index.tsv` — workspaceStorage hash to workspace path mapping for the run
|
||||
- `candidates-report.tsv` — scored candidate list with source references plus transcript prompt-cost proxy columns such as `token_cost_signal`, `avg_prompt_chars`, and `repeated_prompt_chars`
|
||||
- `selection-manifest.tsv` — editable approval surface with `decision`, `review_note`, `detail_file`, and the same transcript prompt-cost proxy columns used during triage
|
||||
- `pattern-details/*.md` — per-candidate evidence bundles used during review, including purpose, expected benefit, audit context, score rationale, and caveats
|
||||
|
||||
Transcript prompt-cost fields are character-count proxies derived from persisted
|
||||
user message text. They help surface repeated long prompts that are likely to be
|
||||
more expensive, but they are not exact billing-token measurements.
|
||||
|
||||
## Selection Interface
|
||||
|
||||
Use `selection-manifest.tsv` as the review surface and `pattern-details/*.md` as
|
||||
the one-by-one evidence bundle for each candidate.
|
||||
|
||||
Inside Copilot, use the shared `review-audit-candidates` prompt to work through
|
||||
pending rows one at a time. That prompt should ask for a decision with buttons,
|
||||
capture optional freeform review notes, explain why the candidate exists and
|
||||
why it scored as it did, and update `selection-manifest.tsv` after each answer.
|
||||
|
||||
Recommended decision states:
|
||||
|
||||
- `promote-skill`
|
||||
- `promote-instruction`
|
||||
- `promote-agent`
|
||||
- `promote-hook`
|
||||
- `promote-script`
|
||||
- `promote-prompt`
|
||||
- `template-only`
|
||||
- `docs-only`
|
||||
- `discard`
|
||||
- `needs-sanitization`
|
||||
|
||||
## Promotion Rule
|
||||
|
||||
If a candidate cannot be explained as a portable skill, instruction, prompt
|
||||
adapter, agent behavior, hook, script, or template, it should stay in audit
|
||||
notes and not be promoted into the shared repo.
|
||||
|
||||
## Follow-on Draft Generation
|
||||
|
||||
After the manifest has approved rows, generate next-step artifacts with:
|
||||
|
||||
```bash
|
||||
resources/scripts/prepare-audit-promotions.sh
|
||||
```
|
||||
|
||||
Or target a specific run:
|
||||
|
||||
```bash
|
||||
resources/scripts/prepare-audit-promotions.sh --audit-dir .local/audits/<machine-id>/<timestamp>
|
||||
```
|
||||
|
||||
The command reads approved rows from `selection-manifest.tsv` and writes:
|
||||
|
||||
- `draft-resources/` for straightforward prompt, instruction, agent, and skill drafts
|
||||
- `staging-notes/` for approved rows that still need manual design work
|
||||
- `promotion-summary.md` to summarize what was generated
|
||||
|
||||
## Limits
|
||||
|
||||
- This audits persisted local artifacts only.
|
||||
- It does not capture every inline completion or transient UI suggestion.
|
||||
- Windows parity is deferred until the macOS workflow and report format are
|
||||
proven.
|
||||
@@ -10,6 +10,18 @@
|
||||
- Use an agent for a reusable persona with tool restrictions or handoffs.
|
||||
- Use a hook only when the behavior must be deterministic and enforced in code.
|
||||
|
||||
## Keep Shared Resources Cheap
|
||||
|
||||
- Reuse an existing shared resource before creating a new one.
|
||||
- Choose the cheapest sufficient primitive for the job instead of defaulting to
|
||||
a long prompt.
|
||||
- Keep frontmatter, descriptions, and embedded examples concise.
|
||||
- Prefer targeted inputs such as a file, symbol, or command over broad repo
|
||||
scans.
|
||||
- State the default output budget when the resource is meant to stay brief.
|
||||
- Say when the resource should not be used so it does not become a generic,
|
||||
expensive fallback.
|
||||
|
||||
## Supported Creation Paths
|
||||
|
||||
### Repo-first
|
||||
|
||||
59
docs/git-bootstrap-hygiene.md
Normal file
59
docs/git-bootstrap-hygiene.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Git Bootstrap Hygiene
|
||||
|
||||
## Purpose
|
||||
|
||||
This guidance helps when a repository is being created, normalized, or cleaned
|
||||
up early in its life so temporary local material does not leak into shared Git
|
||||
history and the first committed shape of the repo is intentional.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Early repository mistakes tend to persist. Temporary import trees, scratch
|
||||
directories, local caches, and vendor copies are easy to commit accidentally
|
||||
when ignore rules and branch decisions are still unsettled. A small amount of
|
||||
bootstrap hygiene keeps those mistakes out of long-term history.
|
||||
|
||||
## Core Guidance
|
||||
|
||||
- Identify scratch, cache, vendor-copy, and other temporary paths before the
|
||||
first shared commit and keep them untracked unless there is an explicit
|
||||
decision to promote them.
|
||||
- Confirm the intended default branch before the first shared commit. Use the
|
||||
project's branch policy when one exists; otherwise use a consistent default
|
||||
such as `main`.
|
||||
- Make the first tracked commit only after the repository's ignore rules and
|
||||
basic bootstrap layout reflect the intended long-term structure.
|
||||
- If temporary content later becomes real project content, move or regenerate it
|
||||
into the correct tracked location instead of committing the original scratch
|
||||
tree as-is.
|
||||
- Keep repo-specific exceptions in repo-local setup notes or docs instead of
|
||||
putting one-off paths, hashes, or names into shared guidance.
|
||||
|
||||
## Good Outcomes
|
||||
|
||||
- The initial history starts from an intentional branch and file layout.
|
||||
- Local-only material stays local until it is deliberately promoted.
|
||||
- Repositories keep fewer one-off cleanup commits related to accidental early
|
||||
tracking.
|
||||
- Shared guidance stays reusable because repo-specific details remain local.
|
||||
|
||||
## What This Is Not
|
||||
|
||||
- It is not a replacement for project-specific setup documentation.
|
||||
- It does not tell a team which branch policy to adopt; it only says to choose
|
||||
that policy intentionally before the first shared commit.
|
||||
- It does not forbid generated or vendor content from being tracked when a
|
||||
project intentionally relies on that model.
|
||||
|
||||
## Typical Signals That This Guidance Applies
|
||||
|
||||
- A repository is brand new and the first commit has not happened yet.
|
||||
- A local import or migration dropped temporary files into the repo root.
|
||||
- Scratch or cache directories are present and the ignore rules are not settled.
|
||||
- The team is still deciding how bootstrap files and initial layout should look.
|
||||
|
||||
## Local Exceptions
|
||||
|
||||
When a repository intentionally tracks generated assets, mirrored vendor code,
|
||||
or unusual bootstrap files, document that exception in the repo's own setup or
|
||||
contributing docs instead of weakening the shared rule for every other project.
|
||||
@@ -21,3 +21,25 @@ install/verify.ps1
|
||||
```
|
||||
|
||||
Scheduled sync will be added on top of the same update and verify entrypoints.
|
||||
|
||||
## Audit
|
||||
|
||||
```bash
|
||||
resources/scripts/audit-copilot-usage.sh --days 30
|
||||
```
|
||||
|
||||
The audit workflow is macOS-first in this iteration. It writes per-machine audit
|
||||
history under `.local/audits/<machine-id>/` in the repo checkout and keeps that
|
||||
runtime state out of git.
|
||||
|
||||
After a run is generated, use the `review-audit-candidates` prompt in Copilot
|
||||
to work through `selection-manifest.tsv` one candidate at a time.
|
||||
|
||||
When rows are approved, generate draft resources or staging notes with:
|
||||
|
||||
```bash
|
||||
resources/scripts/prepare-audit-promotions.sh
|
||||
```
|
||||
|
||||
See `docs/audit-workflow.md` for the report format, selection manifest, and
|
||||
promotion rules.
|
||||
|
||||
@@ -8,3 +8,15 @@ A new shared resource should be reviewed for:
|
||||
- Security implications, especially for hooks and scripts
|
||||
- Clear purpose and expected usage
|
||||
- Duplication with existing resources
|
||||
- Cheapest sufficient primitive for the intended workflow
|
||||
- Justified context size, including whether long examples or repeated guidance
|
||||
can be moved into docs or scripts instead
|
||||
- An intentional default output budget instead of open-ended verbosity
|
||||
- Clear limits on when the resource should not be used
|
||||
|
||||
Audit candidates should also be reviewed for:
|
||||
|
||||
- Clear provenance back to an audit summary or selection manifest
|
||||
- Portable fit as a skill, instruction, prompt adapter, agent, hook, script, or template
|
||||
- Absence of secrets, machine-specific paths, or repo-specific assumptions
|
||||
- A justified decision when the right outcome is `template-only`, `docs-only`, or `discard`
|
||||
|
||||
@@ -17,7 +17,9 @@ install/bootstrap.ps1
|
||||
- Creates a canonical path at `~/.copilot-resources`
|
||||
- Links default discovery locations back to this repository
|
||||
- Merges only the managed Copilot-related VS Code settings into the user settings file
|
||||
- Generates managed VS Code and Copilot CLI MCP config files from the tracked templates in `config/mcp/`
|
||||
- Writes a managed Copilot CLI environment fragment and sources it from the shell or PowerShell profile
|
||||
- Creates `.local/mcp.local.jsonc` from the tracked example if the machine-local MCP override file does not exist yet
|
||||
- Writes a local install-state file outside the repository
|
||||
|
||||
## Optional Settings
|
||||
@@ -31,3 +33,32 @@ possible.
|
||||
Bootstrap also writes a managed Copilot CLI environment file into the local
|
||||
state directory and adds a small managed source block to the active shell or
|
||||
PowerShell profile instead of replacing the whole profile.
|
||||
|
||||
Bootstrap generates user-level MCP config as well:
|
||||
|
||||
- VS Code user `mcp.json`
|
||||
- Copilot CLI user `~/.copilot/mcp-config.json`
|
||||
|
||||
Those files are rendered from the tracked templates in `config/mcp/` and merged
|
||||
in place so unmanaged MCP server entries are preserved. Managed MCP servers that
|
||||
are no longer desired are removed.
|
||||
|
||||
Machine-local MCP inputs live in `.local/mcp.local.jsonc`. The tracked example
|
||||
file starts with Gitea disabled. Enable it per machine by editing that local
|
||||
file and providing:
|
||||
|
||||
- `servers.gitea.enabled: true`
|
||||
- `servers.gitea.serverUrl`
|
||||
- `servers.gitea.token`
|
||||
|
||||
Current managed MCP behavior:
|
||||
|
||||
- VS Code gets Playwright, Filesystem, and optional Gitea
|
||||
- Copilot CLI gets Filesystem and optional Gitea
|
||||
- Copilot CLI Playwright is intentionally omitted because it already ships as a built-in MCP server
|
||||
|
||||
`install/update.sh` and `install/update.ps1` rerun bootstrap after `git pull`,
|
||||
so managed MCP config changes propagate on update.
|
||||
|
||||
For generic Git repository bootstrap hygiene outside the installation flow of
|
||||
this shared resources repo, see `docs/git-bootstrap-hygiene.md`.
|
||||
|
||||
@@ -15,6 +15,22 @@ Treat hot reload as likely but not guaranteed. If needed:
|
||||
- restart the Copilot CLI session
|
||||
- reopen VS Code
|
||||
|
||||
## Session Start Hook Permission Denied
|
||||
|
||||
If you see `/bin/sh: .../report-hook-event.sh: Permission denied` when a Copilot
|
||||
session starts:
|
||||
|
||||
- Pull the latest repo changes and rerun `install/update.sh`.
|
||||
- If you need to refresh links and generated config without pulling, rerun
|
||||
`install/bootstrap.sh`.
|
||||
- Run `install/verify.sh --quick` and resolve any missing managed files it
|
||||
reports.
|
||||
- Start a new Copilot session and confirm
|
||||
`~/.copilot-resources-state/hook-events.log` receives a new JSON line.
|
||||
|
||||
The shared hook now invokes the shell script through `bash`, so the session
|
||||
start hook no longer depends on the script file itself being executable.
|
||||
|
||||
## Publish Refused To Overwrite
|
||||
|
||||
The publish scripts stop on collisions by default. Use a new name or rerun with
|
||||
|
||||
@@ -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,7 +621,7 @@ 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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"SessionStart": [
|
||||
{
|
||||
"type": "command",
|
||||
"osx": "~/.copilot-resources/resources/scripts/report-hook-event.sh",
|
||||
"linux": "~/.copilot-resources/resources/scripts/report-hook-event.sh",
|
||||
"osx": "bash ~/.copilot-resources/resources/scripts/report-hook-event.sh",
|
||||
"linux": "bash ~/.copilot-resources/resources/scripts/report-hook-event.sh",
|
||||
"windows": "powershell -NoProfile -File \"$HOME/.copilot-resources/resources/scripts/report-hook-event.ps1\""
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
description: "Use when authoring or editing shared Copilot resources that should stay lightweight and inexpensive to run."
|
||||
applyTo: "resources/prompts/**/*.prompt.md,resources/instructions/**/*.instructions.md,resources/agents/**/*.agent.md,resources/skills/**/SKILL.md"
|
||||
---
|
||||
|
||||
- Reuse an existing shared resource before creating a new one.
|
||||
- Prefer the cheapest sufficient primitive: instruction for durable rules, skill
|
||||
for portable workflows, prompt for thin VS Code entrypoints, hook only for
|
||||
deterministic enforced behavior.
|
||||
- Keep frontmatter, examples, and repeated policy text short.
|
||||
- Ask for narrow inputs such as a file, symbol, or command instead of broad
|
||||
workspace scans when possible.
|
||||
- Bias the resource toward concise outputs unless the task clearly needs depth.
|
||||
- Say when the resource should not be used if that prevents broad, expensive
|
||||
misuse.
|
||||
@@ -1,5 +1,38 @@
|
||||
# MCP Notes
|
||||
|
||||
This folder is reserved for reusable MCP references and safe shared
|
||||
configuration snippets. Machine-specific secrets and authenticated local server
|
||||
definitions should stay out of the repository.
|
||||
This repository manages shared MCP configuration through tracked templates plus
|
||||
machine-local overrides.
|
||||
|
||||
## What Is Tracked
|
||||
|
||||
- Shared templates live in `config/mcp/`.
|
||||
- The merge logic lives in `install/merge-managed-mcp-config.mjs`.
|
||||
- The Copilot CLI filesystem wrapper lives in `install/mcp/`.
|
||||
|
||||
## What Stays Local
|
||||
|
||||
- Machine-local values live in `.local/mcp.local.jsonc`.
|
||||
- Secrets stay in that local file and are never committed.
|
||||
- Bootstrap creates `.local/mcp.local.jsonc` from
|
||||
`config/mcp/local-overrides.example.jsonc` if it does not exist yet.
|
||||
|
||||
## Generated Outputs
|
||||
|
||||
- VS Code user MCP config: user-profile `mcp.json`
|
||||
- Copilot CLI user MCP config: `~/.copilot/mcp-config.json`
|
||||
|
||||
Bootstrap and update regenerate those managed files while preserving unmanaged
|
||||
entries already present in the user config.
|
||||
|
||||
## Managed Servers
|
||||
|
||||
- Playwright: generated for VS Code with `npx @playwright/mcp@latest`
|
||||
- Filesystem: generated for VS Code with Docker and `${workspaceFolder}` binding
|
||||
- Filesystem: generated for Copilot CLI with a repo-owned Node wrapper that
|
||||
binds the current working directory into Docker
|
||||
- Gitea/Forgejo: generated for VS Code and Copilot CLI with
|
||||
`ronmi/forgejo-mcp`, but only when `.local/mcp.local.jsonc` enables it and
|
||||
provides `serverUrl` plus `token`
|
||||
|
||||
Copilot CLI Playwright is not generated here because Copilot CLI already ships a
|
||||
built-in Playwright MCP server.
|
||||
|
||||
17
resources/prompts/audit-copilot-usage.prompt.md
Normal file
17
resources/prompts/audit-copilot-usage.prompt.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: "audit-copilot-usage"
|
||||
description: "Run the local Copilot reuse audit and review candidates for promotion into the shared resource repo."
|
||||
agent: "agent"
|
||||
tools: [read, search, execute]
|
||||
argument-hint: "days=<default 30> workspace=<optional path filter> sources=<optional csv>"
|
||||
---
|
||||
|
||||
Run the repository audit workflow by using the shared audit script.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Prefer `resources/scripts/audit-copilot-usage.sh` over ad hoc searching.
|
||||
- Keep the audit itself read-only.
|
||||
- Review `audit-summary.md`, `candidates-report.tsv`, `selection-manifest.tsv`, and `pattern-details/`.
|
||||
- Recommend promotion only for portable resources; keep machine-specific or
|
||||
repo-specific patterns in audit notes.
|
||||
16
resources/prompts/prepare-audit-promotions.prompt.md
Normal file
16
resources/prompts/prepare-audit-promotions.prompt.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: "prepare-audit-promotions"
|
||||
description: "Generate draft resources or staging notes from approved audit manifest rows."
|
||||
agent: "agent"
|
||||
tools: [read, search, execute]
|
||||
argument-hint: "audit=<optional audit directory>"
|
||||
---
|
||||
|
||||
Generate promotion drafts from the audit approval surface by using the shared repository script.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Prefer `resources/scripts/prepare-audit-promotions.sh` over manual translation.
|
||||
- Treat `promote-*`, `template-only`, and `docs-only` rows as approved when their `decision` column is non-empty.
|
||||
- Treat `discard`, `needs-sanitization`, and empty decisions as non-approved.
|
||||
- Review `promotion-summary.md` after the script runs and summarize the generated draft resources and staging notes.
|
||||
20
resources/prompts/review-audit-candidates.prompt.md
Normal file
20
resources/prompts/review-audit-candidates.prompt.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "review-audit-candidates"
|
||||
description: "Review an audit shortlist one candidate at a time and update the selection manifest with decisions and notes."
|
||||
agent: "agent"
|
||||
tools: [read, search, edit]
|
||||
argument-hint: "audit=<optional audit directory>"
|
||||
---
|
||||
|
||||
Review the audit shortlist one candidate at a time.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Resolve the target audit directory from `audit=<path>` when provided, otherwise use the latest run under `.local/audits/`.
|
||||
- Read `selection-manifest.tsv` first, then open each pending candidate's `detail_file` before asking for a decision.
|
||||
- Summarize the candidate's purpose, expected benefit, audit context, and why it scored as it did before asking for a decision.
|
||||
- Mention transcript prompt-cost proxy fields when they are present, but label them as cost signals rather than exact token counts.
|
||||
- If the detail bundle has weak or missing evidence, say that explicitly and explain what the user would be deciding on anyway.
|
||||
- Use `vscode_askQuestions` to present decision choices as buttons and allow freeform text for `review_note`.
|
||||
- Update `selection-manifest.tsv` after each answer so review progress survives interruptions.
|
||||
- Stop only when every row has a non-empty `decision` or the user tells you to stop.
|
||||
19
resources/prompts/scaffold-synology-docker-deploy.prompt.md
Normal file
19
resources/prompts/scaffold-synology-docker-deploy.prompt.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: "scaffold-synology-docker-deploy"
|
||||
description: "Scaffold Docker packaging and Synology SSH deployment files into a project with dry-run or apply mode."
|
||||
agent: "agent"
|
||||
tools: [read, search, execute, edit]
|
||||
argument-hint: "project-root=<path> service=<name> stack=<auto|node|python|generic> mode=<dry-run|apply> app-port=<port> container-port=<port>"
|
||||
---
|
||||
|
||||
Scaffold a target project for Synology Docker deployment by running the shared
|
||||
script.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Use `resources/scripts/scaffold-synology-deploy.sh` instead of manually
|
||||
writing files.
|
||||
- Resolve missing required arguments before execution.
|
||||
- Default to `--mode dry-run` unless the user explicitly asks for apply mode.
|
||||
- Summarize created or updated files and the next command to run in the target
|
||||
project.
|
||||
1486
resources/scripts/audit-copilot-usage.mjs
Normal file
1486
resources/scripts/audit-copilot-usage.mjs
Normal file
File diff suppressed because it is too large
Load Diff
17
resources/scripts/audit-copilot-usage.sh
Executable file
17
resources/scripts/audit-copilot-usage.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
repo_root="$(cd -- "$script_dir/../.." && pwd -P)"
|
||||
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node_bin="$(command -v node)"
|
||||
elif command -v nodejs >/dev/null 2>&1; then
|
||||
node_bin="$(command -v nodejs)"
|
||||
else
|
||||
printf 'Node.js is required to run the Copilot audit workflow.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$node_bin" "$script_dir/audit-copilot-usage.mjs" --repo-root "$repo_root" "$@"
|
||||
481
resources/scripts/prepare-audit-promotions.mjs
Normal file
481
resources/scripts/prepare-audit-promotions.mjs
Normal file
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env node
|
||||
// @ts-nocheck
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const APPROVED_DECISIONS = new Set([
|
||||
'promote-skill',
|
||||
'promote-instruction',
|
||||
'promote-agent',
|
||||
'promote-hook',
|
||||
'promote-script',
|
||||
'promote-prompt',
|
||||
'template-only',
|
||||
'docs-only',
|
||||
]);
|
||||
|
||||
const DRAFTABLE_DECISIONS = new Set([
|
||||
'promote-skill',
|
||||
'promote-instruction',
|
||||
'promote-agent',
|
||||
'promote-prompt',
|
||||
]);
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
[
|
||||
'Usage: prepare-audit-promotions.mjs [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --audit-dir <path> Use a specific audit directory.',
|
||||
' --machine-id <id> Limit latest-run discovery to one machine id.',
|
||||
' --repo-root <path> Override the repository root.',
|
||||
' --help Show this help text.',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
auditDir: '',
|
||||
machineId: '',
|
||||
repoRoot: path.resolve(__dirname, '../..'),
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === '--audit-dir') {
|
||||
options.auditDir = argv[index + 1] ?? '';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--machine-id') {
|
||||
options.machineId = argv[index + 1] ?? '';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--repo-root') {
|
||||
options.repoRoot = path.resolve(argv[index + 1] ?? options.repoRoot);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeMachineId(input) {
|
||||
return String(input || os.hostname() || 'unknown-machine')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'unknown-machine';
|
||||
}
|
||||
|
||||
function safeStat(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listDirectories(rootPath) {
|
||||
if (!fs.existsSync(rootPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(rootPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(rootPath, entry.name));
|
||||
}
|
||||
|
||||
function findLatestAuditDir(repoRoot, machineId) {
|
||||
const auditRoot = path.join(repoRoot, '.local', 'audits');
|
||||
const preferredMachineId = sanitizeMachineId(machineId);
|
||||
const candidateMachineDirs = [];
|
||||
|
||||
if (machineId) {
|
||||
const machineDir = path.join(auditRoot, preferredMachineId);
|
||||
if (fs.existsSync(machineDir)) {
|
||||
candidateMachineDirs.push(machineDir);
|
||||
}
|
||||
} else {
|
||||
const localMachineDir = path.join(auditRoot, sanitizeMachineId(os.hostname()));
|
||||
if (fs.existsSync(localMachineDir)) {
|
||||
candidateMachineDirs.push(localMachineDir);
|
||||
}
|
||||
for (const machineDir of listDirectories(auditRoot)) {
|
||||
if (!candidateMachineDirs.includes(machineDir)) {
|
||||
candidateMachineDirs.push(machineDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let latestRun = null;
|
||||
|
||||
for (const machineDir of candidateMachineDirs) {
|
||||
for (const runDir of listDirectories(machineDir)) {
|
||||
const stat = safeStat(runDir);
|
||||
if (!stat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!latestRun || stat.mtimeMs > latestRun.mtimeMs) {
|
||||
latestRun = { path: runDir, mtimeMs: stat.mtimeMs };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return latestRun?.path ?? null;
|
||||
}
|
||||
|
||||
function parseTsv(filePath) {
|
||||
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const header = lines[0].split('\t');
|
||||
return lines.slice(1).map((line) => {
|
||||
const values = line.split('\t');
|
||||
const row = {};
|
||||
for (let index = 0; index < header.length; index += 1) {
|
||||
row[header[index]] = values[index] ?? '';
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
function yamlString(value) {
|
||||
return JSON.stringify(String(value ?? ''));
|
||||
}
|
||||
|
||||
function preferredStem(row) {
|
||||
return slugify(row.title || row.id || 'draft-resource') || 'draft-resource';
|
||||
}
|
||||
|
||||
function relativeAuditPath(auditDir, filePath) {
|
||||
return path.relative(auditDir, filePath) || '.';
|
||||
}
|
||||
|
||||
function buildSourceBlock(row, detailRelativePath) {
|
||||
const lines = [
|
||||
`- Candidate ID: ${row.id}`,
|
||||
`- Decision: ${row.decision}`,
|
||||
`- Suggested action: ${row.suggested_action}`,
|
||||
`- Category: ${row.category}`,
|
||||
`- Value rank: ${row.value_rank}`,
|
||||
];
|
||||
|
||||
if (detailRelativePath) {
|
||||
lines.push(`- Detail file: ${detailRelativePath}`);
|
||||
}
|
||||
|
||||
if (row.review_note) {
|
||||
lines.push(`- Review note: ${row.review_note}`);
|
||||
}
|
||||
|
||||
if (row.notes) {
|
||||
lines.push(`- Audit notes: ${row.notes}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderSkillDraft(row, detailRelativePath) {
|
||||
const name = preferredStem(row);
|
||||
return [
|
||||
'---',
|
||||
`name: ${yamlString(name)}`,
|
||||
`description: ${yamlString(`Draft generated from audit candidate ${row.id}.`)}`,
|
||||
'argument-hint: ""',
|
||||
'---',
|
||||
'',
|
||||
`# ${row.title}`,
|
||||
'',
|
||||
'Use this draft to turn the audited pattern into a portable shared skill.',
|
||||
'',
|
||||
'## Source',
|
||||
'',
|
||||
buildSourceBlock(row, detailRelativePath),
|
||||
'',
|
||||
'## Draft Workflow',
|
||||
'',
|
||||
'Replace this placeholder with the reusable workflow distilled from the audit evidence bundle.',
|
||||
'',
|
||||
'## Validation',
|
||||
'',
|
||||
'- Confirm the pattern is portable across repositories.',
|
||||
'- Add any required docs, prompts, or scripts before publishing.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderInstructionDraft(row, detailRelativePath) {
|
||||
return [
|
||||
'---',
|
||||
`description: ${yamlString(`Draft generated from audit candidate ${row.id}.`)}`,
|
||||
'applyTo: ""',
|
||||
'---',
|
||||
'',
|
||||
'# Draft Instruction',
|
||||
'',
|
||||
'- Replace this placeholder with portable instruction text distilled from the audit evidence.',
|
||||
'',
|
||||
'## Source',
|
||||
'',
|
||||
buildSourceBlock(row, detailRelativePath),
|
||||
'',
|
||||
'## Guardrails',
|
||||
'',
|
||||
'- Remove repo-specific assumptions before publishing.',
|
||||
'- Keep the instruction concise and reusable.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderAgentDraft(row, detailRelativePath) {
|
||||
return [
|
||||
'---',
|
||||
`name: ${yamlString(row.title)}`,
|
||||
`description: ${yamlString(`Draft generated from audit candidate ${row.id}.`)}`,
|
||||
'tools: [read, search, edit, execute]',
|
||||
'---',
|
||||
'',
|
||||
`# ${row.title}`,
|
||||
'',
|
||||
'Use this draft agent to package the audited pattern into a reusable interaction mode.',
|
||||
'',
|
||||
'## Source',
|
||||
'',
|
||||
buildSourceBlock(row, detailRelativePath),
|
||||
'',
|
||||
'## Behavior',
|
||||
'',
|
||||
'- Replace this placeholder with the agent workflow distilled from the audit evidence.',
|
||||
'- Specify when to use the agent and which tradeoffs it should enforce.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderPromptDraft(row, detailRelativePath) {
|
||||
const name = preferredStem(row);
|
||||
return [
|
||||
'---',
|
||||
`name: ${yamlString(name)}`,
|
||||
`description: ${yamlString(`Draft generated from audit candidate ${row.id}.`)}`,
|
||||
'agent: "agent"',
|
||||
'tools: [read, search, edit, execute]',
|
||||
'argument-hint: ""',
|
||||
'---',
|
||||
'',
|
||||
'Translate the audited pattern into a reusable prompt adapter.',
|
||||
'',
|
||||
'## Source',
|
||||
'',
|
||||
buildSourceBlock(row, detailRelativePath),
|
||||
'',
|
||||
'Requirements:',
|
||||
'',
|
||||
'- Replace this placeholder guidance with the portable workflow.',
|
||||
'- Recheck the evidence bundle before publishing.',
|
||||
'- Remove any repo-specific assumptions.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function suggestedTarget(decision, stem) {
|
||||
if (decision === 'promote-script') {
|
||||
return `resources/scripts/${stem}.sh and resources/scripts/${stem}.ps1`;
|
||||
}
|
||||
if (decision === 'promote-hook') {
|
||||
return `resources/hooks/${stem}.json`;
|
||||
}
|
||||
if (decision === 'template-only') {
|
||||
return `templates/<target>/${stem}`;
|
||||
}
|
||||
if (decision === 'docs-only') {
|
||||
return `docs/${stem}.md`;
|
||||
}
|
||||
|
||||
return 'manual follow-up';
|
||||
}
|
||||
|
||||
function renderStagingNote(row, detailRelativePath) {
|
||||
const stem = preferredStem(row);
|
||||
return [
|
||||
`# ${row.title}`,
|
||||
'',
|
||||
'This approved audit row still needs manual design work before it should be published into the shared repository.',
|
||||
'',
|
||||
'## Source',
|
||||
'',
|
||||
buildSourceBlock(row, detailRelativePath),
|
||||
'',
|
||||
'## Suggested Target',
|
||||
'',
|
||||
`- ${suggestedTarget(row.decision, stem)}`,
|
||||
'',
|
||||
'## Next Steps',
|
||||
'',
|
||||
'- Distill the evidence bundle into a portable implementation plan.',
|
||||
'- Decide whether this should stay a note, become docs, or become a concrete shared artifact.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildDraftSpec(auditDir, row) {
|
||||
const stem = preferredStem(row);
|
||||
const detailRelativePath = row.detail_file || '';
|
||||
|
||||
if (row.decision === 'promote-skill') {
|
||||
return {
|
||||
kind: 'draft',
|
||||
outputPath: path.join(auditDir, 'draft-resources', 'resources', 'skills', stem, 'SKILL.md'),
|
||||
content: renderSkillDraft(row, detailRelativePath),
|
||||
};
|
||||
}
|
||||
|
||||
if (row.decision === 'promote-instruction') {
|
||||
return {
|
||||
kind: 'draft',
|
||||
outputPath: path.join(auditDir, 'draft-resources', 'resources', 'instructions', `${stem}.instructions.md`),
|
||||
content: renderInstructionDraft(row, detailRelativePath),
|
||||
};
|
||||
}
|
||||
|
||||
if (row.decision === 'promote-agent') {
|
||||
return {
|
||||
kind: 'draft',
|
||||
outputPath: path.join(auditDir, 'draft-resources', 'resources', 'agents', `${stem}.agent.md`),
|
||||
content: renderAgentDraft(row, detailRelativePath),
|
||||
};
|
||||
}
|
||||
|
||||
if (row.decision === 'promote-prompt') {
|
||||
return {
|
||||
kind: 'draft',
|
||||
outputPath: path.join(auditDir, 'draft-resources', 'resources', 'prompts', `${stem}.prompt.md`),
|
||||
content: renderPromptDraft(row, detailRelativePath),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'note',
|
||||
outputPath: path.join(auditDir, 'staging-notes', `${stem}.md`),
|
||||
content: renderStagingNote(row, detailRelativePath),
|
||||
};
|
||||
}
|
||||
|
||||
function writeArtifacts(auditDir, approvedRows) {
|
||||
const generated = [];
|
||||
|
||||
for (const row of approvedRows) {
|
||||
const artifact = buildDraftSpec(auditDir, row);
|
||||
ensureDir(path.dirname(artifact.outputPath));
|
||||
fs.writeFileSync(artifact.outputPath, artifact.content, 'utf8');
|
||||
generated.push({
|
||||
decision: row.decision,
|
||||
id: row.id,
|
||||
kind: artifact.kind,
|
||||
outputPath: artifact.outputPath,
|
||||
});
|
||||
}
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
function writeSummary(auditDir, manifestPath, approvedRows, generatedArtifacts) {
|
||||
const summaryPath = path.join(auditDir, 'promotion-summary.md');
|
||||
const draftArtifacts = generatedArtifacts.filter((artifact) => artifact.kind === 'draft');
|
||||
const noteArtifacts = generatedArtifacts.filter((artifact) => artifact.kind === 'note');
|
||||
const lines = [
|
||||
'# Audit Promotion Summary',
|
||||
'',
|
||||
`- Generated: ${new Date().toISOString()}`,
|
||||
`- Audit directory: ${auditDir}`,
|
||||
`- Selection manifest: ${manifestPath}`,
|
||||
`- Approved rows: ${approvedRows.length}`,
|
||||
`- Draft resources: ${draftArtifacts.length}`,
|
||||
`- Staging notes: ${noteArtifacts.length}`,
|
||||
'',
|
||||
'## Draft Resources',
|
||||
'',
|
||||
];
|
||||
|
||||
if (draftArtifacts.length === 0) {
|
||||
lines.push('- None');
|
||||
} else {
|
||||
for (const artifact of draftArtifacts) {
|
||||
lines.push(`- ${artifact.decision} :: ${artifact.id} :: ${relativeAuditPath(auditDir, artifact.outputPath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('', '## Staging Notes', '');
|
||||
|
||||
if (noteArtifacts.length === 0) {
|
||||
lines.push('- None');
|
||||
} else {
|
||||
for (const artifact of noteArtifacts) {
|
||||
lines.push(`- ${artifact.decision} :: ${artifact.id} :: ${relativeAuditPath(auditDir, artifact.outputPath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(summaryPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
return summaryPath;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const auditDir = options.auditDir
|
||||
? path.resolve(options.auditDir)
|
||||
: findLatestAuditDir(options.repoRoot, options.machineId);
|
||||
|
||||
if (!auditDir) {
|
||||
throw new Error('No audit directory was found. Run the audit first or pass --audit-dir.');
|
||||
}
|
||||
|
||||
const manifestPath = path.join(auditDir, 'selection-manifest.tsv');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`Selection manifest not found: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const rows = parseTsv(manifestPath);
|
||||
const approvedRows = rows.filter((row) => APPROVED_DECISIONS.has(row.decision));
|
||||
const generatedArtifacts = writeArtifacts(auditDir, approvedRows);
|
||||
const summaryPath = writeSummary(auditDir, manifestPath, approvedRows, generatedArtifacts);
|
||||
|
||||
console.log('Audit promotion preparation complete.');
|
||||
console.log(`Audit directory: ${auditDir}`);
|
||||
console.log(`Approved rows: ${approvedRows.length}`);
|
||||
console.log(`Draft resources: ${generatedArtifacts.filter((artifact) => artifact.kind === 'draft').length}`);
|
||||
console.log(`Staging notes: ${generatedArtifacts.filter((artifact) => artifact.kind === 'note').length}`);
|
||||
console.log(`Summary: ${summaryPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
17
resources/scripts/prepare-audit-promotions.sh
Executable file
17
resources/scripts/prepare-audit-promotions.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
repo_root="$(cd -- "$script_dir/../.." && pwd -P)"
|
||||
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node_bin="$(command -v node)"
|
||||
elif command -v nodejs >/dev/null 2>&1; then
|
||||
node_bin="$(command -v nodejs)"
|
||||
else
|
||||
printf 'Node.js is required to prepare audit promotion drafts.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$node_bin" "$script_dir/prepare-audit-promotions.mjs" --repo-root "$repo_root" "$@"
|
||||
0
resources/scripts/report-hook-event.sh
Normal file → Executable file
0
resources/scripts/report-hook-event.sh
Normal file → Executable file
320
resources/scripts/scaffold-synology-deploy.mjs
Normal file
320
resources/scripts/scaffold-synology-deploy.mjs
Normal file
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env node
|
||||
// @ts-nocheck
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULTS = {
|
||||
stack: 'auto',
|
||||
mode: 'dry-run',
|
||||
synologyPort: '22',
|
||||
remotePathBase: '/volume1/docker',
|
||||
};
|
||||
|
||||
function usage() {
|
||||
console.error(`Usage: resources/scripts/scaffold-synology-deploy.sh [options]
|
||||
|
||||
Required:
|
||||
--project-root <path> Target project path.
|
||||
--service <name> Service/app name.
|
||||
|
||||
Optional:
|
||||
--stack <auto|node|python|generic> Default: auto
|
||||
--mode <dry-run|apply> Default: dry-run
|
||||
--image-name <name> Default: <service>
|
||||
--image-tag <tag> Default: latest
|
||||
--app-port <port> Default: empty (required in deploy.env)
|
||||
--container-port <port> Default: empty (required in deploy.env)
|
||||
--synology-port <port> Default: 22
|
||||
--remote-path <path> Default: /volume1/docker/<service>
|
||||
--force Overwrite generated files.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
stack: DEFAULTS.stack,
|
||||
mode: DEFAULTS.mode,
|
||||
force: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === '--help') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (arg === '--project-root') {
|
||||
options.projectRoot = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--service') {
|
||||
options.service = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--stack') {
|
||||
options.stack = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--mode') {
|
||||
options.mode = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--image-name') {
|
||||
options.imageName = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--image-tag') {
|
||||
options.imageTag = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--synology-port') {
|
||||
options.synologyPort = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--app-port') {
|
||||
options.appPort = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--container-port') {
|
||||
options.containerPort = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--remote-path') {
|
||||
options.remotePath = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--force') {
|
||||
options.force = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
if (!options.projectRoot) {
|
||||
throw new Error('--project-root is required.');
|
||||
}
|
||||
|
||||
if (!options.service) {
|
||||
throw new Error('--service is required.');
|
||||
}
|
||||
|
||||
if (!['auto', 'node', 'python', 'generic'].includes(options.stack)) {
|
||||
throw new Error('--stack must be one of auto|node|python|generic.');
|
||||
}
|
||||
|
||||
if (!['dry-run', 'apply'].includes(options.mode)) {
|
||||
throw new Error('--mode must be dry-run or apply.');
|
||||
}
|
||||
|
||||
options.projectRoot = path.resolve(options.projectRoot);
|
||||
options.service = slugify(options.service);
|
||||
options.imageName = options.imageName || options.service;
|
||||
options.imageTag = options.imageTag || 'latest';
|
||||
options.synologyPort = options.synologyPort || DEFAULTS.synologyPort;
|
||||
options.appPort = options.appPort || '';
|
||||
options.containerPort = options.containerPort || '';
|
||||
options.remotePath = options.remotePath || `${DEFAULTS.remotePathBase}/${options.service}`;
|
||||
return options;
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function detectStack(projectRoot) {
|
||||
if (fs.existsSync(path.join(projectRoot, 'package.json'))) {
|
||||
return 'node';
|
||||
}
|
||||
|
||||
if (
|
||||
fs.existsSync(path.join(projectRoot, 'pyproject.toml')) ||
|
||||
fs.existsSync(path.join(projectRoot, 'requirements.txt'))
|
||||
) {
|
||||
return 'python';
|
||||
}
|
||||
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
function ensureDir(dirPath, mode) {
|
||||
if (mode === 'dry-run') {
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function writeFileWithGuard(filePath, content, { mode, force }) {
|
||||
const exists = fs.existsSync(filePath);
|
||||
if (exists && !force) {
|
||||
return { action: 'skipped', filePath };
|
||||
}
|
||||
|
||||
const action = exists ? 'updated' : 'created';
|
||||
if (mode === 'apply') {
|
||||
ensureDir(path.dirname(filePath), mode);
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
return { action, filePath };
|
||||
}
|
||||
|
||||
function writeExecutableFile(filePath, content, { mode, force }) {
|
||||
const result = writeFileWithGuard(filePath, content, { mode, force });
|
||||
if (mode === 'apply' && (result.action === 'created' || result.action === 'updated')) {
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderDockerfile(stack) {
|
||||
if (stack === 'node') {
|
||||
return `FROM node:20-alpine\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci --omit=dev\n\nCOPY . .\n\nENV NODE_ENV=production\nEXPOSE 3000\nCMD ["npm", "start"]\n`;
|
||||
}
|
||||
|
||||
if (stack === 'python') {
|
||||
return `FROM python:3.12-slim\n\nWORKDIR /app\n\nCOPY requirements.txt ./\nRUN pip install --no-cache-dir -r requirements.txt\n\nCOPY . .\n\nENV PYTHONUNBUFFERED=1\nEXPOSE 8000\nCMD ["python", "-m", "app"]\n`;
|
||||
}
|
||||
|
||||
return `FROM alpine:3.20\n\nWORKDIR /app\nCOPY . .\n\nEXPOSE 3000\nCMD ["sh", "-c", "echo 'Set your runtime command in Dockerfile' && tail -f /dev/null"]\n`;
|
||||
}
|
||||
|
||||
function renderDockerIgnore() {
|
||||
return `.git\n.gitignore\nnode_modules\n__pycache__\n*.pyc\n.env\n.env.*\ncoverage\ndist\nbuild\n`;
|
||||
}
|
||||
|
||||
function renderComposeYaml(service) {
|
||||
return `services:\n app:\n image: \${IMAGE_REF}\n container_name: ${service}\n restart: unless-stopped\n ports:\n - "\${APP_PORT}:\${CONTAINER_PORT}"\n`;
|
||||
}
|
||||
|
||||
function renderDeployEnvExample(options) {
|
||||
return `# Copy this file to deploy.env and fill values before deploying.\n\nSYNOLOGY_USER=\nSYNOLOGY_SSH_PORT=${options.synologyPort}\n\n# Internal and external host routes.\nSYNOLOGY_HOST_INTERNAL=\nSYNOLOGY_HOST_EXTERNAL=\n\n# internal or external\nDEPLOY_TARGET=internal\n\nSERVICE_NAME=${options.service}\nIMAGE_NAME=${options.imageName}\nIMAGE_TAG=${options.imageTag}\n\n# Required per project\nAPP_PORT=${options.appPort}\nCONTAINER_PORT=${options.containerPort}\n\nREMOTE_APP_PATH=${options.remotePath}\n`;
|
||||
}
|
||||
|
||||
function renderDeployScript() {
|
||||
return `#!/usr/bin/env bash\n\nset -euo pipefail\n\nscript_dir="$(cd -- "$(dirname -- "\${BASH_SOURCE[0]}")" && pwd -P)"\nproject_root="$(cd -- "\$script_dir/../.." && pwd -P)"\nenv_file="\$script_dir/deploy.env"\ncompose_file="\$script_dir/compose.yaml"\nruntime_env_file="\$script_dir/runtime.env"\n\ndry_run=false\nif [[ "\${1:-}" == "--dry-run" ]]; then\n dry_run=true\nfi\n\nif [[ ! -f "\$env_file" ]]; then\n printf 'Missing %s. Copy deploy.env.example to deploy.env and set values.\\n' "\$env_file" >&2\n exit 1\nfi\n\n# shellcheck source=/dev/null\nsource "\$env_file"\n\nrequired_vars=(SYNOLOGY_USER SYNOLOGY_SSH_PORT SERVICE_NAME IMAGE_NAME IMAGE_TAG APP_PORT CONTAINER_PORT REMOTE_APP_PATH DEPLOY_TARGET)\nfor var_name in "\${required_vars[@]}"; do\n if [[ -z "\${!var_name:-}" ]]; then\n printf 'Missing required variable: %s\\n' "\$var_name" >&2\n exit 1\n fi\ndone\n\ncase "\$DEPLOY_TARGET" in\n internal)\n target_host="\${SYNOLOGY_HOST_INTERNAL:-}"\n ;;\n external)\n target_host="\${SYNOLOGY_HOST_EXTERNAL:-}"\n ;;\n *)\n printf 'DEPLOY_TARGET must be internal or external.\\n' >&2\n exit 1\n ;;\nesac\n\nif [[ -z "\$target_host" ]]; then\n printf 'Selected host for DEPLOY_TARGET=%s is empty.\\n' "\$DEPLOY_TARGET" >&2\n exit 1\nfi\n\nimage_ref="\${IMAGE_NAME}:\${IMAGE_TAG}"\narchive_name="\${SERVICE_NAME}-\${IMAGE_TAG}.tar"\nlocal_archive="\${TMPDIR:-/tmp}/\$archive_name"\nremote_archive="/tmp/\$archive_name"\n\ncat > "\$runtime_env_file" <<EOF\nIMAGE_REF=\$image_ref\nAPP_PORT=\$APP_PORT\nCONTAINER_PORT=\$CONTAINER_PORT\nEOF\n\nprintf 'Deploy target: %s (%s)\\n' "\$DEPLOY_TARGET" "\$target_host"\nprintf 'Image: %s\\n' "\$image_ref"\nprintf 'Remote path: %s\\n' "\$REMOTE_APP_PATH"\n\nif [[ "\$dry_run" == true ]]; then\n cat <<EOF\nDry-run commands that would execute:\n docker build -t \$image_ref \"\$project_root\"\n docker save \$image_ref -o \"\$local_archive\"\n scp -P \$SYNOLOGY_SSH_PORT \"\$local_archive\" \"\${SYNOLOGY_USER}@\${target_host}:\$remote_archive\"\n scp -P \$SYNOLOGY_SSH_PORT \"\$compose_file\" \"\$runtime_env_file\" \"\${SYNOLOGY_USER}@\${target_host}:\${REMOTE_APP_PATH}/\"\n ssh -p \$SYNOLOGY_SSH_PORT \"\${SYNOLOGY_USER}@\${target_host}\" \"docker load -i '\$remote_archive' && docker compose --env-file '\${REMOTE_APP_PATH}/runtime.env' -f '\${REMOTE_APP_PATH}/compose.yaml' up -d\"\nEOF\n exit 0\nfi\n\ndocker build -t "\$image_ref" "\$project_root"\ndocker save "\$image_ref" -o "\$local_archive"\n\nssh -p "\$SYNOLOGY_SSH_PORT" "\${SYNOLOGY_USER}@\${target_host}" "mkdir -p '\$REMOTE_APP_PATH'"\nscp -P "\$SYNOLOGY_SSH_PORT" "\$local_archive" "\${SYNOLOGY_USER}@\${target_host}:\$remote_archive"\nscp -P "\$SYNOLOGY_SSH_PORT" "\$compose_file" "\$runtime_env_file" "\${SYNOLOGY_USER}@\${target_host}:\${REMOTE_APP_PATH}/"\n\nssh -p "\$SYNOLOGY_SSH_PORT" "\${SYNOLOGY_USER}@\${target_host}" "set -euo pipefail; docker load -i '\$remote_archive'; cd '\$REMOTE_APP_PATH'; docker compose --env-file runtime.env -f compose.yaml up -d; rm -f '\$remote_archive'"\n\nrm -f "\$local_archive"\nprintf 'Deployment completed.\\n'\n`;
|
||||
}
|
||||
|
||||
function renderDeployReadme(service) {
|
||||
return `# Synology Deploy\n\nThis directory was generated by the shared Synology deploy scaffold.\n\n## Quickstart\n\n1. Copy \`deploy.env.example\` to \`deploy.env\`.\n2. Set hosts for internal and external access plus SSH credentials.\n3. Run a safe preview:\n \`bash deploy/synology/deploy.sh --dry-run\`\n4. Deploy for real:\n \`bash deploy/synology/deploy.sh\`\n\n## Internal vs External\n\nSet \`DEPLOY_TARGET=internal\` to use \`SYNOLOGY_HOST_INTERNAL\`, or\n\`DEPLOY_TARGET=external\` to use \`SYNOLOGY_HOST_EXTERNAL\`.\n\n## Service\n\nDefault service name for this scaffold: ${service}\n`;
|
||||
}
|
||||
|
||||
function updateNodePackageScripts(projectRoot, mode, options) {
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return { changed: false, reason: 'package.json not found' };
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const scripts = packageJson.scripts || {};
|
||||
|
||||
const desiredScripts = {
|
||||
'docker:build': `docker build -t ${options.imageName}:${options.imageTag} .`,
|
||||
'deploy:synology:dry-run': 'bash deploy/synology/deploy.sh --dry-run',
|
||||
'deploy:synology': 'bash deploy/synology/deploy.sh',
|
||||
};
|
||||
|
||||
let changed = false;
|
||||
for (const [key, value] of Object.entries(desiredScripts)) {
|
||||
if (scripts[key] !== value) {
|
||||
scripts[key] = value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { changed: false, reason: 'scripts already present' };
|
||||
}
|
||||
|
||||
if (mode === 'apply') {
|
||||
packageJson.scripts = scripts;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
return { changed: true, filePath: packageJsonPath };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!fs.existsSync(options.projectRoot) || !fs.statSync(options.projectRoot).isDirectory()) {
|
||||
throw new Error(`Project root does not exist or is not a directory: ${options.projectRoot}`);
|
||||
}
|
||||
|
||||
const resolvedStack = options.stack === 'auto' ? detectStack(options.projectRoot) : options.stack;
|
||||
const deployDir = path.join(options.projectRoot, 'deploy', 'synology');
|
||||
|
||||
const writes = [];
|
||||
writes.push(
|
||||
writeFileWithGuard(path.join(options.projectRoot, 'Dockerfile'), renderDockerfile(resolvedStack), options),
|
||||
);
|
||||
writes.push(
|
||||
writeFileWithGuard(path.join(options.projectRoot, '.dockerignore'), renderDockerIgnore(), options),
|
||||
);
|
||||
writes.push(
|
||||
writeFileWithGuard(path.join(deployDir, 'compose.yaml'), renderComposeYaml(options.service), options),
|
||||
);
|
||||
writes.push(
|
||||
writeFileWithGuard(path.join(deployDir, 'deploy.env.example'), renderDeployEnvExample(options), options),
|
||||
);
|
||||
writes.push(
|
||||
writeExecutableFile(path.join(deployDir, 'deploy.sh'), renderDeployScript(), options),
|
||||
);
|
||||
writes.push(
|
||||
writeFileWithGuard(path.join(deployDir, 'README.md'), renderDeployReadme(options.service), options),
|
||||
);
|
||||
|
||||
const nodeUpdate = resolvedStack === 'node'
|
||||
? updateNodePackageScripts(options.projectRoot, options.mode, options)
|
||||
: { changed: false, reason: 'non-node stack' };
|
||||
|
||||
console.log(`Mode: ${options.mode}`);
|
||||
console.log(`Project root: ${options.projectRoot}`);
|
||||
console.log(`Service: ${options.service}`);
|
||||
console.log(`Stack: ${resolvedStack}`);
|
||||
console.log('');
|
||||
|
||||
for (const entry of writes) {
|
||||
console.log(`${entry.action.toUpperCase()}: ${entry.filePath}`);
|
||||
}
|
||||
|
||||
if (nodeUpdate.changed) {
|
||||
console.log(`UPDATED: ${nodeUpdate.filePath}`);
|
||||
} else {
|
||||
console.log(`NODE_SETUP: ${nodeUpdate.reason}`);
|
||||
}
|
||||
|
||||
if (options.mode === 'dry-run') {
|
||||
console.log('');
|
||||
console.log('No files were written. Re-run with --mode apply to persist changes.');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
16
resources/scripts/scaffold-synology-deploy.sh
Executable file
16
resources/scripts/scaffold-synology-deploy.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node_bin="$(command -v node)"
|
||||
elif command -v nodejs >/dev/null 2>&1; then
|
||||
node_bin="$(command -v nodejs)"
|
||||
else
|
||||
printf 'Node.js is required to scaffold Synology deployment files.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$node_bin" "$script_dir/scaffold-synology-deploy.mjs" "$@"
|
||||
40
resources/skills/copilot-cost-review/SKILL.md
Normal file
40
resources/skills/copilot-cost-review/SKILL.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: copilot-cost-review
|
||||
description: "Use when reviewing a prompt, instruction, agent, or skill draft for avoidable token cost and reuse opportunities."
|
||||
argument-hint: "target=<resource path or description>"
|
||||
---
|
||||
|
||||
# Copilot Cost Review
|
||||
|
||||
Use this skill when you want to make a Copilot resource cheaper to run without
|
||||
stripping away the behavior it actually needs.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Identify the resource's real job, expected inputs, and default output size.
|
||||
2. Search for an existing shared resource that already covers most of the same
|
||||
workflow before proposing a new artifact.
|
||||
3. Re-evaluate the primitive choice: use an instruction for durable rules, a
|
||||
skill for a portable workflow, a prompt for a thin VS Code entrypoint, and a
|
||||
hook only for deterministic enforced behavior.
|
||||
4. Remove repeated policy text, long examples, and broad workspace-reading
|
||||
requirements unless they materially improve correctness.
|
||||
5. Replace broad discovery steps with narrow anchors such as a file, symbol,
|
||||
command, or manifest row whenever possible.
|
||||
6. Add or tighten an explicit output budget and note when the resource should
|
||||
not be used.
|
||||
7. If the resource is still too heavy, split reference material into docs or
|
||||
scripts and keep the runtime resource concise.
|
||||
|
||||
## Outputs
|
||||
|
||||
- Recommended primitive
|
||||
- Reuse candidates in the shared repo
|
||||
- Context and prompt reductions
|
||||
- Output-budget guidance
|
||||
- Non-goals or usage limits
|
||||
|
||||
## Notes
|
||||
|
||||
- Reduce prompt size without removing information that changes correctness.
|
||||
- Treat transcript prompt-cost fields as proxies, not exact billing data.
|
||||
52
resources/skills/copilot-reuse-audit/SKILL.md
Normal file
52
resources/skills/copilot-reuse-audit/SKILL.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: copilot-reuse-audit
|
||||
description: "Use when auditing the last 30 days of persisted Copilot artifacts for reusable patterns worth promoting into the shared repo."
|
||||
argument-hint: "days=<n> workspace=<optional path filter> sources=<optional csv>"
|
||||
---
|
||||
|
||||
# Copilot Reuse Audit
|
||||
|
||||
Use this skill when you want a repeatable audit of local Copilot usage artifacts
|
||||
to find patterns that should become shared resources.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Run `resources/scripts/audit-copilot-usage.sh` instead of manually hunting
|
||||
through transcripts, memories, and publish logs.
|
||||
2. By default, the runner excludes the `copilot-resources` repo root from
|
||||
workspace-backed candidate discovery so the audit does not ask you to review
|
||||
patterns that already live in the shared repository.
|
||||
3. Review the generated `audit-summary.md` first to understand coverage and top
|
||||
candidates.
|
||||
4. Use `selection-manifest.tsv` and `pattern-details/*.md` as the approval
|
||||
surface for each candidate.
|
||||
5. When you want interactive triage inside Copilot, use the
|
||||
`review-audit-candidates` prompt so each pending row is handled one at a
|
||||
time and written back to `selection-manifest.tsv`.
|
||||
6. Use the transcript prompt-cost proxy fields to prioritize repeated long
|
||||
prompts that are likely worth turning into a shared resource.
|
||||
7. Treat those prompt-cost fields as triage signals, not as exact billing data.
|
||||
6. Before asking for a decision, explain the candidate's likely purpose,
|
||||
concrete benefit, audit context, and score rationale. If evidence is thin,
|
||||
call that out explicitly instead of asking the user to infer it.
|
||||
8. Promote only candidates that map cleanly to a portable skill, instruction,
|
||||
prompt adapter, agent, hook, script, or template.
|
||||
9. After rows are approved, run `resources/scripts/prepare-audit-promotions.sh`
|
||||
to create draft resources or staging notes in the audit directory.
|
||||
10. Keep non-portable or repo-specific findings in audit notes rather than
|
||||
forcing them into shared resources.
|
||||
|
||||
## Outputs
|
||||
|
||||
- `audit-summary.md` — high-level run summary and top candidates
|
||||
- `candidates-report.tsv` — scored candidate list with source references
|
||||
- `selection-manifest.tsv` — editable approval surface for promotion decisions
|
||||
- `pattern-details/*.md` — per-candidate evidence bundles with benefit, context, score rationale, and caveats
|
||||
- `draft-resources/` — generated draft resource files for approved portable rows
|
||||
- `staging-notes/` — generated follow-up notes for approved rows that still need design work
|
||||
|
||||
## Notes
|
||||
|
||||
- The runner is macOS-first in this iteration.
|
||||
- Audit history is stored per machine under `.local/audits/<machine-id>/` in the
|
||||
repo checkout and is intentionally git-ignored.
|
||||
@@ -5,3 +5,9 @@
|
||||
- Confirm frontmatter is valid and descriptive.
|
||||
- Avoid duplicating a workflow that already exists.
|
||||
- If scripts are referenced, validate them before merging.
|
||||
- Check whether the same outcome can be achieved with a cheaper shared
|
||||
primitive or by reusing an existing resource.
|
||||
- Keep examples and embedded guidance short unless the extra context is
|
||||
essential.
|
||||
- Say when the resource should not be used if that prevents wasteful generic
|
||||
usage.
|
||||
|
||||
52
resources/skills/synology-docker-deploy/SKILL.md
Normal file
52
resources/skills/synology-docker-deploy/SKILL.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: synology-docker-deploy
|
||||
description: "Use when scaffolding Docker packaging and direct SSH deployment to a Synology host for a project that should be ready to run after setup."
|
||||
argument-hint: "project-root=<path> service=<name> stack=<auto|node|python|generic> mode=<dry-run|apply> app-port=<port> container-port=<port>"
|
||||
---
|
||||
|
||||
# Synology Docker Deploy
|
||||
|
||||
Use this skill when you want a portable, repeatable setup for Dockerizing a
|
||||
project and deploying it to Synology over SSH without requiring a container
|
||||
registry.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Confirm required inputs: `project-root`, `service`, `stack`, and deploy mode.
|
||||
2. Run `resources/scripts/scaffold-synology-deploy.sh` to scaffold Docker and
|
||||
deployment files directly into the target project.
|
||||
3. Prefer `--mode dry-run` first to review planned changes before writing files.
|
||||
4. For Node projects, let the scaffold update `package.json` scripts so deploy
|
||||
commands are available immediately.
|
||||
5. Copy `deploy/synology/deploy.env.example` to `deploy/synology/deploy.env`
|
||||
and provide environment values.
|
||||
6. Run `bash deploy/synology/deploy.sh --dry-run` from the target project to
|
||||
verify inputs and planned remote actions.
|
||||
7. Run `bash deploy/synology/deploy.sh` for actual deploy once dry-run output
|
||||
looks correct.
|
||||
8. Use `DEPLOY_TARGET=internal` or `DEPLOY_TARGET=external` to switch between
|
||||
internal and external host routing without changing scripts.
|
||||
|
||||
## Outputs
|
||||
|
||||
- `Dockerfile` (stack-aware default)
|
||||
- `.dockerignore`
|
||||
- `deploy/synology/deploy.sh`
|
||||
- `deploy/synology/deploy.env.example`
|
||||
- `deploy/synology/compose.yaml`
|
||||
- `deploy/synology/README.md`
|
||||
- Optional `package.json` script updates for Node projects
|
||||
|
||||
## Do Not Use
|
||||
|
||||
- Do not use this workflow when the project requires a registry-only release
|
||||
pipeline.
|
||||
- Do not use this workflow when Kubernetes manifests are the primary deployment
|
||||
target.
|
||||
- Do not store secrets in generated files committed to source control.
|
||||
|
||||
## Notes
|
||||
|
||||
- This workflow is environment-variable-first and keeps secrets out of the repo.
|
||||
- The generated deploy path uses direct `docker save` + `scp` + remote
|
||||
`docker load` on Synology.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Required Runtime Variables
|
||||
|
||||
Populate these values in `deploy/synology/deploy.env` in the target project:
|
||||
|
||||
- `SYNOLOGY_USER`
|
||||
- `SYNOLOGY_SSH_PORT`
|
||||
- `SYNOLOGY_HOST_INTERNAL`
|
||||
- `SYNOLOGY_HOST_EXTERNAL`
|
||||
- `REMOTE_APP_PATH`
|
||||
- `SERVICE_NAME`
|
||||
- `IMAGE_NAME`
|
||||
- `IMAGE_TAG`
|
||||
- `APP_PORT`
|
||||
- `CONTAINER_PORT`
|
||||
- `DEPLOY_TARGET` (`internal` or `external`)
|
||||
@@ -5,4 +5,8 @@
|
||||
- Add clear frontmatter.
|
||||
- Check naming and portability rules.
|
||||
- Validate any referenced scripts.
|
||||
- Reuse an existing shared resource when possible instead of adding a near
|
||||
duplicate.
|
||||
- Keep descriptions and examples compact.
|
||||
- Note the default output budget or brevity expectation when it matters.
|
||||
- Commit and push after publishing.
|
||||
|
||||
Reference in New Issue
Block a user