Compare commits

...

4 Commits

71 changed files with 6883 additions and 133 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.local/

View File

@@ -20,6 +20,7 @@ This initial implementation provides:
- Publish, update, and verify scripts - Publish, update, and verify scripts
- Scripted VS Code settings merge for managed Copilot-related keys - Scripted VS Code settings merge for managed Copilot-related keys
- Scripted Copilot CLI environment wiring through a managed sourced fragment - Scripted Copilot CLI environment wiring through a managed sourced fragment
- Scripted managed MCP config generation for VS Code and Copilot CLI
## Operating Model ## 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 small managed shell or PowerShell profile block for Copilot CLI environment
variables without replacing the rest of the user config. 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 ## Next Docs
The rest of the handbook will live in `docs/` and will cover architecture, The rest of the handbook will live in `docs/` and will cover architecture,

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

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

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

View File

@@ -6,7 +6,6 @@
"chat.useAgentsMdFile": true, "chat.useAgentsMdFile": true,
"chat.useClaudeMdFile": true, "chat.useClaudeMdFile": true,
"chat.useCustomizationsInParentRepositories": true, "chat.useCustomizationsInParentRepositories": true,
// Direct-path fallback configuration in case symlinked defaults are not preferred. // Direct-path fallback configuration in case symlinked defaults are not preferred.
"chat.instructionsFilesLocations": { "chat.instructionsFilesLocations": {
"{{COPILOT_RESOURCES_HOME}}/resources/instructions": true, "{{COPILOT_RESOURCES_HOME}}/resources/instructions": true,
@@ -26,4 +25,4 @@
"{{COPILOT_RESOURCES_HOME}}/resources/hooks": true, "{{COPILOT_RESOURCES_HOME}}/resources/hooks": true,
"~/.claude/settings.json": true "~/.claude/settings.json": true
} }
} }

View File

@@ -13,6 +13,9 @@ Git-based after a resource is published.
- `resources/instructions/`: shared instruction packs - `resources/instructions/`: shared instruction packs
- `resources/agents/`: shared custom agents for local chat and overlays - `resources/agents/`: shared custom agents for local chat and overlays
- `resources/hooks/`: shared hook definitions - `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 - `templates/repo-overlay/`: files that can be copied into another repository for
repository-scoped behavior 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 This keeps the repository authoritative while still using default discovery
locations whenever possible. 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 ## Propagation Model
There are only two supported creation paths: 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 Once a resource lands in the repository, commit and push it. Other systems pick
it up through `install/update.*` or future scheduled sync. 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
View 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.

View File

@@ -10,6 +10,18 @@
- Use an agent for a reusable persona with tool restrictions or handoffs. - 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. - 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 ## Supported Creation Paths
### Repo-first ### Repo-first

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

View File

@@ -21,3 +21,50 @@ install/verify.ps1
``` ```
Scheduled sync will be added on top of the same update and verify entrypoints. Scheduled sync will be added on top of the same update and verify entrypoints.
## Port Registry
Session start hooks append events and also synchronize project-local port
declarations into the machine-wide registry.
Source-of-truth file:
- `~/.copilot-resources-state/project-ports-registry.json`
Project-local declaration file:
- `.local/project-ports.json`
Manual sync for the current workspace:
```bash
node ~/.copilot-resources/resources/scripts/update-port-registry.mjs
```
Conflict report:
```bash
node ~/.copilot-resources/resources/scripts/update-port-registry.mjs --report
```
## 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.

View File

@@ -8,3 +8,15 @@ A new shared resource should be reviewed for:
- Security implications, especially for hooks and scripts - Security implications, especially for hooks and scripts
- Clear purpose and expected usage - Clear purpose and expected usage
- Duplication with existing resources - 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`

View File

