[CmdletBinding()] param() $ErrorActionPreference = "Stop" $bundleName = "zed-settings-sync.json" $utf8 = New-Object System.Text.UTF8Encoding($false) function Require-Command([string]$Name, [string]$InstallHint) { if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "$Name is required. Install it first: $InstallHint" } } function Get-GistId([string]$Value) { $matches = [regex]::Matches($Value.Trim(), "[a-fA-F0-9]{8,}") if ($matches.Count -eq 0) { throw "Enter a valid GitHub Gist ID or URL." } return $matches[$matches.Count - 1].Value.ToLowerInvariant() } function Test-AllowedPath([string]$Path) { $fixed = @( "settings.json", "keymap.json", "tasks.json", "debug.json", "scripts/toggle-file-scan-exclusions.mjs", "scripts/file-exclusions.json", "scripts/zed-settings-sync.mjs" ) if ($Path.StartsWith("/") -or $Path.Contains("\") -or $Path.Split("/") -contains "..") { return $false } return $fixed -contains $Path -or $Path.StartsWith("snippets/") -or $Path.StartsWith("themes/") } function Write-Atomic([string]$Path, [string]$Contents) { $directory = Split-Path $Path -Parent New-Item -ItemType Directory -Force -Path $directory | Out-Null $temporary = Join-Path $directory ".$([IO.Path]::GetFileName($Path)).$PID.tmp" try { [IO.File]::WriteAllText($temporary, $Contents, $utf8) Move-Item -LiteralPath $temporary -Destination $Path -Force } finally { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue } } Require-Command "node" "winget install OpenJS.NodeJS.LTS" if (-not $env:ZED_SETTINGS_BUNDLE_PATH) { Require-Command "gh" "winget install GitHub.cli" Remove-Item Env:GH_TOKEN,Env:GITHUB_TOKEN -ErrorAction SilentlyContinue & gh auth status *> $null if ($LASTEXITCODE -ne 0) { Write-Host "GitHub authentication is required." & gh auth login if ($LASTEXITCODE -ne 0) { throw "GitHub authentication failed." } } } $entered = if ($env:ZED_SETTINGS_GIST_ID) { $env:ZED_SETTINGS_GIST_ID } else { Read-Host "GitHub Gist ID or URL (leave blank to create a new Secret Gist)" } if ($env:ZED_SETTINGS_BUNDLE_PATH) { $gistId = Get-GistId $entered $bundleSource = [IO.File]::ReadAllText($env:ZED_SETTINGS_BUNDLE_PATH, [Text.Encoding]::UTF8) } else { $token = (& gh auth token).Trim() if ($LASTEXITCODE -ne 0 -or -not $token) { throw "Could not obtain the GitHub authentication token from gh." } $headers = @{ Accept = "application/vnd.github+json" Authorization = "Bearer $token" "User-Agent" = "zed-settings-bootstrap" "X-GitHub-Api-Version" = "2022-11-28" } if ([string]::IsNullOrWhiteSpace($entered)) { $seedUrl = "https://git.okk.cool/purp1e/zed-sync/raw/branch/master/zed-settings-sync.json" $bundleSource = (Invoke-WebRequest -UseBasicParsing -Uri $seedUrl).Content $payload = @{ description = "Zed settings sync" public = $false files = @{ $bundleName = @{ content = $bundleSource } } } | ConvertTo-Json -Depth 5 $created = Invoke-RestMethod -Method Post -Uri "https://api.github.com/gists" -Headers $headers -ContentType "application/json" -Body ([Text.Encoding]::UTF8.GetBytes($payload)) $gistId = $created.id Write-Host "Created Secret Gist: $($created.html_url)" } else { $gistId = Get-GistId $entered $gist = Invoke-RestMethod -Uri "https://api.github.com/gists/$gistId" -Headers $headers $remoteFile = $gist.files.$bundleName if (-not $remoteFile) { throw "Gist $gistId does not contain $bundleName." } if ($remoteFile.truncated) { throw "$bundleName is too large for the Gist API response." } $bundleSource = [string]$remoteFile.content } } try { $bundle = $bundleSource | ConvertFrom-Json } catch { throw "The Gist contains invalid JSON: $($_.Exception.Message)" } $properties = @($bundle.PSObject.Properties) foreach ($required in @("settings.json", "keymap.json", "tasks.json")) { if (-not ($properties.Name -contains $required)) { throw "The bundle is missing $required." } } foreach ($property in $properties) { if (-not (Test-AllowedPath $property.Name) -or $property.Value -isnot [string]) { throw "Invalid bundle file: $($property.Name)" } } $target = Join-Path $env:APPDATA "Zed" $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $backup = Join-Path $target "backups\gist-bootstrap-$timestamp" New-Item -ItemType Directory -Force -Path $target, $backup, (Join-Path $target "scripts") | Out-Null foreach ($property in $properties) { $existing = Join-Path $target ($property.Name.Replace("/", "\")) if (Test-Path -LiteralPath $existing) { $backupPath = Join-Path $backup ($property.Name.Replace("/", "\")) New-Item -ItemType Directory -Force -Path (Split-Path $backupPath -Parent) | Out-Null Copy-Item -LiteralPath $existing -Destination $backupPath -Force } } $toggleScript = (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs").Replace("\", "/") $syncScript = (Join-Path $target "scripts\zed-settings-sync.mjs").Replace("\", "/") foreach ($property in $properties) { $destination = Join-Path $target ($property.Name.Replace("/", "\")) $contents = [string]$property.Value if ($property.Name -eq "tasks.json") { $contents = $contents.Replace("__TOGGLE_SCRIPT__", $toggleScript).Replace("__SYNC_SCRIPT__", $syncScript) } Write-Atomic $destination $contents } $bytes = $utf8.GetBytes($bundleSource) $sha = [Security.Cryptography.SHA256]::Create() try { $hash = -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) } finally { $sha.Dispose() } $syncState = [ordered]@{ gist_id = $gistId; last_synced_hash = $hash } | ConvertTo-Json Write-Atomic (Join-Path $target "scripts\settings-sync.json") "$syncState`n" Write-Host "Installed Zed settings from Gist $gistId" Write-Host "SHA-256: $hash" Write-Host "Backup: $backup" Write-Host "Restart Zed to load the synchronized configuration."