@@ -17,7 +17,10 @@ install/bootstrap.ps1
- Creates a canonical path at `~/.copilot-resources` - Creates a canonical path at `~/.copilot-resources`
- Links default discovery locations back to this repository - Links default discovery locations back to this repository
- Merges only the managed Copilot-related VS Code settings into the user settings file - 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 - 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
- Creates a project-local port declaration file at `.local/project-ports.json` on first session start if it does not exist yet
- Writes a local install-state file outside the repository - Writes a local install-state file outside the repository
## Optional Settings ## Optional Settings
@@ -31,3 +34,32 @@ possible.
Bootstrap also writes a managed Copilot CLI environment file into the local 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 state directory and adds a small managed source block to the active shell or
PowerShell profile instead of replacing the whole profile. 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`.

View File

@@ -15,6 +15,38 @@ Treat hot reload as likely but not guaranteed. If needed:
- restart the Copilot CLI session - restart the Copilot CLI session
- reopen VS Code - 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.
## Port Registry Not Updating
If `~/.copilot-resources-state/project-ports-registry.json` is missing or stale:
- Run `install/verify.sh --quick` and confirm the hook script paths exist.
- Run `node ~/.copilot-resources/resources/scripts/update-port-registry.mjs`.
- Check `~/.copilot-resources-state/project-ports-errors.log` for parse or
write failures.
If `.local/project-ports.json` contains invalid JSON, fix the JSON and re-run
the sync command.
To review collisions and recommended project changes:
- Run `node ~/.copilot-resources/resources/scripts/update-port-registry.mjs --report`.
## Publish Refused To Overwrite ## Publish Refused To Overwrite
The publish scripts stop on collisions by default. Use a new name or rerun with The publish scripts stop on collisions by default. Use a new name or rerun with

View File

@@ -9,6 +9,9 @@ $VscodeUserDir = if ($env:VSCODE_USER_DIR) { $env:VSCODE_USER_DIR } else { Join-
$ManagedShellEnv = $null $ManagedShellEnv = $null
$ProfilePath = $PROFILE.CurrentUserAllHosts $ProfilePath = $PROFILE.CurrentUserAllHosts
$VscodeSettingsFile = Join-Path $VscodeUserDir 'settings.json' $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 { function Resolve-Directory {
param([string]$Path) param([string]$Path)
@@ -127,6 +130,14 @@ function Write-ManagedPowerShellEnv {
-Body "if (Test-Path -LiteralPath $QuotedEnvPath) {`n . $QuotedEnvPath`n}" -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 { function Merge-VscodeSettings {
$NodeExecutable = Find-NodeExecutable $NodeExecutable = Find-NodeExecutable
if (-not $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" & $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) Ensure-Directory (Split-Path -Parent $CanonicalHome)
if (Test-Path -LiteralPath $CanonicalHome) { if (Test-Path -LiteralPath $CanonicalHome) {
if ((Resolve-Directory $CanonicalHome) -ne (Resolve-Directory $RepoRoot)) { 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') Ensure-Junction -Target (Join-Path $CanonicalHome 'resources\prompts') -Path (Join-Path $VscodeUserDir 'prompts')
Write-ManagedPowerShellEnv Write-ManagedPowerShellEnv
Ensure-LocalMcpOverrides
Merge-VscodeSettings Merge-VscodeSettings
Merge-ManagedMcpConfig
Ensure-Directory $StateDir Ensure-Directory $StateDir
@{ @{
@@ -166,6 +193,9 @@ Ensure-Directory $StateDir
copilotHome = $CopilotHome copilotHome = $CopilotHome
vscodeUserDir = $VscodeUserDir vscodeUserDir = $VscodeUserDir
vscodeSettingsFile = $VscodeSettingsFile vscodeSettingsFile = $VscodeSettingsFile
vscodeMcpFile = $VscodeMcpFile
copilotCliMcpFile = $CopilotCliMcpFile
mcpLocalOverridesFile = $LocalMcpOverridesFile
shellRcFile = $ProfilePath shellRcFile = $ProfilePath
managedShellEnv = $ManagedShellEnv managedShellEnv = $ManagedShellEnv
bootstrapScript = (Join-Path $ScriptDir 'bootstrap.ps1') bootstrapScript = (Join-Path $ScriptDir 'bootstrap.ps1')
@@ -177,6 +207,9 @@ Write-Host "Repository root: $RepoRoot"
Write-Host "Copilot home: $CopilotHome" Write-Host "Copilot home: $CopilotHome"
Write-Host "VS Code user dir: $VscodeUserDir" Write-Host "VS Code user dir: $VscodeUserDir"
Write-Host "Merged managed VS Code settings into: $VscodeSettingsFile" 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 "Installed managed Copilot CLI PowerShell environment into: $ManagedShellEnv"
Write-Host "Linked PowerShell profile: $ProfilePath" Write-Host "Linked PowerShell profile: $ProfilePath"
Write-Host "Optional VS Code template: $(Join-Path $CanonicalHome 'config\vscode\settings.template.jsonc')" Write-Host "Optional VS Code template: $(Join-Path $CanonicalHome 'config\vscode\settings.template.jsonc')"
Write-Host "Machine-local MCP overrides live in: $LocalMcpOverridesFile"

56
install/bootstrap.sh Normal file → Executable file
View File

@@ -10,6 +10,9 @@ copilot_home="${COPILOT_HOME:-$HOME/.copilot}"
managed_shell_env="" managed_shell_env=""
shell_rc_file="" shell_rc_file=""
vscode_settings_file="" vscode_settings_file=""
vscode_mcp_file=""
copilot_cli_mcp_file=""
local_mcp_overrides_file=""
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -163,6 +166,18 @@ EOF
"$shell_block" "$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() { merge_vscode_settings() {
local node_bin local node_bin
vscode_settings_file="$vscode_user_dir/settings.json" vscode_settings_file="$vscode_user_dir/settings.json"
@@ -180,6 +195,35 @@ merge_vscode_settings() {
--set "COPILOT_RESOURCES_HOME=$canonical_home" --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() { write_state() {
mkdir -p -- "$state_dir" mkdir -p -- "$state_dir"
cat > "$state_dir/install-state.json" <<EOF cat > "$state_dir/install-state.json" <<EOF
@@ -189,6 +233,9 @@ write_state() {
"copilotHome": "${copilot_home}", "copilotHome": "${copilot_home}",
"vscodeUserDir": "${vscode_user_dir}", "vscodeUserDir": "${vscode_user_dir}",
"vscodeSettingsFile": "${vscode_settings_file}", "vscodeSettingsFile": "${vscode_settings_file}",
"vscodeMcpFile": "${vscode_mcp_file}",
"copilotCliMcpFile": "${copilot_cli_mcp_file}",
"mcpLocalOverridesFile": "${local_mcp_overrides_file}",
"shellRcFile": "${shell_rc_file}", "shellRcFile": "${shell_rc_file}",
"managedShellEnv": "${managed_shell_env}", "managedShellEnv": "${managed_shell_env}",
"bootstrapScript": "${script_dir}/bootstrap.sh" "bootstrapScript": "${script_dir}/bootstrap.sh"
@@ -232,7 +279,9 @@ main() {
link_path "$canonical_home/resources/prompts" "$vscode_user_dir/prompts" link_path "$canonical_home/resources/prompts" "$vscode_user_dir/prompts"
write_managed_shell_env write_managed_shell_env
ensure_local_mcp_overrides
merge_vscode_settings merge_vscode_settings
merge_managed_mcp_config
write_state write_state
@@ -250,6 +299,10 @@ instructions, hooks, and prompts.
Merged managed VS Code settings into: Merged managed VS Code settings into:
$vscode_settings_file $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: Installed managed Copilot CLI shell environment into:
$managed_shell_env $managed_shell_env
@@ -259,6 +312,9 @@ Linked shell startup file:
Optional VS Code feature flags are available in: Optional VS Code feature flags are available in:
$canonical_home/config/vscode/settings.template.jsonc $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: Optional Copilot CLI environment template is available in:
$canonical_home/config/copilot-cli/env.example.sh $canonical_home/config/copilot-cli/env.example.sh
EOF EOF

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
function resolveWorkspaceDir() {
const workspaceDir = process.env.COPILOT_MCP_FILESYSTEM_ROOT
? path.resolve(process.env.COPILOT_MCP_FILESYSTEM_ROOT)
: process.cwd();
if (!fs.existsSync(workspaceDir)) {
throw new Error(`Filesystem MCP workspace does not exist: ${workspaceDir}`);
}
if (!fs.statSync(workspaceDir).isDirectory()) {
throw new Error(`Filesystem MCP workspace is not a directory: ${workspaceDir}`);
}
return workspaceDir;
}
function main() {
const workspaceDir = resolveWorkspaceDir();
const dockerArgs = [
"run",
"-i",
"--rm",
"--mount",
`type=bind,src=${workspaceDir},dst=/projects/workspace`,
"mcp/filesystem",
"/projects/workspace",
];
const child = spawn("docker", dockerArgs, {
stdio: "inherit",
windowsHide: true,
});
child.on("error", (error) => {
console.error(`Failed to start Docker for the filesystem MCP server: ${error.message}`);
process.exit(1);
});
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.on(signal, () => {
if (!child.killed) {
child.kill(signal);
}
});
}
child.on("exit", (code, signal) => {
if (signal) {
console.error(`Filesystem MCP server exited with signal ${signal}`);
process.exit(1);
}
process.exit(code ?? 0);
});
}
main();

View File

@@ -0,0 +1,834 @@
#!/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) {
if (!input.trim()) {
return {
type: "object",
start: 0,
end: 0,
properties: [],
value: {},
};
}
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 existingTargetText = fs.existsSync(options.target)
? fs.readFileSync(options.target, "utf8")
: "";
const targetText = existingTargetText.trim() ? existingTargetText : "{}\n";
const targetAst = parseJsoncAst(targetText, options.target);
const cleanedTargetText = removeStaleManagedServers(
targetText,
targetAst,
options.serverKey,
allManagedServerNames,
desiredServerNames,
);
const cleanedTargetAst = parseJsoncAst(cleanedTargetText, options.target);
const indentUnit = detectIndentUnit(targetText);
const eol = detectEol(targetText);
const edits = [];
buildMergeEdits(
cleanedTargetText,
cleanedTargetAst,
desiredConfig,
indentUnit,
eol,
edits,
);
const output =
edits.length === 0 ? cleanedTargetText : applyEdits(cleanedTargetText, edits);
parseJsonc(output, options.target);
fs.mkdirSync(path.dirname(options.target), { recursive: true });
if (output === targetText) {
console.log(`Managed MCP config already up to date: ${options.target}`);
return;
}
fs.writeFileSync(options.target, output, "utf8");
console.log(`Merged managed MCP config into: ${options.target}`);
}
main();

View File

@@ -0,0 +1,301 @@
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("creates managed MCP config from an empty target file", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-"));
const targetFile = path.join(tempDir, "mcp.json");
fs.writeFileSync(targetFile, "", "utf8");
const result = runMerge({
targetFile,
templateFile: vscodeTemplateFile,
serverKey: "servers",
});
assert.equal(result.status, 0, result.stderr);
const output = fs.readFileSync(targetFile, "utf8");
const parsed = parseJsonc(output);
assert.equal(parsed.servers.playwright.command, "npx");
assert.equal(parsed.servers.filesystem.command, "docker");
});
test("renders optional Gitea config when local overrides are complete", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-"));
const targetFile = path.join(tempDir, "mcp-config.json");
const overridesFile = path.join(tempDir, "mcp.local.jsonc");
fs.writeFileSync(
overridesFile,
`{
"servers": {
"gitea": {
"enabled": true,
"serverUrl": "https://git.example.com",
"token": "secret-token"
}
}
}
`,
"utf8",
);
const result = runMerge({
targetFile,
templateFile: copilotCliTemplateFile,
serverKey: "mcpServers",
overridesFile,
});
assert.equal(result.status, 0, result.stderr);
const parsed = parseJsonc(fs.readFileSync(targetFile, "utf8"));
assert.equal(
parsed.mcpServers.filesystem.args[0],
"/repo/home/install/mcp/copilot-cli-filesystem-wrapper.mjs",
);
assert.equal(parsed.mcpServers.gitea.env.FORGEJOMCP_SERVER, "https://git.example.com");
assert.equal(parsed.mcpServers.gitea.env.FORGEJOMCP_TOKEN, "secret-token");
assert.equal(parsed.mcpServers.playwright, undefined);
});
test("second run is idempotent and keeps the file text unchanged", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-"));
const targetFile = path.join(tempDir, "mcp.json");
fs.writeFileSync(
targetFile,
`{
// user comment
"servers": {
"customServer": {
"type": "http",
"url": "https://example.com/mcp"
}
}
}
`,
"utf8",
);
const firstRun = runMerge({
targetFile,
templateFile: vscodeTemplateFile,
serverKey: "servers",
});
assert.equal(firstRun.status, 0, firstRun.stderr);
const firstOutput = fs.readFileSync(targetFile, "utf8");
const secondRun = runMerge({
targetFile,
templateFile: vscodeTemplateFile,
serverKey: "servers",
});
assert.equal(secondRun.status, 0, secondRun.stderr);
assert.match(secondRun.stdout, /already up to date/);
const secondOutput = fs.readFileSync(targetFile, "utf8");
assert.equal(secondOutput, firstOutput);
assert.match(secondOutput, /\/\/ user comment/);
});

View File

@@ -1,12 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
import fs from 'node:fs'; import fs from "node:fs";
import path from 'node:path'; import path from "node:path";
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from "node:util";
function usage() { function usage() {
console.error( 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) { for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]; const arg = argv[index];
if (arg === '--target') { if (arg === "--target") {
options.target = argv[index + 1]; options.target = argv[index + 1];
index += 1; index += 1;
continue; continue;
} }
if (arg === '--template') { if (arg === "--template") {
options.template = argv[index + 1]; options.template = argv[index + 1];
index += 1; index += 1;
continue; continue;
} }
if (arg === '--set') { if (arg === "--set") {
const assignment = argv[index + 1] ?? ''; const assignment = argv[index + 1] ?? "";
const equalsIndex = assignment.indexOf('='); const equalsIndex = assignment.indexOf("=");
if (equalsIndex <= 0) { if (equalsIndex <= 0) {
throw new Error(`Invalid --set assignment: ${assignment}`); throw new Error(`Invalid --set assignment: ${assignment}`);
} }
@@ -48,14 +48,14 @@ function parseArgs(argv) {
if (!options.target || !options.template) { if (!options.target || !options.template) {
usage(); usage();
throw new Error('Both --target and --template are required.'); throw new Error("Both --target and --template are required.");
} }
return options; return options;
} }
function stripJsonComments(input) { function stripJsonComments(input) {
let output = ''; let output = "";
let inString = false; let inString = false;
let escaping = false; let escaping = false;
let inLineComment = false; let inLineComment = false;
@@ -66,7 +66,7 @@ function stripJsonComments(input) {
const next = input[index + 1]; const next = input[index + 1];
if (inLineComment) { if (inLineComment) {
if (char === '\n') { if (char === "\n") {
inLineComment = false; inLineComment = false;
output += char; output += char;
} }
@@ -74,10 +74,10 @@ function stripJsonComments(input) {
} }
if (inBlockComment) { if (inBlockComment) {
if (char === '*' && next === '/') { if (char === "*" && next === "/") {
inBlockComment = false; inBlockComment = false;
index += 1; index += 1;
} else if (char === '\n' || char === '\r') { } else if (char === "\n" || char === "\r") {
output += char; output += char;
} }
continue; continue;
@@ -87,7 +87,7 @@ function stripJsonComments(input) {
output += char; output += char;
if (escaping) { if (escaping) {
escaping = false; escaping = false;
} else if (char === '\\') { } else if (char === "\\") {
escaping = true; escaping = true;
} else if (char === '"') { } else if (char === '"') {
inString = false; inString = false;
@@ -101,13 +101,13 @@ function stripJsonComments(input) {
continue; continue;
} }
if (char === '/' && next === '/') { if (char === "/" && next === "/") {
inLineComment = true; inLineComment = true;
index += 1; index += 1;
continue; continue;
} }
if (char === '/' && next === '*') { if (char === "/" && next === "*") {
inBlockComment = true; inBlockComment = true;
index += 1; index += 1;
continue; continue;
@@ -120,7 +120,7 @@ function stripJsonComments(input) {
} }
function stripTrailingCommas(input) { function stripTrailingCommas(input) {
let output = ''; let output = "";
let inString = false; let inString = false;
let escaping = false; let escaping = false;
@@ -131,7 +131,7 @@ function stripTrailingCommas(input) {
output += char; output += char;
if (escaping) { if (escaping) {
escaping = false; escaping = false;
} else if (char === '\\') { } else if (char === "\\") {
escaping = true; escaping = true;
} else if (char === '"') { } else if (char === '"') {
inString = false; inString = false;
@@ -145,12 +145,12 @@ function stripTrailingCommas(input) {
continue; continue;
} }
if (char === ',') { if (char === ",") {
let lookahead = index + 1; let lookahead = index + 1;
while (lookahead < input.length && /\s/.test(input[lookahead])) { while (lookahead < input.length && /\s/.test(input[lookahead])) {
lookahead += 1; lookahead += 1;
} }
if (input[lookahead] === '}' || input[lookahead] === ']') { if (input[lookahead] === "}" || input[lookahead] === "]") {
continue; continue;
} }
} }
@@ -166,9 +166,7 @@ function renderTemplate(input, replacements) {
if (!(key in replacements)) { if (!(key in replacements)) {
return match; return match;
} }
return replacements[key] return replacements[key].replace(/\\/g, "\\\\").replace(/"/g, '\\"');
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
}); });
} }
@@ -209,21 +207,24 @@ function parseJsoncAst(input, label) {
continue; continue;
} }
if (char === '/' && next === '/') { if (char === "/" && next === "/") {
index += 2; index += 2;
while (index < input.length && input[index] !== '\n') { while (index < input.length && input[index] !== "\n") {
index += 1; index += 1;
} }
continue; continue;
} }
if (char === '/' && next === '*') { if (char === "/" && next === "*") {
index += 2; index += 2;
while (index < input.length && !(input[index] === '*' && input[index + 1] === '/')) { while (
index < input.length &&
!(input[index] === "*" && input[index + 1] === "/")
) {
index += 1; index += 1;
} }
if (index >= input.length) { if (index >= input.length) {
fail('unterminated block comment'); fail("unterminated block comment");
} }
index += 2; index += 2;
continue; continue;
@@ -240,7 +241,7 @@ function parseJsoncAst(input, label) {
while (index < input.length) { while (index < input.length) {
const char = input[index]; const char = input[index];
if (char === '\\') { if (char === "\\") {
index += 2; index += 2;
continue; continue;
} }
@@ -249,7 +250,7 @@ function parseJsoncAst(input, label) {
index += 1; index += 1;
const raw = input.slice(start, index); const raw = input.slice(start, index);
return { return {
type: 'string', type: "string",
start, start,
end: index, end: index,
value: JSON.parse(raw), value: JSON.parse(raw),
@@ -259,7 +260,7 @@ function parseJsoncAst(input, label) {
index += 1; index += 1;
} }
fail('unterminated string literal'); fail("unterminated string literal");
} }
function parseLiteralNode(expectedText, value) { function parseLiteralNode(expectedText, value) {
@@ -277,7 +278,9 @@ function parseJsoncAst(input, label) {
} }
function parseNumberNode() { 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) { if (!match) {
fail(`invalid number at offset ${index}`); fail(`invalid number at offset ${index}`);
} }
@@ -285,7 +288,7 @@ function parseJsoncAst(input, label) {
const start = index; const start = index;
index += match[0].length; index += match[0].length;
return { return {
type: 'number', type: "number",
start, start,
end: index, end: index,
value: Number(match[0]), value: Number(match[0]),
@@ -298,29 +301,29 @@ function parseJsoncAst(input, label) {
const elements = []; const elements = [];
skipTrivia(); skipTrivia();
while (index < input.length && input[index] !== ']') { while (index < input.length && input[index] !== "]") {
const valueNode = parseValueNode(); const valueNode = parseValueNode();
elements.push(valueNode); elements.push(valueNode);
skipTrivia(); skipTrivia();
if (input[index] === ',') { if (input[index] === ",") {
index += 1; index += 1;
skipTrivia(); skipTrivia();
continue; continue;
} }
if (input[index] !== ']') { if (input[index] !== "]") {
fail(`expected ',' or ']' at offset ${index}`); fail(`expected ',' or ']' at offset ${index}`);
} }
} }
if (input[index] !== ']') { if (input[index] !== "]") {
fail('unterminated array'); fail("unterminated array");
} }
index += 1; index += 1;
return { return {
type: 'array', type: "array",
start, start,
end: index, end: index,
elements, elements,
@@ -335,7 +338,7 @@ function parseJsoncAst(input, label) {
const value = {}; const value = {};
skipTrivia(); skipTrivia();
while (index < input.length && input[index] !== '}') { while (index < input.length && input[index] !== "}") {
if (input[index] !== '"') { if (input[index] !== '"') {
fail(`expected string property name at offset ${index}`); fail(`expected string property name at offset ${index}`);
} }
@@ -344,7 +347,7 @@ function parseJsoncAst(input, label) {
const key = keyNode.value; const key = keyNode.value;
skipTrivia(); skipTrivia();
if (input[index] !== ':') { if (input[index] !== ":") {
fail(`expected ':' after property name at offset ${index}`); fail(`expected ':' after property name at offset ${index}`);
} }
@@ -363,25 +366,25 @@ function parseJsoncAst(input, label) {
value[key] = valueNode.value; value[key] = valueNode.value;
skipTrivia(); skipTrivia();
if (input[index] === ',') { if (input[index] === ",") {
property.hasTrailingComma = true; property.hasTrailingComma = true;
index += 1; index += 1;
skipTrivia(); skipTrivia();
continue; continue;
} }
if (input[index] !== '}') { if (input[index] !== "}") {
fail(`expected ',' or '}' at offset ${index}`); fail(`expected ',' or '}' at offset ${index}`);
} }
} }
if (input[index] !== '}') { if (input[index] !== "}") {
fail('unterminated object'); fail("unterminated object");
} }
index += 1; index += 1;
return { return {
type: 'object', type: "object",
start, start,
end: index, end: index,
properties, properties,
@@ -393,25 +396,25 @@ function parseJsoncAst(input, label) {
skipTrivia(); skipTrivia();
const char = input[index]; const char = input[index];
if (char === '{') { if (char === "{") {
return parseObjectNode(); return parseObjectNode();
} }
if (char === '[') { if (char === "[") {
return parseArrayNode(); return parseArrayNode();
} }
if (char === '"') { if (char === '"') {
return parseStringNode(); return parseStringNode();
} }
if (char === 't') { if (char === "t") {
return parseLiteralNode('true', true); return parseLiteralNode("true", true);
} }
if (char === 'f') { if (char === "f") {
return parseLiteralNode('false', false); return parseLiteralNode("false", false);
} }
if (char === 'n') { if (char === "n") {
return parseLiteralNode('null', null); return parseLiteralNode("null", null);
} }
if (char === '-' || /\d/.test(char ?? '')) { if (char === "-" || /\d/.test(char ?? "")) {
return parseNumberNode(); return parseNumberNode();
} }
@@ -426,7 +429,7 @@ function parseJsoncAst(input, label) {
fail(`unexpected trailing content at offset ${index}`); 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.`); throw new Error(`${label} must contain a JSON object at the root.`);
} }
@@ -434,31 +437,31 @@ function parseJsoncAst(input, label) {
} }
function isPlainObject(value) { function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value); return value !== null && typeof value === "object" && !Array.isArray(value);
} }
function detectIndentUnit(input) { function detectIndentUnit(input) {
const matches = input.match(/^( +|\t+)\S/m); const matches = input.match(/^( +|\t+)\S/m);
if (!matches) { if (!matches) {
return ' '; return " ";
} }
return matches[1]; return matches[1];
} }
function detectEol(input) { function detectEol(input) {
return input.includes('\r\n') ? '\r\n' : '\n'; return input.includes("\r\n") ? "\r\n" : "\n";
} }
function lineStartIndex(input, index) { function lineStartIndex(input, index) {
const newlineIndex = input.lastIndexOf('\n', index - 1); const newlineIndex = input.lastIndexOf("\n", index - 1);
return newlineIndex === -1 ? 0 : newlineIndex + 1; return newlineIndex === -1 ? 0 : newlineIndex + 1;
} }
function lineIndentAt(input, index) { function lineIndentAt(input, index) {
const start = lineStartIndex(input, index); const start = lineStartIndex(input, index);
let end = start; let end = start;
while (end < input.length && (input[end] === ' ' || input[end] === '\t')) { while (end < input.length && (input[end] === " " || input[end] === "\t")) {
end += 1; end += 1;
} }
return input.slice(start, end); return input.slice(start, end);
@@ -466,12 +469,12 @@ function lineIndentAt(input, index) {
function renderValue(value, propertyIndent, indentUnit, eol) { function renderValue(value, propertyIndent, indentUnit, eol) {
const raw = JSON.stringify(value, null, indentUnit); const raw = JSON.stringify(value, null, indentUnit);
if (!raw.includes('\n')) { if (!raw.includes("\n")) {
return raw; return raw;
} }
return raw return raw
.split('\n') .split("\n")
.map((line, index) => (index === 0 ? line : `${propertyIndent}${line}`)) .map((line, index) => (index === 0 ? line : `${propertyIndent}${line}`))
.join(eol); .join(eol);
} }
@@ -488,19 +491,35 @@ function getObjectChildIndent(input, objectNode, indentUnit) {
return `${lineIndentAt(input, objectNode.start)}${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 = []; const missingEntries = [];
for (const [key, managedValue] of Object.entries(managedSettings)) { 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) { if (!property) {
missingEntries.push([key, managedValue]); missingEntries.push([key, managedValue]);
continue; continue;
} }
if (isPlainObject(managedValue) && property.value.type === 'object') { if (isPlainObject(managedValue) && property.value.type === "object") {
buildMergeEdits(input, property.value, managedValue, indentUnit, eol, edits); buildMergeEdits(
input,
property.value,
managedValue,
indentUnit,
eol,
edits,
);
continue; continue;
} }
@@ -520,14 +539,18 @@ function buildMergeEdits(input, objectNode, managedSettings, indentUnit, eol, ed
const propertyIndent = getObjectChildIndent(input, objectNode, indentUnit); const propertyIndent = getObjectChildIndent(input, objectNode, indentUnit);
const closingIndent = lineIndentAt(input, objectNode.end); const closingIndent = lineIndentAt(input, objectNode.end);
const closingBraceIndex = objectNode.end - 1;
const renderedProperties = missingEntries 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}`); .join(`,${eol}`);
if (objectNode.properties.length === 0) { if (objectNode.properties.length === 0) {
edits.push({ edits.push({
start: objectNode.start + 1, start: objectNode.start + 1,
end: objectNode.end, end: closingBraceIndex,
text: `${eol}${renderedProperties}${eol}${closingIndent}`, text: `${eol}${renderedProperties}${eol}${closingIndent}`,
}); });
return; return;
@@ -538,7 +561,7 @@ function buildMergeEdits(input, objectNode, managedSettings, indentUnit, eol, ed
edits.push({ edits.push({
start: lastProperty.value.end, start: lastProperty.value.end,
end: lastProperty.value.end, end: lastProperty.value.end,
text: ',', text: ",",
}); });
} }
@@ -558,27 +581,36 @@ function applyEdits(input, edits) {
return edits return edits
.sort((left, right) => right.start - left.start || right.end - left.end) .sort((left, right) => right.start - left.start || right.end - left.end)
.reduce( .reduce(
(text, edit) => `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`, (text, edit) =>
input `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`,
input,
); );
} }
function main() { function main() {
const options = parseArgs(process.argv.slice(2)); 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 renderedTemplate = renderTemplate(templateText, options.replacements);
const managedSettings = parseJsonc(renderedTemplate, options.template); const managedSettings = parseJsonc(renderedTemplate, options.template);
const targetText = fs.existsSync(options.target) const targetText = fs.existsSync(options.target)
? fs.readFileSync(options.target, 'utf8') ? fs.readFileSync(options.target, "utf8")
: '{}\n'; : "{}\n";
const targetAst = parseJsoncAst(targetText, options.target); const targetAst = parseJsoncAst(targetText, options.target);
const indentUnit = detectIndentUnit(targetText); const indentUnit = detectIndentUnit(targetText);
const eol = detectEol(targetText); const eol = detectEol(targetText);
const edits = []; const edits = [];
buildMergeEdits(targetText, targetAst, managedSettings, indentUnit, eol, edits); buildMergeEdits(
const output = edits.length === 0 ? targetText : applyEdits(targetText, edits); targetText,
targetAst,
managedSettings,
indentUnit,
eol,
edits,
);
const output =
edits.length === 0 ? targetText : applyEdits(targetText, edits);
parseJsonc(output, options.target); parseJsonc(output, options.target);
@@ -589,8 +621,8 @@ function main() {
return; return;
} }
fs.writeFileSync(options.target, output, 'utf8'); fs.writeFileSync(options.target, output, "utf8");
console.log(`Merged managed VS Code settings into: ${options.target}`); console.log(`Merged managed VS Code settings into: ${options.target}`);
} }
main(); main();

View File

@@ -1,18 +1,23 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import { spawnSync } from 'node:child_process'; import { spawnSync } from "node:child_process";
import fs from 'node:fs'; import fs from "node:fs";
import os from 'node:os'; import os from "node:os";
import path from 'node:path'; import path from "node:path";
import test from 'node:test'; import test from "node:test";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(scriptDir, "..");
const mergeScript = path.join(scriptDir, 'merge-vscode-settings.mjs'); const mergeScript = path.join(scriptDir, "merge-vscode-settings.mjs");
const templateFile = path.join(repoRoot, 'config', 'vscode', 'settings.template.jsonc'); const templateFile = path.join(
repoRoot,
"config",
"vscode",
"settings.template.jsonc",
);
function stripJsonComments(input) { function stripJsonComments(input) {
let output = ''; let output = "";
let inString = false; let inString = false;
let escaping = false; let escaping = false;
let inLineComment = false; let inLineComment = false;
@@ -23,7 +28,7 @@ function stripJsonComments(input) {
const next = input[index + 1]; const next = input[index + 1];
if (inLineComment) { if (inLineComment) {
if (char === '\n') { if (char === "\n") {
inLineComment = false; inLineComment = false;
output += char; output += char;
} }
@@ -31,10 +36,10 @@ function stripJsonComments(input) {
} }
if (inBlockComment) { if (inBlockComment) {
if (char === '*' && next === '/') { if (char === "*" && next === "/") {
inBlockComment = false; inBlockComment = false;
index += 1; index += 1;
} else if (char === '\n' || char === '\r') { } else if (char === "\n" || char === "\r") {
output += char; output += char;
} }
continue; continue;
@@ -44,7 +49,7 @@ function stripJsonComments(input) {
output += char; output += char;
if (escaping) { if (escaping) {
escaping = false; escaping = false;
} else if (char === '\\') { } else if (char === "\\") {
escaping = true; escaping = true;
} else if (char === '"') { } else if (char === '"') {
inString = false; inString = false;
@@ -58,13 +63,13 @@ function stripJsonComments(input) {
continue; continue;
} }
if (char === '/' && next === '/') { if (char === "/" && next === "/") {
inLineComment = true; inLineComment = true;
index += 1; index += 1;
continue; continue;
} }
if (char === '/' && next === '*') { if (char === "/" && next === "*") {
inBlockComment = true; inBlockComment = true;
index += 1; index += 1;
continue; continue;
@@ -77,7 +82,7 @@ function stripJsonComments(input) {
} }
function stripTrailingCommas(input) { function stripTrailingCommas(input) {
let output = ''; let output = "";
let inString = false; let inString = false;
let escaping = false; let escaping = false;
@@ -88,7 +93,7 @@ function stripTrailingCommas(input) {
output += char; output += char;
if (escaping) { if (escaping) {
escaping = false; escaping = false;
} else if (char === '\\') { } else if (char === "\\") {
escaping = true; escaping = true;
} else if (char === '"') { } else if (char === '"') {
inString = false; inString = false;
@@ -102,12 +107,12 @@ function stripTrailingCommas(input) {
continue; continue;
} }
if (char === ',') { if (char === ",") {
let lookahead = index + 1; let lookahead = index + 1;
while (lookahead < input.length && /\s/.test(input[lookahead])) { while (lookahead < input.length && /\s/.test(input[lookahead])) {
lookahead += 1; lookahead += 1;
} }
if (input[lookahead] === '}' || input[lookahead] === ']') { if (input[lookahead] === "}" || input[lookahead] === "]") {
continue; continue;
} }
} }
@@ -127,23 +132,23 @@ function runMerge(targetFile) {
process.execPath, process.execPath,
[ [
mergeScript, mergeScript,
'--target', "--target",
targetFile, targetFile,
'--template', "--template",
templateFile, templateFile,
'--set', "--set",
'COPILOT_RESOURCES_HOME=/repo/home', "COPILOT_RESOURCES_HOME=/repo/home",
], ],
{ {
cwd: repoRoot, cwd: repoRoot,
encoding: 'utf8', encoding: "utf8",
} },
); );
} }
test('preserves comments and custom nested entries while inserting managed settings', () => { test("preserves comments and custom nested entries while inserting managed settings", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-settings-')); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-settings-"));
const targetFile = path.join(tempDir, 'settings.json'); const targetFile = path.join(tempDir, "settings.json");
fs.writeFileSync( fs.writeFileSync(
targetFile, targetFile,
@@ -163,13 +168,13 @@ test('preserves comments and custom nested entries while inserting managed setti
} }
} }
`, `,
'utf8' "utf8",
); );
const result = runMerge(targetFile); const result = runMerge(targetFile);
assert.equal(result.status, 0, result.stderr); 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, /\/\/ keep this comment/);
assert.match(output, /\/\/ preserve nested comments/); assert.match(output, /\/\/ preserve nested comments/);
assert.match(output, /\/\/ custom agents stay/); 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/); assert.match(output, /"\/repo\/home\/resources\/agents": true/);
const parsed = parseJsonc(output); const parsed = parseJsonc(output);
assert.equal(parsed['workbench.colorTheme'], 'GitHub Dark Mode'); assert.equal(parsed["workbench.colorTheme"], "GitHub Dark Mode");
assert.equal(parsed['chat.agentFilesLocations']['/custom/agents'], true); assert.equal(parsed["chat.agentFilesLocations"]["/custom/agents"], true);
assert.equal(parsed['chat.agentFilesLocations']['/repo/home/resources/agents'], true); assert.equal(
assert.equal(parsed['chat.agentFilesLocations']['~/.copilot/agents'], true); parsed["chat.agentFilesLocations"]["/repo/home/resources/agents"],
assert.equal(parsed['chat.instructionsFilesLocations']['/old/instructions'], true); true,
assert.equal(parsed['chat.instructionsFilesLocations']['/repo/home/resources/instructions'], true); );
assert.equal(parsed['chat.instructionsFilesLocations']['~/.claude/rules'], 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', () => { test("second run is idempotent and keeps the file text unchanged", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-settings-')); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-settings-"));
const targetFile = path.join(tempDir, 'settings.json'); const targetFile = path.join(tempDir, "settings.json");
fs.writeFileSync( fs.writeFileSync(
targetFile, targetFile,
@@ -199,18 +218,40 @@ test('second run is idempotent and keeps the file text unchanged', () => {
} }
} }
`, `,
'utf8' "utf8",
); );
const firstRun = runMerge(targetFile); const firstRun = runMerge(targetFile);
assert.equal(firstRun.status, 0, firstRun.stderr); assert.equal(firstRun.status, 0, firstRun.stderr);
const firstOutput = fs.readFileSync(targetFile, 'utf8'); const firstOutput = fs.readFileSync(targetFile, "utf8");
const secondRun = runMerge(targetFile); const secondRun = runMerge(targetFile);
assert.equal(secondRun.status, 0, secondRun.stderr); assert.equal(secondRun.status, 0, secondRun.stderr);
assert.match(secondRun.stdout, /already up to date/); 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.equal(secondOutput, firstOutput);
assert.match(secondOutput, /\/\/ user comment/); assert.match(secondOutput, /\/\/ user comment/);
}); });
test("creates a valid settings file from an empty target", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-settings-"));
const targetFile = path.join(tempDir, "settings.json");
fs.writeFileSync(targetFile, "{}\n", "utf8");
const result = runMerge(targetFile);
assert.equal(result.status, 0, result.stderr);
const parsed = parseJsonc(fs.readFileSync(targetFile, "utf8"));
assert.equal(
parsed["chat.agentFilesLocations"]["/repo/home/resources/agents"],
true,
);
assert.equal(
parsed["chat.instructionsFilesLocations"][
"/repo/home/resources/instructions"
],
true,
);
});

0
install/publish.sh Normal file → Executable file
View File

View File

@@ -9,4 +9,5 @@ if (Test-Path -LiteralPath (Join-Path $RepoRoot '.git')) {
Write-Host 'Skipping git pull because this repository is not initialized as a git repository yet.' 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 & (Join-Path $ScriptDir 'verify.ps1') -Quick

1
install/update.sh Normal file → Executable file
View File

@@ -12,6 +12,7 @@ main() {
printf 'Skipping git pull because this repository is not initialized as a git repository yet.\n' printf 'Skipping git pull because this repository is not initialized as a git repository yet.\n'
fi fi
"$script_dir/bootstrap.sh"
"$script_dir/verify.sh" --quick "$script_dir/verify.sh" --quick
} }

View File

@@ -23,15 +23,55 @@ 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 'repo root' -Path $RepoRoot
Assert-Path -Label 'canonical home' -Path $CanonicalHome Assert-Path -Label 'canonical home' -Path $CanonicalHome
Assert-Path -Label 'skills link' -Path (Join-Path $CopilotHome 'skills') Assert-Path -Label 'skills link' -Path (Join-Path $CopilotHome 'skills')
Assert-Path -Label 'agents link' -Path (Join-Path $CopilotHome 'agents') Assert-Path -Label 'agents link' -Path (Join-Path $CopilotHome 'agents')
Assert-Path -Label 'instructions link' -Path (Join-Path $CopilotHome 'instructions') Assert-Path -Label 'instructions link' -Path (Join-Path $CopilotHome 'instructions')
Assert-Path -Label 'hooks link' -Path (Join-Path $CopilotHome 'hooks') 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-Path -Label 'port registry updater script' -Path (Join-Path $CanonicalHome 'resources\scripts\update-port-registry.mjs')
Assert-ReadableFile -Label 'port registry updater script' -Path (Join-Path $CanonicalHome 'resources\scripts\update-port-registry.mjs')
Assert-Command -Label 'session start hook shell' -CommandName 'bash'
Assert-Command -Label 'port registry updater runtime' -CommandName 'node'
Assert-Path -Label 'prompts link' -Path (Join-Path $VscodeUserDir 'prompts') 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) { if (-not $Quick) {
Assert-Path -Label 'VS Code settings template' -Path (Join-Path $RepoRoot 'config\vscode\settings.template.jsonc') 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 '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')
} }

44
install/verify.sh Normal file → Executable file
View File

@@ -39,6 +39,30 @@ check_path() {
fi 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() { main() {
if [[ "${1:-}" == "--quick" ]]; then if [[ "${1:-}" == "--quick" ]]; then
quick="true" quick="true"
@@ -46,6 +70,12 @@ main() {
local vscode_user_dir local vscode_user_dir
vscode_user_dir="$(detect_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"
local port_registry_script="$canonical_home/resources/scripts/update-port-registry.mjs"
check_path "repo root" "$repo_root" check_path "repo root" "$repo_root"
check_path "canonical home" "$canonical_home" check_path "canonical home" "$canonical_home"
@@ -53,11 +83,25 @@ main() {
check_path "agents link" "$copilot_home/agents" check_path "agents link" "$copilot_home/agents"
check_path "instructions link" "$copilot_home/instructions" check_path "instructions link" "$copilot_home/instructions"
check_path "hooks link" "$copilot_home/hooks" 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_path "port registry updater script" "$port_registry_script"
check_readable_file "port registry updater script" "$port_registry_script"
check_command "session start hook shell" "bash"
check_command "port registry updater runtime" "node"
check_path "prompts link" "$vscode_user_dir/prompts" 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 if [[ "$quick" != "true" ]]; then
check_path "VS Code settings template" "$repo_root/config/vscode/settings.template.jsonc" 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 "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 fi
} }

View File

@@ -3,8 +3,8 @@
"SessionStart": [ "SessionStart": [
{ {
"type": "command", "type": "command",
"osx": "~/.copilot-resources/resources/scripts/report-hook-event.sh", "osx": "bash ~/.copilot-resources/resources/scripts/report-hook-event.sh",
"linux": "~/.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\"" "windows": "powershell -NoProfile -File \"$HOME/.copilot-resources/resources/scripts/report-hook-event.ps1\""
} }
] ]

View File

@@ -0,0 +1,14 @@
---
name: "Shared Port Registry Workflow"
description: "Use when working in projects that share development ports. Keep declared ports in project-local JSON and synchronize to the machine-wide source-of-truth registry."
applyTo: "**"
---
- Track each project's declared ports in `.local/project-ports.json` with an array field named `ports`.
- Use entries shaped like `{ "service": "web", "port": 3000, "protocol": "tcp" }`.
- Treat `.local/project-ports.json` as the writable project-local declaration source.
- Synchronize declarations to the machine-wide source-of-truth file at `~/.copilot-resources-state/project-ports-registry.json` using `node ~/.copilot-resources/resources/scripts/update-port-registry.mjs`.
- When a conflict is reported for a port, recommend changing the conflicting unlogged or newly introduced project first.
- Do not change an existing logged incumbent project's port unless the user explicitly asks.
- After changing ports in the new project, re-sync so both JSON files become consistent.
- Use `node ~/.copilot-resources/resources/scripts/update-port-registry.mjs --report` to inspect current conflicts and recommendations.

View File

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

View File

@@ -1,5 +1,38 @@
# MCP Notes # MCP Notes
This folder is reserved for reusable MCP references and safe shared This repository manages shared MCP configuration through tracked templates plus
configuration snippets. Machine-specific secrets and authenticated local server machine-local overrides.
definitions should stay out of the repository.
## 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.

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

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

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

View File

@@ -0,0 +1,19 @@
---
name: "scaffold-discord-oauth-vue3-vite"
description: "Scaffold a full-stack Vue 3 + Vite Discord OAuth setup: server bundle under src/server with PKCE, sessions, and allowlist support, plus client-side composable, DiscordAuthWidget organism, callback page, router guard, and Vite proxy."
agent: "agent"
tools: [read, search, execute, edit]
argument-hint: "project-root=<path> mode=<dry-run|apply> frontend-origin=<url> allowlist-discord-ids=<csv>"
---
Scaffold Discord OAuth for the target project following the discord-oauth-vue3-vite skill procedure.
Requirements:
- Prefer `resources/scripts/scaffold-discord-oauth-vue3-vite.sh` when the shared resources repo is available in the current workspace or agent context.
- If that script path is not available, do not stop. Fall back to the skill procedure and scaffold the `src/server/discord-oauth` bundle manually.
- Default to `--mode dry-run` unless the user explicitly asks for apply mode.
- Keep the generated auth bundle under `src/server/`.
- After the server bundle, place the client-side files using the reference templates in `resources/templates/discord-oauth-vue3-vite/src/client/`. Adapt component tier paths and SCSS `@use` imports to the target project's conventions. For projects using atomic design, colocated SCSS and a stories stub are required for the `DiscordAuthWidget` organism.
- Summarize the generated server files, the client files placed, the env vars the user still needs to set, and the runtime wiring step.
- Include a concrete next action: run `node src/server/discord-oauth/server.js` in a separate terminal (or add an `auth:dev` script) and configure the Vite proxy for `/api/auth`.

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

File diff suppressed because it is too large Load Diff

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

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

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

View File

@@ -5,4 +5,15 @@ New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
$Payload = [Console]::In.ReadToEnd() $Payload = [Console]::In.ReadToEnd()
$Payload | Add-Content -LiteralPath (Join-Path $StateDir 'hook-events.log') $Payload | Add-Content -LiteralPath (Join-Path $StateDir 'hook-events.log')
$PortRegistryScript = Join-Path $PSScriptRoot 'update-port-registry.mjs'
$NodeCommand = Get-Command node -ErrorAction SilentlyContinue
if ($NodeCommand -and (Test-Path -LiteralPath $PortRegistryScript)) {
try {
$Payload | & $NodeCommand.Source $PortRegistryScript | Out-Null
} catch {
"$(Get-Date -AsUTC -Format o) update-port-registry failed" | Add-Content -LiteralPath (Join-Path $StateDir 'project-ports-errors.log')
}
}
'{"continue": true}' '{"continue": true}'

9
resources/scripts/report-hook-event.sh Normal file → Executable file
View File

@@ -3,8 +3,17 @@
set -euo pipefail set -euo pipefail
state_dir="${COPILOT_RESOURCES_STATE_DIR:-$HOME/.copilot-resources-state}" state_dir="${COPILOT_RESOURCES_STATE_DIR:-$HOME/.copilot-resources-state}"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
port_registry_script="$script_dir/update-port-registry.mjs"
mkdir -p -- "$state_dir" mkdir -p -- "$state_dir"
event_payload="$(cat)" event_payload="$(cat)"
printf '%s\n' "$event_payload" >> "$state_dir/hook-events.log" printf '%s\n' "$event_payload" >> "$state_dir/hook-events.log"
if command -v node >/dev/null 2>&1 && [[ -f "$port_registry_script" ]]; then
if ! printf '%s' "$event_payload" | node "$port_registry_script"; then
printf '%s update-port-registry failed\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$state_dir/project-ports-errors.log"
fi
fi
printf '{"continue": true}\n' printf '{"continue": true}\n'

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const TEMPLATE_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"templates",
"discord-oauth-vue3-vite",
"src",
"server",
"discord-oauth",
);
function usage() {
console.error(`Usage: node resources/scripts/scaffold-discord-oauth-vue3-vite.mjs [options]
Required:
--project-root <path> Target project path.
Optional:
--mode <dry-run|apply> Default: dry-run
--frontend-origin <url> Default: http://localhost:5173
--allowlist-discord-ids <csv> Default: empty
--scopes <csv> Default: identify,email
--force Overwrite generated files.
--help Show this help text.
`);
}
function parseArgs(argv) {
const options = {
mode: "dry-run",
frontendOrigin: "http://localhost:5173",
allowlistDiscordIds: "",
scopes: "identify,email",
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 === "--mode") {
options.mode = argv[index + 1];
index += 1;
continue;
}
if (arg === "--frontend-origin") {
options.frontendOrigin = argv[index + 1];
index += 1;
continue;
}
if (arg === "--allowlist-discord-ids") {
options.allowlistDiscordIds = argv[index + 1];
index += 1;
continue;
}
if (arg === "--scopes") {
options.scopes = 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 (!["dry-run", "apply"].includes(options.mode)) {
throw new Error("--mode must be dry-run or apply.");
}
options.projectRoot = path.resolve(options.projectRoot);
options.targetRoot = path.join(
options.projectRoot,
"src",
"server",
"discord-oauth",
);
options.allowlistDiscordIdsJson = JSON.stringify(
options.allowlistDiscordIds
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
);
options.scopesJson = JSON.stringify(
options.scopes
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
);
return options;
}
function ensureDir(dirPath, mode) {
if (mode === "dry-run") {
return;
}
fs.mkdirSync(dirPath, { recursive: true });
}
function isTextFile(filePath) {
return /\.(?:js|json|md|txt|mjs|sh)$/.test(filePath);
}
function replacePlaceholders(content, options) {
return content
.replaceAll("__FRONTEND_ORIGIN__", options.frontendOrigin)
.replaceAll(
"__ALLOWLIST_DISCORD_IDS_JSON__",
options.allowlistDiscordIdsJson,
)
.replaceAll("__SCOPES_JSON__", options.scopesJson);
}
function walkFiles(rootDir, callback) {
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
walkFiles(fullPath, callback);
continue;
}
callback(fullPath);
}
}
function scaffold(options) {
if (!fs.existsSync(TEMPLATE_ROOT)) {
throw new Error(`Template root not found: ${TEMPLATE_ROOT}`);
}
const results = [];
walkFiles(TEMPLATE_ROOT, (sourcePath) => {
const relativePath = path.relative(TEMPLATE_ROOT, sourcePath);
const targetPath = path.join(options.targetRoot, relativePath);
const exists = fs.existsSync(targetPath);
if (exists && !options.force) {
results.push({ action: "skipped", filePath: targetPath });
return;
}
const action = exists ? "updated" : "created";
if (options.mode === "apply") {
ensureDir(path.dirname(targetPath), options.mode);
const rawContent = fs.readFileSync(sourcePath);
const content = isTextFile(sourcePath)
? replacePlaceholders(rawContent.toString("utf8"), options)
: rawContent;
fs.writeFileSync(targetPath, content);
}
results.push({ action, filePath: targetPath });
});
return results;
}
function main() {
const options = parseArgs(process.argv.slice(2));
console.log(
`Target bundle: ${path.relative(options.projectRoot, options.targetRoot)}`,
);
console.log(`Mode: ${options.mode}`);
const results = scaffold(options);
for (const result of results) {
console.log(
`${result.action.toUpperCase()}: ${path.relative(options.projectRoot, result.filePath)}`,
);
}
if (options.mode === "dry-run") {
console.log("Dry-run only. Re-run with --mode apply to write files.");
}
}
main();

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
node "$script_dir/scaffold-discord-oauth-vue3-vite.mjs" "$@"

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

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

View File

@@ -0,0 +1,486 @@
#!/usr/bin/env node
// @ts-check
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const defaultStateDir =
process.env.COPILOT_RESOURCES_STATE_DIR ||
path.join(os.homedir(), ".copilot-resources-state");
const machineRegistryName = "project-ports-registry.json";
const localSnapshotRelativePath = path.join(".local", "project-ports.json");
/**
* @typedef {{service: string, protocol: string, port: number, notes: string}} PortEntry
* @typedef {{version: number, projectName: string, projectPath: string, updatedAt: string, lastSeenAt: string, ports: PortEntry[]}} LocalSnapshot
* @typedef {{projectPath: string, projectName: string, localSnapshotPath: string, firstSeenAt: string, lastSeenAt: string, ports: PortEntry[]}} ProjectRecord
* @typedef {{version: number, updatedAt: string, projects: Record<string, ProjectRecord>, ports: Record<string, Array<{projectPath: string, projectName: string, service: string, protocol: string, firstSeenAt: string | null, lastSeenAt: string | null}>>, conflicts: Record<string, {incumbentProjectPath: string, incumbentProjectName: string, recommendedProjectToChangePath: string, recommendedProjectToChangeName: string, entries: Array<{projectPath: string, projectName: string, service: string, protocol: string, firstSeenAt: string | null, lastSeenAt: string | null}>}>}} MachineRegistry
*/
function printUsage() {
console.log(
"Usage: node resources/scripts/update-port-registry.mjs [--report] [--state-dir <dir>] [--project-path <path>]",
);
}
function nowIso() {
return new Date().toISOString();
}
/** @param {string} dirPath */
function ensureDirectory(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
/**
* @template T
* @param {string} filePath
* @param {T} fallbackValue
* @returns {{value: T, parseError: Error | null}}
*/
function safeReadJson(filePath, fallbackValue) {
if (!fs.existsSync(filePath)) {
return { value: fallbackValue, parseError: null };
}
try {
return {
value: JSON.parse(fs.readFileSync(filePath, "utf8")),
parseError: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
value: fallbackValue,
parseError: new Error(
`Failed to parse JSON file at ${filePath}: ${message}`,
),
};
}
}
/** @param {string} filePath @param {unknown} value */
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
/** @param {string} value */
function normalizePath(value) {
return path.resolve(value);
}
/** @param {unknown} value */
function normalizePortNumber(value) {
const parsed =
typeof value === "number"
? value
: typeof value === "string" && value.trim()
? Number.parseInt(value.trim(), 10)
: NaN;
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
return null;
}
return parsed;
}
/** @param {unknown} rawPorts @returns {PortEntry[]} */
function normalizePorts(rawPorts) {
if (!Array.isArray(rawPorts)) {
return [];
}
const dedupe = new Set();
const ports = [];
for (const rawEntry of rawPorts) {
if (!rawEntry || typeof rawEntry !== "object") {
continue;
}
const port = normalizePortNumber(rawEntry.port);
if (port === null) {
continue;
}
const service =
typeof rawEntry.service === "string" && rawEntry.service.trim()
? rawEntry.service.trim()
: "default";
const protocol =
typeof rawEntry.protocol === "string" && rawEntry.protocol.trim()
? rawEntry.protocol.trim().toLowerCase()
: "tcp";
const dedupeKey = `${service}::${protocol}::${port}`;
if (dedupe.has(dedupeKey)) {
continue;
}
dedupe.add(dedupeKey);
ports.push({
service,
protocol,
port,
notes: typeof rawEntry.notes === "string" ? rawEntry.notes : "",
});
}
ports.sort(
(left, right) =>
left.port - right.port || left.service.localeCompare(right.service),
);
return ports;
}
/** @param {any} eventPayload @param {string | null} projectPathOverride */
function detectProjectContext(eventPayload, projectPathOverride) {
const candidateTimestamp =
typeof eventPayload?.timestamp === "string" && eventPayload.timestamp.trim()
? eventPayload.timestamp
: nowIso();
const parsedTimestamp = Number.isNaN(Date.parse(candidateTimestamp))
? nowIso()
: new Date(candidateTimestamp).toISOString();
if (projectPathOverride) {
const projectPath = normalizePath(projectPathOverride);
return {
projectPath,
projectName: path.basename(projectPath),
timestamp: parsedTimestamp,
payloadCwd: projectPath,
};
}
const cwdFromPayload =
typeof eventPayload?.cwd === "string" && eventPayload.cwd.trim()
? eventPayload.cwd
: null;
const projectPath = normalizePath(cwdFromPayload || process.cwd());
return {
projectPath,
projectName: path.basename(projectPath),
timestamp: parsedTimestamp,
payloadCwd: cwdFromPayload,
};
}
/** @param {{projectName: string, projectPath: string, timestamp: string}} context @returns {LocalSnapshot} */
function defaultLocalSnapshot({ projectName, projectPath, timestamp }) {
return {
version: 1,
projectName,
projectPath,
updatedAt: timestamp,
lastSeenAt: timestamp,
ports: [],
};
}
/** @param {{projectPath: string, projectName: string, timestamp: string}} projectContext */
function loadAndSyncLocalSnapshot(projectContext) {
const localSnapshotPath = path.join(
projectContext.projectPath,
localSnapshotRelativePath,
);
ensureDirectory(path.dirname(localSnapshotPath));
const { value: localSnapshot, parseError } = safeReadJson(
localSnapshotPath,
defaultLocalSnapshot(projectContext),
);
if (parseError) {
throw parseError;
}
const syncedSnapshot = {
version: 1,
projectName: projectContext.projectName,
projectPath: projectContext.projectPath,
updatedAt: projectContext.timestamp,
lastSeenAt: projectContext.timestamp,
ports: normalizePorts(localSnapshot.ports),
};
writeJson(localSnapshotPath, syncedSnapshot);
return {
localSnapshotPath,
localSnapshot: syncedSnapshot,
};
}
/** @param {string} timestamp @returns {MachineRegistry} */
function defaultRegistry(timestamp) {
return {
version: 1,
updatedAt: timestamp,
projects: {},
ports: {},
conflicts: {},
};
}
/** @param {Record<string, ProjectRecord>} projects */
function buildPortIndexes(projects) {
/** @type {MachineRegistry["ports"]} */
const ports = {};
for (const project of Object.values(projects)) {
if (!project || typeof project !== "object") {
continue;
}
for (const entry of normalizePorts(project.ports)) {
const portKey = String(entry.port);
if (!ports[portKey]) {
ports[portKey] = [];
}
ports[portKey].push({
projectPath: project.projectPath,
projectName: project.projectName,
service: entry.service,
protocol: entry.protocol,
firstSeenAt:
typeof project.firstSeenAt === "string" ? project.firstSeenAt : null,
lastSeenAt:
typeof project.lastSeenAt === "string" ? project.lastSeenAt : null,
});
}
}
for (const entries of Object.values(ports)) {
entries.sort((left, right) => {
const leftSeen = left.firstSeenAt
? Date.parse(left.firstSeenAt)
: Number.POSITIVE_INFINITY;
const rightSeen = right.firstSeenAt
? Date.parse(right.firstSeenAt)
: Number.POSITIVE_INFINITY;
if (leftSeen !== rightSeen) {
return leftSeen - rightSeen;
}
return left.projectPath.localeCompare(right.projectPath);
});
}
/** @type {MachineRegistry["conflicts"]} */
const conflicts = {};
for (const [port, entries] of Object.entries(ports)) {
if (entries.length < 2) {
continue;
}
const incumbent = entries[0];
const recommendedChange = entries[entries.length - 1];
conflicts[port] = {
incumbentProjectPath: incumbent.projectPath,
incumbentProjectName: incumbent.projectName,
recommendedProjectToChangePath: recommendedChange.projectPath,
recommendedProjectToChangeName: recommendedChange.projectName,
entries,
};
}
return { ports, conflicts };
}
/**
* @param {{
* stateDir: string,
* projectContext: {projectPath: string, projectName: string, timestamp: string},
* localSnapshotPath: string,
* localSnapshot: LocalSnapshot
* }} params
*/
function updateRegistry({
stateDir,
projectContext,
localSnapshotPath,
localSnapshot,
}) {
ensureDirectory(stateDir);
const registryPath = path.join(stateDir, machineRegistryName);
const { value: registry, parseError } = safeReadJson(
registryPath,
defaultRegistry(projectContext.timestamp),
);
if (parseError) {
throw parseError;
}
const projects =
registry.projects && typeof registry.projects === "object"
? registry.projects
: {};
const existing =
projects[projectContext.projectPath] &&
typeof projects[projectContext.projectPath] === "object"
? projects[projectContext.projectPath]
: null;
projects[projectContext.projectPath] = {
projectPath: projectContext.projectPath,
projectName: projectContext.projectName,
localSnapshotPath,
firstSeenAt:
existing && typeof existing.firstSeenAt === "string"
? existing.firstSeenAt
: projectContext.timestamp,
lastSeenAt: projectContext.timestamp,
ports: normalizePorts(localSnapshot.ports),
};
const { ports, conflicts } = buildPortIndexes(projects);
const updatedRegistry = {
version: 1,
updatedAt: projectContext.timestamp,
projects,
ports,
conflicts,
};
writeJson(registryPath, updatedRegistry);
return { registryPath, registry: updatedRegistry };
}
/** @param {string} stateDir @param {string} errorMessage */
function appendError(stateDir, errorMessage) {
ensureDirectory(stateDir);
const errorLine = `${nowIso()} ${errorMessage}`;
fs.appendFileSync(
path.join(stateDir, "project-ports-errors.log"),
`${errorLine}\n`,
"utf8",
);
}
function readStdin() {
return fs.readFileSync(0, "utf8");
}
/**
* @param {string[]} argv
* @returns {{stateDir: string, report: boolean, projectPath: string | null, help: boolean}}
*/
function parseArgs(argv) {
/** @type {{stateDir: string, report: boolean, projectPath: string | null, help: boolean}} */
const options = {
stateDir: defaultStateDir,
report: false,
projectPath: null,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
continue;
}
if (arg === "--report") {
options.report = true;
continue;
}
if (arg === "--state-dir") {
options.stateDir = normalizePath(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--project-path") {
options.projectPath = argv[index + 1];
index += 1;
continue;
}
}
return options;
}
/** @param {string} stateDir */
function runReport(stateDir) {
const registryPath = path.join(stateDir, machineRegistryName);
const { value: registry, parseError } = safeReadJson(
registryPath,
defaultRegistry(nowIso()),
);
if (parseError) {
throw parseError;
}
const conflicts =
registry.conflicts && typeof registry.conflicts === "object"
? registry.conflicts
: {};
const summary = {
registryPath,
updatedAt: registry.updatedAt || null,
projectCount:
registry.projects && typeof registry.projects === "object"
? Object.keys(registry.projects).length
: 0,
conflictCount: Object.keys(conflicts).length,
conflicts,
};
console.log(JSON.stringify(summary, null, 2));
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printUsage();
return;
}
if (options.report) {
runReport(options.stateDir);
return;
}
let eventPayload = {};
const stdinContent = readStdin();
if (stdinContent.trim()) {
try {
eventPayload = JSON.parse(stdinContent);
} catch {
eventPayload = {};
}
}
const projectContext = detectProjectContext(
eventPayload,
options.projectPath,
);
const { localSnapshotPath, localSnapshot } =
loadAndSyncLocalSnapshot(projectContext);
updateRegistry({
stateDir: options.stateDir,
projectContext,
localSnapshotPath,
localSnapshot,
});
}
try {
main();
} catch (error) {
appendError(
defaultStateDir,
error instanceof Error ? error.message : String(error),
);
process.exitCode = 1;
}

View File

@@ -0,0 +1,167 @@
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 scriptPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"update-port-registry.mjs",
);
function runSync({ stateDir, projectPath, payload }) {
return spawnSync(
process.execPath,
[scriptPath, "--state-dir", stateDir, "--project-path", projectPath],
{
input: JSON.stringify(payload),
encoding: "utf8",
},
);
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
test("creates local snapshot and machine registry", () => {
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), "copilot-port-registry-"),
);
const stateDir = path.join(tempDir, "state");
const projectPath = path.join(tempDir, "workspace-a");
fs.mkdirSync(projectPath, { recursive: true });
const result = runSync({
stateDir,
projectPath,
payload: { timestamp: "2026-05-19T12:00:00.000Z" },
});
assert.equal(result.status, 0, result.stderr);
const localSnapshotPath = path.join(
projectPath,
".local",
"project-ports.json",
);
assert.equal(fs.existsSync(localSnapshotPath), true);
const localSnapshot = readJson(localSnapshotPath);
assert.equal(Array.isArray(localSnapshot.ports), true);
const registryPath = path.join(stateDir, "project-ports-registry.json");
const registry = readJson(registryPath);
assert.equal(Object.keys(registry.projects).length, 1);
assert.equal(Object.keys(registry.conflicts).length, 0);
});
test("reports conflict and recommends changing newest project", () => {
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), "copilot-port-registry-"),
);
const stateDir = path.join(tempDir, "state");
const projectA = path.join(tempDir, "workspace-a");
const projectB = path.join(tempDir, "workspace-b");
fs.mkdirSync(path.join(projectA, ".local"), { recursive: true });
fs.mkdirSync(path.join(projectB, ".local"), { recursive: true });
fs.writeFileSync(
path.join(projectA, ".local", "project-ports.json"),
JSON.stringify(
{
version: 1,
projectName: "workspace-a",
projectPath: projectA,
ports: [{ service: "web", port: 3000 }],
},
null,
2,
),
);
fs.writeFileSync(
path.join(projectB, ".local", "project-ports.json"),
JSON.stringify(
{
version: 1,
projectName: "workspace-b",
projectPath: projectB,
ports: [{ service: "web", port: 3000 }],
},
null,
2,
),
);
let result = runSync({
stateDir,
projectPath: projectA,
payload: { timestamp: "2026-05-19T12:00:00.000Z" },
});
assert.equal(result.status, 0, result.stderr);
result = runSync({
stateDir,
projectPath: projectB,
payload: { timestamp: "2026-05-19T13:00:00.000Z" },
});
assert.equal(result.status, 0, result.stderr);
const report = spawnSync(
process.execPath,
[scriptPath, "--state-dir", stateDir, "--report"],
{ encoding: "utf8" },
);
assert.equal(report.status, 0, report.stderr);
const parsedReport = JSON.parse(report.stdout);
assert.equal(parsedReport.conflictCount, 1);
assert.equal(
parsedReport.conflicts["3000"].recommendedProjectToChangeName,
"workspace-b",
);
});
test("keeps firstSeenAt stable across re-sync", () => {
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), "copilot-port-registry-"),
);
const stateDir = path.join(tempDir, "state");
const projectPath = path.join(tempDir, "workspace-a");
fs.mkdirSync(path.join(projectPath, ".local"), { recursive: true });
fs.writeFileSync(
path.join(projectPath, ".local", "project-ports.json"),
JSON.stringify(
{
version: 1,
projectName: "workspace-a",
projectPath,
ports: [{ service: "api", port: 4100 }],
},
null,
2,
),
);
let result = runSync({
stateDir,
projectPath,
payload: { timestamp: "2026-05-19T10:00:00.000Z" },
});
assert.equal(result.status, 0, result.stderr);
result = runSync({
stateDir,
projectPath,
payload: { timestamp: "2026-05-19T14:00:00.000Z" },
});
assert.equal(result.status, 0, result.stderr);
const registry = readJson(path.join(stateDir, "project-ports-registry.json"));
const project = registry.projects[projectPath];
assert.equal(project.firstSeenAt, "2026-05-19T10:00:00.000Z");
assert.equal(project.lastSeenAt, "2026-05-19T14:00:00.000Z");
});

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

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

View File

@@ -0,0 +1,69 @@
---
name: discord-oauth-vue3-vite
description: "Use when scaffolding Discord OAuth into a Vue 3 + Vite app with a server bundle under src/server, PKCE, session handling, and route protection."
argument-hint: "project-root=<path> mode=<dry-run|apply> frontend-origin=<url> allowlist-discord-ids=<csv>"
---
# Discord OAuth Vue 3 + Vite
Use this skill when you want a portable, repeatable Discord OAuth setup for a Vue 3 + Vite app and you want the server-side auth bundle kept under `src/server/` instead of a standalone `scripts/` service.
## Procedure
### Server bundle
1. Confirm the target project root and review the generated server bundle path, which defaults to `src/server/discord-oauth`.
2. Confirm the user-owned prerequisites: a Discord application, the authorized redirect URI, client ID, client secret, and any Discord user IDs that should be allowlisted.
3. Run `resources/scripts/scaffold-discord-oauth-vue3-vite.sh` in `--mode dry-run` first when the shared resources repo is available; if the script path is not available in the current workspace, continue with the manual scaffold path instead of stopping.
4. Let the scaffold create the auth bundle under `src/server/` with PKCE state handling, Discord token exchange, profile fetch, allowlist checks, refresh-session rotation, and logout cleanup.
5. Copy the generated `clients.example.json` to `clients.json` and fill in the Discord client credentials and frontend origin values.
6. Set `AUTH_PORT`, `JWT_SECRET`, and the other auth env vars locally. Align the Discord redirect URI with the app callback route (`<frontendOrigin>/oauth/callback?provider=discord`).
### Client-side wiring
7. Create `useAuth.js` as a module-level singleton composable under `src/client/src/composables/` using the reference template at `resources/templates/discord-oauth-vue3-vite/src/client/composables/useAuth.js`. The singleton exposes `user`, `features`, `isLoading`, `isLoggedIn`, `checkSession()`, `login()`, `logout()`, and `setSession()`.
8. Create `DiscordAuthWidget` as an organism under `src/client/src/components/organisms/` with a colocated SCSS file and a stories stub, using the reference templates at `resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/`. Update the SCSS `@use` paths to match the target project's style foundation before placing. Both the `.vue` and `.scss` must exist; the stories stub must export `LoggedOut` and `LoggedIn`.
9. Create `OAuthCallbackPage.vue` under `src/client/src/pages/` using the reference template at `resources/templates/discord-oauth-vue3-vite/src/client/pages/OAuthCallbackPage.vue`. Register it on the `/oauth/callback` route with no `meta.requiresAuth` guard.
10. Add a `beforeEach` router guard: call `checkSession()` before any route with `meta.requiresAuth: true` and redirect to `/` if `isLoggedIn` is false. Mark protected routes with `meta: { requiresAuth: true }`.
11. Add a Vite dev proxy entry for `/api/auth` pointing to `http://localhost:<AUTH_PORT>` in `vite.config.js`.
### Runtime and validation
12. Either run the auth server as its own Node process (add an `auth:dev` script: `node src/server/discord-oauth/server.js`) or mount it into an existing backend entrypoint.
13. Validate the flow: start login, complete the callback, check the session endpoint, refresh the session, and log out. Confirm the allowlist rejects an unapproved Discord account.
## Outputs
**Server bundle** (scaffold script writes these):
- `src/server/discord-oauth/server.js`
- `src/server/discord-oauth/config.js`
- `src/server/discord-oauth/sessionStore.js`
- `src/server/discord-oauth/allowlist.js`
- `src/server/discord-oauth/providers/discord.js`
- `src/server/discord-oauth/lib/oauth/pkce.js`
- `src/server/discord-oauth/lib/oauth/providers.js`
- `src/server/discord-oauth/clients.example.json`
- `src/server/discord-oauth/README.md`
**Client files** (placed from reference templates; adapt paths per project):
- `src/client/src/composables/useAuth.js`
- `src/client/src/components/organisms/DiscordAuthWidget.vue`
- `src/client/src/components/organisms/DiscordAuthWidget.scss`
- `src/client/src/components/organisms/DiscordAuthWidget.stories.js`
- `src/client/src/pages/OAuthCallbackPage.vue`
## Do Not Use
- Do not use this workflow when the project does not have a Node-side runtime path for auth code under `src/server/`.
- Do not use this workflow when the project uses a hosted auth provider or a managed backend where Discord auth should not live in the repo.
- Do not store secrets in the generated files.
## Notes
- This workflow follows the proven pattern: origin-specific client config, PKCE, Discord code exchange, allowlist gating, refresh-token rotation, and cookie cleanup.
- Client template files live in `resources/templates/discord-oauth-vue3-vite/src/client/` and are reference-only. Adapt component paths and SCSS `@use` imports to match the target project's conventions before placing them.
- Projects using atomic design require colocated SCSS and a stories stub for every organism; the `DiscordAuthWidget` templates satisfy this by default.
- Keep the generated bundle generic to Vue 3 + Vite so the same path can be reused in other apps.
- If the project does not already have a server entrypoint, add an `auth:dev` script that runs `node src/server/discord-oauth/server.js`, then configure Vite to proxy `/api/auth` to that port.

View File

@@ -0,0 +1,22 @@
# Environment Variables
Set these in the target app or in the app's local env files.
- `AUTH_PORT` - auth service port, default `8787`
- `NODE_ENV` - enables stricter secret checks outside development
- `JWT_SECRET` - signing secret for access tokens
- `ACCESS_TOKEN_TTL` - access token lifetime, default `15m`
- `REFRESH_TOKEN_TTL_DAYS` - refresh session lifetime, default `30`
- `COOKIE_NAME` - refresh token cookie name, default `gopvp_refresh`
- `COOKIE_SECURE` - set to `true` for HTTPS deployments
- `COOKIE_SAME_SITE` - cookie same-site mode, default `lax`
- `AUTH_CLIENTS_FILE` - optional path to a JSON client map
- `AUTH_CLIENTS_JSON` - optional inline JSON client map
- `FRONTEND_ORIGIN` - legacy single-origin fallback
- `DISCORD_CLIENT_ID` - legacy single-origin fallback
- `DISCORD_CLIENT_SECRET` - legacy single-origin fallback
- `DISCORD_SCOPES` - legacy single-origin fallback, default `identify,email`
- `ALLOWLIST_DISCORD_IDS` - legacy single-origin fallback, comma-separated Discord user IDs
- `DEFAULT_FEATURE_KEYS` - feature flags granted to allowlisted users
Keep secrets out of committed files and copy them into local environment files instead.

View File

@@ -0,0 +1,14 @@
# Implementation Checklist
1. Create the Discord application and add the callback URL used by the app.
2. Generate the `src/server/discord-oauth/` bundle from the scaffold.
3. Copy `clients.example.json` to `clients.json` and fill in credentials.
4. Set `AUTH_PORT`, `JWT_SECRET`, and the other auth env vars locally.
5. Wire the auth server: add an `auth:dev` script or mount it into an existing backend entrypoint.
6. Create `useAuth.js` singleton composable from the reference template; adapt the import path.
7. Create `DiscordAuthWidget` organism (`.vue`, `.scss`, `.stories.js`) from reference templates; update SCSS `@use` paths to match the target project's style foundation.
8. Create `OAuthCallbackPage.vue` from the reference template; register it at `/oauth/callback` with no auth guard.
9. Add `meta: { requiresAuth: true }` to protected routes and a `beforeEach` guard that calls `checkSession()` and redirects to `/` if the user is not logged in.
10. Add Vite dev proxy: `/api/auth``http://localhost:<AUTH_PORT>`.
11. Test login, callback, session persistence, session refresh, and logout.
12. Confirm the allowlist rejects a Discord account that is not approved.

View File

@@ -5,3 +5,9 @@
- Confirm frontmatter is valid and descriptive. - Confirm frontmatter is valid and descriptive.
- Avoid duplicating a workflow that already exists. - Avoid duplicating a workflow that already exists.
- If scripts are referenced, validate them before merging. - 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.

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

View File

@@ -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`)

View File

@@ -0,0 +1,172 @@
// organisms/DiscordAuthWidget.scss
//
// NOTE: Update the three @use paths below to match the target project's style
// foundation before placing this file. Remove any imports that are unused.
//
// @use '<path-to>/global-color' as color;
// @use '<path-to>/global-variables' as vars;
// @use '<path-to>/global-mixins' as mix;
//
// Replace vars.$radius-full, vars.$font-body, vars.$radius-md, vars.$radius-sm,
// vars.$z-topbar, and vars.$shadow-card with the target project's equivalents,
// or convert them to CSS custom properties.
$discord-blurple: #5865f2;
$discord-blurple-hover: #4752c4;
$avatar-size: 2rem;
.discord-auth-widget {
position: relative;
display: inline-flex;
align-items: center;
}
// ── Login button ───────────────────────────────────────────────────────────
.discord-auth-widget__login-btn {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.9rem;
border: none;
border-radius: 9999px;
background: $discord-blurple;
color: #fff;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
&:hover {
background: $discord-blurple-hover;
}
&:active {
transform: scale(0.97);
}
}
.discord-auth-widget__discord-logo {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
}
// ── Profile trigger ────────────────────────────────────────────────────────
.discord-auth-widget__profile {
position: relative;
}
.discord-auth-widget__profile-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0.55rem 0.3rem 0.3rem;
border: 1px solid var(--line-soft);
border-radius: 9999px;
background: var(--bg-surface);
color: var(--text-main);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
&:hover,
&.is-open {
background: var(--bg-filter);
border-color: var(--line-strong);
}
}
.discord-auth-widget__avatar {
width: $avatar-size;
height: $avatar-size;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
&--initials {
display: inline-flex;
align-items: center;
justify-content: center;
background: $discord-blurple;
color: #fff;
font-size: 0.85rem;
font-weight: 700;
}
}
.discord-auth-widget__display-name {
max-width: 9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.discord-auth-widget__chevron {
width: 0.65rem;
height: 0.65rem;
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.18s;
.is-open & {
transform: rotate(180deg);
}
}
// ── Dropdown menu ──────────────────────────────────────────────────────────
.discord-auth-widget__menu {
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
min-width: 9rem;
padding: 0.3rem;
border: 1px solid var(--line-strong);
border-radius: 0.5rem;
background: var(--bg-surface-strong);
box-shadow: var(--shadow-card);
z-index: 100;
}
.discord-auth-widget__menu-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 0.25rem;
background: transparent;
color: var(--text-main);
font-size: 0.85rem;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background 0.12s, color 0.12s;
&:hover {
background: var(--bg-filter);
}
&--danger {
color: var(--red);
&:hover {
background: rgba(243, 67, 83, 0.1);
}
}
}
// ── Menu transition ────────────────────────────────────────────────────────
.discord-menu-enter-active,
.discord-menu-leave-active {
transition: opacity 0.15s, transform 0.15s;
}
.discord-menu-enter-from,
.discord-menu-leave-to {
opacity: 0;
transform: translateY(-0.3rem);
}

View File

@@ -0,0 +1,9 @@
import DiscordAuthWidget from './DiscordAuthWidget.vue';
export default {
title: 'Organisms/DiscordAuthWidget',
component: DiscordAuthWidget,
};
export const LoggedOut = {};
export const LoggedIn = {};

View File

@@ -0,0 +1,120 @@
<script setup>
import { onUnmounted, ref, watch } from "vue";
import { useAuth } from "../../composables/useAuth.js";
const { isLoading, isLoggedIn, login, logout, user } = useAuth();
const menuOpen = ref(false);
const wrapperRef = ref(null);
function toggleMenu() {
menuOpen.value = !menuOpen.value;
}
function handleLogout() {
menuOpen.value = false;
logout();
}
function handleDocumentClick(event) {
if (wrapperRef.value && !wrapperRef.value.contains(event.target)) {
menuOpen.value = false;
}
}
watch(menuOpen, (open) => {
if (open) {
document.addEventListener("click", handleDocumentClick);
} else {
document.removeEventListener("click", handleDocumentClick);
}
});
onUnmounted(() => {
document.removeEventListener("click", handleDocumentClick);
});
</script>
<template>
<div
v-if="!isLoading"
ref="wrapperRef"
class="discord-auth-widget"
>
<!-- Logged out -->
<button
v-if="!isLoggedIn"
class="discord-auth-widget__login-btn"
type="button"
@click="login()"
>
<svg
class="discord-auth-widget__discord-logo"
viewBox="0 0 24 24"
aria-hidden="true"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Sign in with Discord
</button>
<!-- Logged in -->
<div v-else class="discord-auth-widget__profile">
<button
class="discord-auth-widget__profile-btn"
:class="{ 'is-open': menuOpen }"
type="button"
:aria-expanded="menuOpen"
aria-haspopup="menu"
@click="toggleMenu"
>
<img
v-if="user?.avatarUrl"
:src="user.avatarUrl"
:alt="user.displayName"
class="discord-auth-widget__avatar"
referrerpolicy="no-referrer"
/>
<span
v-else
class="discord-auth-widget__avatar discord-auth-widget__avatar--initials"
aria-hidden="true"
>{{ (user?.displayName ?? '?')[0].toUpperCase() }}</span>
<span class="discord-auth-widget__display-name">{{ user?.displayName }}</span>
<svg
class="discord-auth-widget__chevron"
viewBox="0 0 10 6"
aria-hidden="true"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path d="M1 1l4 4 4-4" />
</svg>
</button>
<Transition name="discord-menu">
<div
v-if="menuOpen"
class="discord-auth-widget__menu"
role="menu"
>
<button
class="discord-auth-widget__menu-item discord-auth-widget__menu-item--danger"
type="button"
role="menuitem"
@click="handleLogout"
>
Log out
</button>
</div>
</Transition>
</div>
</div>
</template>
<style lang="scss">
@use './DiscordAuthWidget.scss';
</style>

View File

@@ -0,0 +1,60 @@
import { ref, computed } from "vue";
// Module-level singleton — shared across all composable call sites.
const user = ref(null);
const features = ref([]);
const isLoading = ref(false);
let sessionChecked = false;
const isLoggedIn = computed(() => !!user.value);
async function checkSession() {
if (sessionChecked) return;
isLoading.value = true;
try {
const res = await fetch("/api/auth/session", { credentials: "include" });
if (res.ok) {
const data = await res.json();
user.value = data.user ?? null;
features.value = data.features ?? [];
}
} catch {
// No session or auth server not reachable — stay logged out.
} finally {
isLoading.value = false;
sessionChecked = true;
}
}
function login(returnTo = window.location.pathname) {
window.location.href = `/api/auth/discord/start?returnTo=${encodeURIComponent(returnTo)}`;
}
async function logout() {
try {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
} finally {
user.value = null;
features.value = [];
sessionChecked = false;
}
}
function setSession(data) {
user.value = data.user ?? null;
features.value = data.features ?? [];
sessionChecked = true;
}
export function useAuth() {
return {
user,
features,
isLoading,
isLoggedIn,
checkSession,
login,
logout,
setSession,
};
}

View File

@@ -0,0 +1,68 @@
<script setup>
import { onMounted, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuth } from "../composables/useAuth.js";
const router = useRouter();
const route = useRoute();
const { setSession } = useAuth();
const error = ref(null);
onMounted(async () => {
const provider = `${route.query.provider || ""}`.toLowerCase();
const code = `${route.query.code || ""}`;
const state = `${route.query.state || ""}`;
if (!provider || !code || !state) {
error.value = "Missing OAuth callback parameters.";
return;
}
try {
const res = await fetch("/api/auth/callback", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, code, state }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
error.value = data.error || `Auth failed (${res.status}).`;
return;
}
const data = await res.json();
setSession(data);
const returnTo = `${data.returnTo || "/"}`;
router.replace(returnTo.startsWith("/") ? returnTo : "/");
} catch {
error.value = "Could not reach the auth server. Try again.";
}
});
</script>
<template>
<div class="oauth-callback-page">
<p v-if="error" class="oauth-callback-error">{{ error }}</p>
<p v-else class="oauth-callback-loading">Completing sign-in</p>
</div>
</template>
<style scoped>
.oauth-callback-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
font-family: var(--font-body, sans-serif);
color: var(--color-text-muted, #666);
}
.oauth-callback-error {
color: var(--color-danger, #c0392b);
}
</style>

View File

@@ -0,0 +1,9 @@
AUTH_PORT=8787
NODE_ENV=development
JWT_SECRET=replace-me
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL_DAYS=30
COOKIE_NAME=gopvp_refresh
COOKIE_SECURE=false
COOKIE_SAME_SITE=lax
AUTH_CLIENTS_FILE=src/server/discord-oauth/clients.json

View File

@@ -0,0 +1,39 @@
# discord-oauth
This bundle lives under `src/server/discord-oauth` and provides a Discord OAuth auth service with PKCE, session rotation, and allowlist support.
## Quickstart
1. Copy `clients.example.json` to `clients.json`.
2. Fill in the Discord client ID, client secret, and allowlist entries.
3. Set `JWT_SECRET` and the other auth env vars.
4. Decide how to run the auth server next:
- If the app already has a Node backend, import or mount this bundle there.
- If the app is frontend-only, add an `auth:dev` script that runs `node src/server/discord-oauth/server.js` and proxy `/api/auth` to that port from Vite.
5. Start the app and test login, callback, session refresh, and logout.
## Wiring Options
### Option 1: Separate auth process
Add a package script such as:
```json
{
"scripts": {
"auth:dev": "node src/server/discord-oauth/server.js"
}
}
```
Then point your Vite proxy at the auth server port for `/api/auth` requests.
### Option 2: Existing Node backend
If the project already has an Express or Node entrypoint, import this bundle there and start it alongside the rest of the backend so the frontend can reach the same `/api/auth/*` routes.
## Notes
- The callback route should match the Discord redirect URI.
- Keep secrets out of committed files.
- Replace the placeholder frontend origin and allowlist values before running.

View File

@@ -0,0 +1,31 @@
function normalizeDiscordUser(profile) {
return {
id: profile.id,
email: profile.email,
username: profile.username,
displayName: profile.global_name || profile.username,
avatarUrl: profile.avatar
? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`
: "",
provider: "discord",
};
}
export function resolveEntitlements(profile, authClient) {
const user = normalizeDiscordUser(profile);
const allowed = authClient.allowlistDiscordIds.has(`${user.id || ""}`);
if (!allowed) {
return {
allowed: false,
user,
features: [],
};
}
return {
allowed: true,
user,
features: authClient.defaultFeatureKeys,
};
}

View File

@@ -0,0 +1,11 @@
[
{
"key": "discord-oauth-local",
"frontendOrigin": "__FRONTEND_ORIGIN__",
"discordClientId": "",
"discordClientSecret": "",
"discordScopes": __SCOPES_JSON__,
"allowlistDiscordIds": __ALLOWLIST_DISCORD_IDS_JSON__,
"defaultFeatureKeys": ["feature.auth"]
}
]

View File

@@ -0,0 +1,161 @@
import "dotenv/config";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
function parseCsv(value) {
return `${value || ""}`
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function normalizeOrigin(value) {
if (!value) {
return "";
}
try {
return new URL(value).origin;
} catch {
return "";
}
}
function defaultFeatureKeys() {
return parseCsv(
process.env.DEFAULT_FEATURE_KEYS ||
"feature.team-list,feature.bills-pc,feature.team-coach",
);
}
function normalizeClient(rawClient) {
const frontendOrigin = normalizeOrigin(rawClient.frontendOrigin);
return {
key: `${rawClient.key || ""}`,
frontendOrigin,
discordClientId: `${rawClient.discordClientId || ""}`,
discordClientSecret: `${rawClient.discordClientSecret || ""}`,
discordScopes: Array.isArray(rawClient.discordScopes)
? rawClient.discordScopes
: parseCsv(rawClient.discordScopes || "identify,email"),
allowlistDiscordIds: new Set(
Array.isArray(rawClient.allowlistDiscordIds)
? rawClient.allowlistDiscordIds.map((entry) => `${entry}`)
: parseCsv(rawClient.allowlistDiscordIds || ""),
),
defaultFeatureKeys: Array.isArray(rawClient.defaultFeatureKeys)
? rawClient.defaultFeatureKeys
: parseCsv(rawClient.defaultFeatureKeys || defaultFeatureKeys().join(",")),
};
}
function validateClient(client) {
if (!client.key) {
throw new Error("Auth client is missing key.");
}
if (!client.frontendOrigin) {
throw new Error(`Auth client ${client.key} is missing frontendOrigin.`);
}
}
function parseClientsFromJson(jsonText) {
if (!jsonText) {
return [];
}
const parsed = JSON.parse(jsonText);
if (!Array.isArray(parsed)) {
throw new Error("Auth clients config must be an array.");
}
return parsed.map((entry) => normalizeClient(entry));
}
function resolveClientsFilePath(configPath) {
if (!configPath) {
return "";
}
if (path.isAbsolute(configPath)) {
return configPath;
}
const currentDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(currentDir, "..", "..", configPath);
}
function loadClientsFromFile(configPath) {
const resolvedPath = resolveClientsFilePath(configPath.trim());
const fileText = fs.readFileSync(resolvedPath, "utf8");
const clients = parseClientsFromJson(fileText);
clients.forEach(validateClient);
return clients;
}
function loadAuthClients() {
const configPath = process.env.AUTH_CLIENTS_FILE || "";
const jsonText = process.env.AUTH_CLIENTS_JSON || "";
if (jsonText.trim()) {
const clients = parseClientsFromJson(jsonText);
clients.forEach(validateClient);
return clients;
}
if (configPath.trim()) {
return loadClientsFromFile(configPath);
}
const localDefaultPath = "src/server/discord-oauth/clients.json";
const resolvedDefaultPath = resolveClientsFilePath(localDefaultPath);
if (fs.existsSync(resolvedDefaultPath)) {
return loadClientsFromFile(localDefaultPath);
}
const legacyClient = normalizeClient({
key: "legacy-local",
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
discordClientId: process.env.DISCORD_CLIENT_ID || "",
discordClientSecret: process.env.DISCORD_CLIENT_SECRET || "",
discordScopes: parseCsv(process.env.DISCORD_SCOPES || "identify,email"),
allowlistDiscordIds: parseCsv(process.env.ALLOWLIST_DISCORD_IDS || ""),
defaultFeatureKeys: defaultFeatureKeys(),
});
validateClient(legacyClient);
return [legacyClient];
}
const authClients = loadAuthClients();
const authClientsByOrigin = new Map(
authClients.map((client) => [client.frontendOrigin, client]),
);
const authClientsByKey = new Map(authClients.map((client) => [client.key, client]));
const nodeEnv = process.env.NODE_ENV || "development";
const jwtSecret = process.env.JWT_SECRET || "";
if (nodeEnv !== "development" && !jwtSecret.trim()) {
throw new Error("JWT_SECRET is required when NODE_ENV is not development.");
}
export const config = {
port: Number(process.env.PORT || 8787),
jwtSecret: jwtSecret || "dev-only-secret-change-me",
accessTokenTtl: process.env.ACCESS_TOKEN_TTL || "15m",
refreshTokenTtlDays: Number(process.env.REFRESH_TOKEN_TTL_DAYS || 30),
cookieName: process.env.COOKIE_NAME || "gopvp_refresh",
cookieSecure: `${process.env.COOKIE_SECURE || "false"}` === "true",
cookieSameSite: process.env.COOKIE_SAME_SITE || "lax",
authClients,
};
export function getAuthClientByOrigin(origin) {
return authClientsByOrigin.get(normalizeOrigin(origin));
}
export function getAuthClientByKey(clientKey) {
return authClientsByKey.get(`${clientKey || ""}`);
}

View File

@@ -0,0 +1,46 @@
function getCrypto() {
if (globalThis.crypto?.subtle && globalThis.crypto?.getRandomValues) {
return globalThis.crypto;
}
throw new Error("Web Crypto API is required for PKCE utilities.");
}
function toBase64Url(bytes) {
let base64;
if (typeof globalThis.btoa === "function") {
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
"",
);
base64 = globalThis.btoa(binary);
} else {
base64 = Buffer.from(bytes).toString("base64");
}
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
export function createOAuthState(byteLength = 24) {
const cryptoObject = getCrypto();
const bytes = new Uint8Array(byteLength);
cryptoObject.getRandomValues(bytes);
return toBase64Url(bytes);
}
export function createCodeVerifier(byteLength = 64) {
const cryptoObject = getCrypto();
const bytes = new Uint8Array(byteLength);
cryptoObject.getRandomValues(bytes);
return toBase64Url(bytes);
}
export async function createCodeChallenge(codeVerifier) {
const cryptoObject = getCrypto();
const encoder = new TextEncoder();
const digest = await cryptoObject.subtle.digest(
"SHA-256",
encoder.encode(codeVerifier),
);
return toBase64Url(new Uint8Array(digest));
}

View File

@@ -0,0 +1,26 @@
function buildAuthUrl(baseUrl, params) {
const url = new URL(baseUrl);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && `${value}`.length > 0) {
url.searchParams.set(key, `${value}`);
}
});
return url.toString();
}
export function buildDiscordAuthorizationUrl(options) {
const scopes = Array.isArray(options.scopes)
? options.scopes.join(" ")
: options.scopes || "identify email";
return buildAuthUrl("https://discord.com/oauth2/authorize", {
client_id: options.clientId,
redirect_uri: options.redirectUri,
response_type: "code",
scope: scopes,
state: options.state,
code_challenge: options.codeChallenge,
code_challenge_method: options.codeChallenge ? "S256" : undefined,
prompt: options.prompt,
});
}

View File

@@ -0,0 +1,46 @@
export async function exchangeDiscordCode({
code,
codeVerifier,
clientId,
clientSecret,
redirectUri,
}) {
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
code_verifier: codeVerifier,
grant_type: "authorization_code",
redirect_uri: redirectUri,
});
const response = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Discord token exchange failed: ${text}`);
}
return response.json();
}
export async function fetchDiscordProfile(accessToken) {
const response = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Discord profile fetch failed: ${text}`);
}
return response.json();
}

View File

@@ -0,0 +1,304 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import jwt from "jsonwebtoken";
import {
buildDiscordAuthorizationUrl,
} from "./lib/oauth/providers.js";
import {
createCodeChallenge,
createCodeVerifier,
createOAuthState,
} from "./lib/oauth/pkce.js";
import { config, getAuthClientByKey, getAuthClientByOrigin } from "./config.js";
import { resolveEntitlements } from "./allowlist.js";
import { SessionStore } from "./sessionStore.js";
import {
exchangeDiscordCode,
fetchDiscordProfile,
} from "./providers/discord.js";
const app = express();
const store = new SessionStore({ refreshTokenTtlDays: config.refreshTokenTtlDays });
app.use(
cors({
origin(origin, callback) {
if (!origin) {
callback(null, true);
return;
}
const authClient = getAuthClientByOrigin(origin);
if (authClient) {
callback(null, true);
return;
}
callback(new Error("Origin is not allowlisted for auth."));
},
credentials: true,
}),
);
app.use(express.json());
app.use(cookieParser());
function getRequestOrigin(req) {
const originHeader = req.get("origin");
if (originHeader) {
return originHeader;
}
const refererHeader = req.get("referer");
if (refererHeader) {
try {
return new URL(refererHeader).origin;
} catch {
// Ignore malformed Referer and continue to host/proto fallback.
}
}
const proto = req.get("x-forwarded-proto") || req.protocol || "http";
const host = req.get("x-forwarded-host") || req.get("host") || "";
if (!host) {
return "";
}
return `${proto}://${host}`;
}
function buildUserPayload(record) {
return {
user: record.user,
features: record.features,
};
}
function getCookieOptions(expiresAt) {
return {
httpOnly: true,
secure: config.cookieSecure,
sameSite: config.cookieSameSite,
path: "/",
expires: new Date(expiresAt),
};
}
function signAccessToken(record) {
return jwt.sign(
{
sub: record.user.id,
provider: record.user.provider,
features: record.features,
email: record.user.email || "",
},
config.jwtSecret,
{
expiresIn: config.accessTokenTtl,
},
);
}
function ensureProvider(provider) {
return provider === "discord";
}
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.get("/api/auth/:provider/start", async (req, res) => {
const provider = `${req.params.provider || ""}`.toLowerCase();
if (!ensureProvider(provider)) {
res.status(400).json({ error: "Unsupported provider." });
return;
}
const authClient = getAuthClientByOrigin(getRequestOrigin(req));
if (!authClient) {
res.status(403).json({ error: "Unknown or unauthorized frontend origin." });
return;
}
if (!authClient.discordClientId || !authClient.discordClientSecret) {
res.status(503).json({
error:
"Discord OAuth credentials are not configured for this frontend origin.",
});
return;
}
const returnTo = `${req.query.returnTo || "/"}`;
const state = createOAuthState();
const codeVerifier = createCodeVerifier();
const codeChallenge = await createCodeChallenge(codeVerifier);
store.createOAuthState({
state,
provider,
returnTo,
codeVerifier,
clientKey: authClient.key,
});
const redirectUri = `${authClient.frontendOrigin}/oauth/callback?provider=${provider}`;
const authUrl = buildDiscordAuthorizationUrl({
clientId: authClient.discordClientId,
redirectUri,
state,
scopes: authClient.discordScopes,
codeChallenge,
prompt: "consent",
});
res.redirect(authUrl);
});
app.post("/api/auth/callback", async (req, res) => {
const provider = `${req.body?.provider || ""}`.toLowerCase();
const code = `${req.body?.code || ""}`;
const state = `${req.body?.state || ""}`;
if (!ensureProvider(provider) || !code || !state) {
res.status(400).json({ error: "Invalid callback payload." });
return;
}
const oauthState = store.consumeOAuthState(state);
if (!oauthState || oauthState.provider !== provider) {
res.status(400).json({ error: "OAuth state is invalid or expired." });
return;
}
const authClient = getAuthClientByKey(oauthState.clientKey);
if (!authClient) {
res.status(400).json({ error: "Unknown auth client context." });
return;
}
if (!authClient.discordClientId || !authClient.discordClientSecret) {
res.status(503).json({
error:
"Discord OAuth credentials are not configured for this frontend origin.",
});
return;
}
try {
const redirectUri = `${authClient.frontendOrigin}/oauth/callback?provider=${provider}`;
const tokenPayload = await exchangeDiscordCode({
code,
codeVerifier: oauthState.codeVerifier,
clientId: authClient.discordClientId,
clientSecret: authClient.discordClientSecret,
redirectUri,
});
const profile = await fetchDiscordProfile(tokenPayload.access_token);
const entitlement = resolveEntitlements(profile, authClient);
if (!entitlement.allowed) {
res.status(403).json({ error: "Account is not allowlisted." });
return;
}
const sessionRecord = {
user: entitlement.user,
features: entitlement.features,
};
const refresh = store.createRefreshSession(sessionRecord);
const accessToken = signAccessToken(sessionRecord);
res.cookie(
config.cookieName,
refresh.refreshToken,
getCookieOptions(refresh.expiresAt),
);
res.json({
...buildUserPayload(sessionRecord),
accessToken,
returnTo: oauthState.returnTo,
});
} catch {
res.status(500).json({ error: "Failed to complete OAuth callback." });
}
});
app.get("/api/auth/session", (req, res) => {
const refreshToken = req.cookies[config.cookieName];
if (!refreshToken) {
res.status(401).json({ error: "No active session." });
return;
}
const sessionRecord = store.getRefreshSession(refreshToken);
if (!sessionRecord) {
res.clearCookie(config.cookieName, getCookieOptions(Date.now()));
res.status(401).json({ error: "Session expired." });
return;
}
const accessToken = signAccessToken(sessionRecord);
res.json({
...buildUserPayload(sessionRecord),
accessToken,
});
});
app.post("/api/auth/refresh", (req, res) => {
const refreshToken = req.cookies[config.cookieName];
if (!refreshToken) {
res.status(401).json({ error: "No refresh token." });
return;
}
const sessionRecord = store.getRefreshSession(refreshToken);
if (!sessionRecord) {
res.clearCookie(config.cookieName, getCookieOptions(Date.now()));
res.status(401).json({ error: "Session expired." });
return;
}
store.revokeRefreshSession(refreshToken);
const refresh = store.createRefreshSession({
user: sessionRecord.user,
features: sessionRecord.features,
});
const accessToken = signAccessToken(sessionRecord);
res.cookie(
config.cookieName,
refresh.refreshToken,
getCookieOptions(refresh.expiresAt),
);
res.json({
...buildUserPayload(sessionRecord),
accessToken,
});
});
app.post("/api/auth/logout", (req, res) => {
const refreshToken = req.cookies[config.cookieName];
if (refreshToken) {
store.revokeRefreshSession(refreshToken);
}
res.clearCookie(config.cookieName, {
httpOnly: true,
secure: config.cookieSecure,
sameSite: config.cookieSameSite,
path: "/",
});
res.status(204).send();
});
app.listen(config.port, () => {
console.log(`discord-oauth auth service running on http://localhost:${config.port}`);
});

View File

@@ -0,0 +1,74 @@
import crypto from "node:crypto";
const STATE_TTL_MS = 10 * 60 * 1000;
function now() {
return Date.now();
}
function ttlToMs(days) {
return days * 24 * 60 * 60 * 1000;
}
export class SessionStore {
constructor(options = {}) {
this.oauthStates = new Map();
this.refreshSessions = new Map();
this.refreshTtlMs = ttlToMs(options.refreshTokenTtlDays || 30);
}
createOAuthState(payload) {
this.oauthStates.set(payload.state, {
...payload,
createdAt: now(),
});
}
consumeOAuthState(state) {
const record = this.oauthStates.get(state);
if (!record) {
return null;
}
this.oauthStates.delete(state);
if (now() - record.createdAt > STATE_TTL_MS) {
return null;
}
return record;
}
createRefreshSession(payload) {
const refreshToken = crypto.randomBytes(48).toString("base64url");
const expiresAt = now() + this.refreshTtlMs;
this.refreshSessions.set(refreshToken, {
...payload,
expiresAt,
});
return {
refreshToken,
expiresAt,
};
}
getRefreshSession(refreshToken) {
const record = this.refreshSessions.get(refreshToken);
if (!record) {
return null;
}
if (record.expiresAt <= now()) {
this.refreshSessions.delete(refreshToken);
return null;
}
return record;
}
revokeRefreshSession(refreshToken) {
this.refreshSessions.delete(refreshToken);
}
}

View File

@@ -5,4 +5,8 @@
- Add clear frontmatter. - Add clear frontmatter.
- Check naming and portability rules. - Check naming and portability rules.
- Validate any referenced scripts. - 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. - Commit and push after publishing.