mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 19:54:15 +00:00
appview: render the install scripts from config instead of shipping ATCR's
seamark.dev's /install and /settings/devices told users to pipe
seamark.dev/static/install.sh into bash. That file was the unmodified ATCR
script: it announced itself as the "ATCR Credential Helper Installer",
installed docker-credential-atcr, and finished by telling the user to configure
credHelpers for atcr.io, the wrong registry for that deployment. Anyone
following the documented setup ended up pointed at another service. The
templates hardcoded docker-credential-atcr, "atcr" and ~/.atcr/device.json
alongside a correctly themed {{ .RegistryURL }}.
The scripts are now rendered from config by a handler, rather than forked per
brand. A theme overlay was the alternative and was worse: it needed a full copy
of both install.sh and install.ps1 per brand, four scripts to keep in sync, and
the operator asked for these values to come from config.
credential_helper.name is the single knob. Docker resolves a credHelpers value
x by exec'ing docker-credential-x, so the credHelpers value, the binary suffix
and the config directory are genuinely one word, not three that can drift. It
is validated against a strict pattern because it is interpolated into a shell
script.
install.sh renders byte-identical to the deleted static file under the atcr
default, so existing installs are unaffected. install.ps1 differs by one line,
where a stale usage comment named a path the script is not served at.
Two behaviour changes worth noting: these two URLs drop from a one-year
Cache-Control to five minutes, since the body now depends on deployment config;
and credential_helper.tangled_repo becomes a real overridable default. It was
previously assigned over unconditionally and read by nothing, while the shipped
script used a different URL form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
This commit is contained in:
co-authored by
Claude Opus 5
parent
2743445e65
commit
9d8bd513da
@@ -0,0 +1,115 @@
|
||||
// Package installscript renders the credential-helper install scripts
|
||||
// (install.sh, install.ps1) from the running deployment's configuration.
|
||||
//
|
||||
// The scripts used to be static files under pkg/appview/public/static/, which
|
||||
// meant every rebranded deployment served atcr.io's script: it installed
|
||||
// docker-credential-atcr and told the user to point Docker's credHelpers at
|
||||
// atcr.io no matter which registry they had actually been browsing. Rendering
|
||||
// them from a Brand keeps the helper binary name, the credHelpers value, the
|
||||
// config directory and the registry host in exactly one place, shared with the
|
||||
// install/settings UI templates.
|
||||
package installscript
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed templates/*.tmpl
|
||||
var templatesFS embed.FS
|
||||
|
||||
var tmpl = template.Must(template.ParseFS(templatesFS, "templates/*.tmpl"))
|
||||
|
||||
// DefaultName is the credential helper brand used when nothing is configured.
|
||||
const DefaultName = "atcr"
|
||||
|
||||
// DefaultReleasesBaseURL is where the helper release archives are published.
|
||||
// The DID-based Tangled URL is used rather than the handle-based one so the
|
||||
// link survives a handle rename; pkg/credhelper's self-updater uses the same.
|
||||
const DefaultReleasesBaseURL = "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64"
|
||||
|
||||
// nameRE constrains Brand.Name. The value is interpolated into a shell script
|
||||
// and a PowerShell script, and Docker additionally requires that it be usable
|
||||
// as a filename suffix (docker-credential-<name>), so keep it boring.
|
||||
var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// Brand is the credential-helper identity of one deployment. Docker resolves a
|
||||
// credHelpers value "x" by executing "docker-credential-x", so the helper's
|
||||
// binary name, the credHelpers value and (by convention) the helper's config
|
||||
// directory are all the same word. Keeping them as one field is what stops
|
||||
// them drifting apart across the scripts and the UI.
|
||||
type Brand struct {
|
||||
// Name is the credHelpers value, e.g. "atcr" or "seamark".
|
||||
Name string
|
||||
|
||||
// DisplayName is the human brand shown in script output, e.g. "Seamark".
|
||||
DisplayName string
|
||||
|
||||
// ReleasesBaseURL is the Tangled repo the release archives hang off.
|
||||
ReleasesBaseURL string
|
||||
}
|
||||
|
||||
// NewBrand normalizes a configured brand, filling in defaults.
|
||||
func NewBrand(name, displayName, releasesBaseURL string) (Brand, error) {
|
||||
b := Brand{
|
||||
Name: strings.TrimSpace(name),
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
ReleasesBaseURL: strings.TrimRight(strings.TrimSpace(releasesBaseURL), "/"),
|
||||
}
|
||||
if b.Name == "" {
|
||||
b.Name = DefaultName
|
||||
}
|
||||
if !nameRE.MatchString(b.Name) {
|
||||
return Brand{}, fmt.Errorf("credential helper name %q must match %s", b.Name, nameRE)
|
||||
}
|
||||
if b.DisplayName == "" {
|
||||
b.DisplayName = strings.ToUpper(b.Name)
|
||||
}
|
||||
if b.ReleasesBaseURL == "" {
|
||||
b.ReleasesBaseURL = DefaultReleasesBaseURL
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// BinaryName is the executable Docker looks for: docker-credential-<name>.
|
||||
func (b Brand) BinaryName() string { return "docker-credential-" + b.Name }
|
||||
|
||||
// ConfigDir is the helper's home-relative config directory, e.g. "~/.atcr".
|
||||
func (b Brand) ConfigDir() string { return "~/." + b.Name }
|
||||
|
||||
// DeviceFile is where the helper stores its device credential.
|
||||
func (b Brand) DeviceFile() string { return b.ConfigDir() + "/device.json" }
|
||||
|
||||
// EnvPrefix is the prefix for the install scripts' override variables, e.g.
|
||||
// ATCR_VERSION / SEAMARK_VERSION.
|
||||
func (b Brand) EnvPrefix() string {
|
||||
return strings.ToUpper(strings.ReplaceAll(b.Name, "-", "_"))
|
||||
}
|
||||
|
||||
// Params is everything a rendered install script needs.
|
||||
type Params struct {
|
||||
Brand
|
||||
|
||||
// RegistryHost is the credHelpers key: the registry host Docker
|
||||
// authenticates against, e.g. "atcr.io" or "seamark.cr". This is NOT
|
||||
// necessarily the site the script was downloaded from.
|
||||
RegistryHost string
|
||||
|
||||
// SiteHost is the web UI host the script is served from, used only for
|
||||
// the usage comment at the top of the script.
|
||||
SiteHost string
|
||||
}
|
||||
|
||||
// RenderShell writes install.sh for the given params.
|
||||
func RenderShell(w io.Writer, p Params) error {
|
||||
return tmpl.ExecuteTemplate(w, "install.sh.tmpl", p)
|
||||
}
|
||||
|
||||
// RenderPowerShell writes install.ps1 for the given params.
|
||||
func RenderPowerShell(w io.Writer, p Params) error {
|
||||
return tmpl.ExecuteTemplate(w, "install.ps1.tmpl", p)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package installscript_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/appview/installscript"
|
||||
)
|
||||
|
||||
func mustBrand(t *testing.T, name, display, releases string) installscript.Brand {
|
||||
t.Helper()
|
||||
b, err := installscript.NewBrand(name, display, releases)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBrand(%q, %q, %q) error = %v", name, display, releases, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestNewBrandDefaultsToATCR(t *testing.T) {
|
||||
b := mustBrand(t, "", "", "")
|
||||
|
||||
if b.Name != "atcr" {
|
||||
t.Errorf("Name = %q, want atcr", b.Name)
|
||||
}
|
||||
if b.BinaryName() != "docker-credential-atcr" {
|
||||
t.Errorf("BinaryName() = %q", b.BinaryName())
|
||||
}
|
||||
if b.ConfigDir() != "~/.atcr" {
|
||||
t.Errorf("ConfigDir() = %q", b.ConfigDir())
|
||||
}
|
||||
if b.DeviceFile() != "~/.atcr/device.json" {
|
||||
t.Errorf("DeviceFile() = %q", b.DeviceFile())
|
||||
}
|
||||
if b.EnvPrefix() != "ATCR" {
|
||||
t.Errorf("EnvPrefix() = %q", b.EnvPrefix())
|
||||
}
|
||||
if b.ReleasesBaseURL != installscript.DefaultReleasesBaseURL {
|
||||
t.Errorf("ReleasesBaseURL = %q", b.ReleasesBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBrandSeamark(t *testing.T) {
|
||||
b := mustBrand(t, "seamark", "Seamark", "")
|
||||
|
||||
if b.BinaryName() != "docker-credential-seamark" {
|
||||
t.Errorf("BinaryName() = %q", b.BinaryName())
|
||||
}
|
||||
if b.DeviceFile() != "~/.seamark/device.json" {
|
||||
t.Errorf("DeviceFile() = %q", b.DeviceFile())
|
||||
}
|
||||
if b.EnvPrefix() != "SEAMARK" {
|
||||
t.Errorf("EnvPrefix() = %q", b.EnvPrefix())
|
||||
}
|
||||
}
|
||||
|
||||
// The name lands inside a shell script and a PowerShell script, so anything
|
||||
// that is not a plain lowercase word has to be refused at config load.
|
||||
func TestNewBrandRejectsUnsafeNames(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"ATCR",
|
||||
"sea mark",
|
||||
"sea/mark",
|
||||
"$(id)",
|
||||
"a`id`",
|
||||
"-lead",
|
||||
"sea\nmark",
|
||||
} {
|
||||
if _, err := installscript.NewBrand(name, "", ""); err == nil {
|
||||
t.Errorf("NewBrand(%q) accepted an unsafe name", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderShellSeamark(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := installscript.RenderShell(&buf, installscript.Params{
|
||||
Brand: mustBrand(t, "seamark", "Seamark", ""),
|
||||
RegistryHost: "seamark.cr",
|
||||
SiteHost: "seamark.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderShell() error = %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"# Seamark Credential Helper Installation Script",
|
||||
"# Usage: curl -fsSL https://seamark.dev/static/install.sh | bash",
|
||||
`BINARY_NAME="docker-credential-seamark"`,
|
||||
`TANGLED_REPO="${SEAMARK_TANGLED_REPO:-` + installscript.DefaultReleasesBaseURL + `}"`,
|
||||
`download/docker-credential-seamark_${version_without_v}_${OS}_${ARCH}.tar.gz`,
|
||||
`{"credHelpers": {"seamark.cr": "seamark"}}`,
|
||||
` "seamark.cr": "seamark"`,
|
||||
`if [ -n "$SEAMARK_VERSION" ]; then`,
|
||||
`VERSION="$SEAMARK_VERSION"`,
|
||||
"Seamark Credential Helper Installer",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("rendered install.sh missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the fix: a Seamark deployment must not name atcr
|
||||
// anywhere in the script it hands to `curl | bash`.
|
||||
for _, forbidden := range []string{"atcr", "ATCR"} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Errorf("rendered install.sh still contains %q:\n%s", forbidden, offendingLines(got, forbidden))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPowerShellSeamark(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := installscript.RenderPowerShell(&buf, installscript.Params{
|
||||
Brand: mustBrand(t, "seamark", "Seamark", ""),
|
||||
RegistryHost: "seamark.cr",
|
||||
SiteHost: "seamark.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderPowerShell() error = %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
`$BinaryName = "docker-credential-seamark.exe"`,
|
||||
`$env:SEAMARK_INSTALL_DIR`,
|
||||
`"$env:ProgramFiles\Seamark"`,
|
||||
`docker-credential-seamark_${versionClean}_Windows_${Arch}.tar.gz`,
|
||||
` "seamark.cr": "seamark"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("rendered install.ps1 missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"atcr", "ATCR"} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Errorf("rendered install.ps1 still contains %q:\n%s", forbidden, offendingLines(got, forbidden))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The default deployment must keep producing exactly the script it shipped as
|
||||
// a static file, so a rebrand cannot regress atcr.io's documented install.
|
||||
func TestRenderDefaultIsUnchangedATCR(t *testing.T) {
|
||||
p := installscript.Params{
|
||||
Brand: mustBrand(t, "", "ATCR", ""),
|
||||
RegistryHost: "atcr.io",
|
||||
SiteHost: "atcr.io",
|
||||
}
|
||||
|
||||
var sh bytes.Buffer
|
||||
if err := installscript.RenderShell(&sh, p); err != nil {
|
||||
t.Fatalf("RenderShell() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"# ATCR Credential Helper Installation Script",
|
||||
"# Usage: curl -fsSL https://atcr.io/static/install.sh | bash",
|
||||
`BINARY_NAME="docker-credential-atcr"`,
|
||||
`TANGLED_REPO="${ATCR_TANGLED_REPO:-https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64}"`,
|
||||
`download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz`,
|
||||
`{"credHelpers": {"atcr.io": "atcr"}}`,
|
||||
` "atcr.io": "atcr"`,
|
||||
`if [ -n "$ATCR_VERSION" ]; then`,
|
||||
`VERSION="$ATCR_VERSION"`,
|
||||
"ATCR Credential Helper Installer",
|
||||
} {
|
||||
if !strings.Contains(sh.String(), want) {
|
||||
t.Errorf("rendered install.sh missing %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(sh.String(), "#!/bin/bash\n") {
|
||||
t.Error("rendered install.sh lost its shebang")
|
||||
}
|
||||
|
||||
var ps bytes.Buffer
|
||||
if err := installscript.RenderPowerShell(&ps, p); err != nil {
|
||||
t.Fatalf("RenderPowerShell() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`$BinaryName = "docker-credential-atcr.exe"`,
|
||||
`$env:ATCR_INSTALL_DIR`,
|
||||
`"$env:ProgramFiles\ATCR"`,
|
||||
` "atcr.io": "atcr"`,
|
||||
} {
|
||||
if !strings.Contains(ps.String(), want) {
|
||||
t.Errorf("rendered install.ps1 missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A registry host that differs from the site host is the Seamark shape:
|
||||
// the UI is seamark.dev, but Docker authenticates against seamark.cr, so the
|
||||
// credHelpers key has to be the registry.
|
||||
func TestCredHelpersKeyIsRegistryNotSite(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := installscript.RenderShell(&buf, installscript.Params{
|
||||
Brand: mustBrand(t, "seamark", "Seamark", ""),
|
||||
RegistryHost: "seamark.cr",
|
||||
SiteHost: "seamark.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderShell() error = %v", err)
|
||||
}
|
||||
if strings.Contains(buf.String(), `"seamark.dev": "seamark"`) {
|
||||
t.Error("credHelpers key used the site host instead of the registry host")
|
||||
}
|
||||
}
|
||||
|
||||
func offendingLines(s, needle string) string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if strings.Contains(line, needle) {
|
||||
out = append(out, " "+line)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
# {{ .DisplayName }} Credential Helper Installation Script for Windows
|
||||
# Usage: iwr -useb https://{{ .SiteHost }}/static/install.ps1 | iex
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Configuration
|
||||
$BinaryName = "{{ .BinaryName }}.exe"
|
||||
$InstallDir = if ($env:{{ .EnvPrefix }}_INSTALL_DIR) { $env:{{ .EnvPrefix }}_INSTALL_DIR } else { "$env:ProgramFiles\{{ .DisplayName }}" }
|
||||
$TangledRepo = if ($env:{{ .EnvPrefix }}_TANGLED_REPO) { $env:{{ .EnvPrefix }}_TANGLED_REPO } else { "{{ .ReleasesBaseURL }}" }
|
||||
|
||||
Write-Host "{{ .DisplayName }} Credential Helper Installer for Windows" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Detect architecture
|
||||
function Get-Architecture {
|
||||
$arch = (Get-WmiObject Win32_Processor).Architecture
|
||||
switch ($arch) {
|
||||
9 { return "x86_64" } # x64
|
||||
12 { return "arm64" } # ARM64
|
||||
default {
|
||||
Write-Host "Unsupported architecture: $arch" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$Arch = Get-Architecture
|
||||
Write-Host "Detected: Windows $Arch" -ForegroundColor Green
|
||||
|
||||
# Resolve the latest version by following the tangled /tags/latest redirect
|
||||
# chain. Tangled redirects DID→handle first, then handle/tags/latest→handle/tags/vX.Y.Z,
|
||||
# so we follow all redirects and read the final effective URL.
|
||||
function Get-LatestVersion {
|
||||
Write-Host "Resolving latest version..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri "$TangledRepo/tags/latest" -UseBasicParsing -Method Head
|
||||
} catch {
|
||||
Write-Host "Failed to resolve latest version from $TangledRepo/tags/latest" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# PowerShell 7+ exposes the final URI via RequestMessage; PowerShell 5 via ResponseUri.
|
||||
if ($response.BaseResponse.RequestMessage) {
|
||||
$finalUrl = $response.BaseResponse.RequestMessage.RequestUri.ToString()
|
||||
} else {
|
||||
$finalUrl = $response.BaseResponse.ResponseUri.ToString()
|
||||
}
|
||||
|
||||
if (-not $finalUrl) {
|
||||
Write-Host "Failed to resolve latest version from $TangledRepo/tags/latest" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$tag = $finalUrl.TrimEnd('/').Split('/')[-1]
|
||||
if (-not $tag.StartsWith('v')) {
|
||||
Write-Host "Unexpected redirect location: $finalUrl" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found latest version: $tag" -ForegroundColor Green
|
||||
return $tag
|
||||
}
|
||||
|
||||
# Build the download URL from version and platform
|
||||
function Get-DownloadUrl {
|
||||
param([string]$Version, [string]$Arch)
|
||||
|
||||
$versionClean = $Version.TrimStart('v')
|
||||
$fileName = "{{ .BinaryName }}_${versionClean}_Windows_${Arch}.tar.gz"
|
||||
return "$TangledRepo/tags/$Version/download/$fileName"
|
||||
}
|
||||
|
||||
# Determine version and download URL
|
||||
if ($env:{{ .EnvPrefix }}_VERSION) {
|
||||
$Version = $env:{{ .EnvPrefix }}_VERSION
|
||||
Write-Host "Using specified version: $Version" -ForegroundColor Yellow
|
||||
} else {
|
||||
$Version = Get-LatestVersion
|
||||
}
|
||||
|
||||
$DownloadUrl = Get-DownloadUrl -Version $Version -Arch $Arch
|
||||
Write-Host "Installing version: $Version" -ForegroundColor Green
|
||||
|
||||
# Download and install binary
|
||||
function Install-Binary {
|
||||
param (
|
||||
[string]$DownloadUrl
|
||||
)
|
||||
|
||||
Write-Host "Downloading from: $DownloadUrl" -ForegroundColor Yellow
|
||||
|
||||
$tempDir = New-Item -ItemType Directory -Path "$env:TEMP\{{ .Name }}-install-$(Get-Random)" -Force
|
||||
$archivePath = Join-Path $tempDir "{{ .BinaryName }}.tar.gz"
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri $DownloadUrl -OutFile $archivePath -UseBasicParsing
|
||||
} catch {
|
||||
Write-Host "Failed to download release: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Extracting..." -ForegroundColor Yellow
|
||||
# Modern Windows ships tar.exe; use it to handle .tar.gz produced by goreleaser.
|
||||
& tar.exe -xzf $archivePath -C $tempDir
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Failed to extract archive" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Create install directory
|
||||
if (-not (Test-Path $InstallDir)) {
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Install binary
|
||||
$binaryPath = Join-Path $tempDir $BinaryName
|
||||
$destPath = Join-Path $InstallDir $BinaryName
|
||||
|
||||
Copy-Item -Path $binaryPath -Destination $destPath -Force
|
||||
|
||||
# Clean up
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
|
||||
Write-Host "Installed $BinaryName to $InstallDir" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Add to PATH if not already present
|
||||
function Add-ToPath {
|
||||
$currentPath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
|
||||
if ($currentPath -notlike "*$InstallDir*") {
|
||||
Write-Host "Adding $InstallDir to system PATH..." -ForegroundColor Yellow
|
||||
|
||||
# Check if running as administrator
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
|
||||
if ($isAdmin) {
|
||||
[Environment]::SetEnvironmentVariable("Path", "$currentPath;$InstallDir", "Machine")
|
||||
$env:Path = "$env:Path;$InstallDir"
|
||||
Write-Host "Added to PATH successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "WARNING: Not running as administrator. Cannot update system PATH." -ForegroundColor Yellow
|
||||
Write-Host "Please add $InstallDir to your PATH manually or re-run as administrator." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Verify installation
|
||||
function Test-Installation {
|
||||
$binaryPath = Join-Path $InstallDir $BinaryName
|
||||
|
||||
if (Test-Path $binaryPath) {
|
||||
Write-Host "Verification successful!" -ForegroundColor Green
|
||||
|
||||
# Try to run version command
|
||||
try {
|
||||
& $binaryPath --version
|
||||
} catch {
|
||||
Write-Host "Binary installed but PATH may need to be refreshed." -ForegroundColor Yellow
|
||||
Write-Host "Please restart your terminal or run: refreshenv" -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Host "Installation failed: binary not found at $binaryPath" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Show configuration instructions
|
||||
function Show-Configuration {
|
||||
Write-Host ""
|
||||
Write-Host "Installation complete!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "To use {{ .DisplayName }} with Docker, configure Docker to use this credential helper:" -ForegroundColor Yellow
|
||||
Write-Host ' Edit %USERPROFILE%\.docker\config.json and add:'
|
||||
Write-Host ' {
|
||||
"credHelpers": {
|
||||
"{{ .RegistryHost }}": "{{ .Name }}"
|
||||
}
|
||||
}'
|
||||
Write-Host ""
|
||||
Write-Host "Note: You may need to restart your terminal for PATH changes to take effect." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Main installation flow
|
||||
try {
|
||||
Install-Binary -DownloadUrl $DownloadUrl
|
||||
Add-ToPath
|
||||
Test-Installation
|
||||
Show-Configuration
|
||||
} catch {
|
||||
Write-Host "Installation failed: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/bin/bash
|
||||
# {{ .DisplayName }} Credential Helper Installation Script
|
||||
# Usage: curl -fsSL https://{{ .SiteHost }}/static/install.sh | bash
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
BINARY_NAME="{{ .BinaryName }}"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
||||
TANGLED_REPO="${ {{- .EnvPrefix }}_TANGLED_REPO:-{{ .ReleasesBaseURL }}}"
|
||||
|
||||
# Detect OS and architecture
|
||||
detect_platform() {
|
||||
local os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
local arch=$(uname -m)
|
||||
|
||||
case "$os" in
|
||||
linux*)
|
||||
OS="Linux"
|
||||
;;
|
||||
darwin*)
|
||||
OS="Darwin"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS: $os${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$arch" in
|
||||
x86_64|amd64)
|
||||
ARCH="x86_64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported architecture: $arch${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Resolve the latest version by following the tangled /tags/latest redirect
|
||||
# chain. Tangled redirects DID→handle first, then handle/tags/latest→handle/tags/vX.Y.Z,
|
||||
# so we need -L to follow both hops and read the final effective URL.
|
||||
fetch_latest_version() {
|
||||
echo -e "${YELLOW}Resolving latest version...${NC}"
|
||||
|
||||
local final_url
|
||||
final_url=$(curl -sL --max-time 10 -o /dev/null -w '%{url_effective}' "${TANGLED_REPO}/tags/latest")
|
||||
|
||||
if [ -z "$final_url" ]; then
|
||||
echo -e "${RED}Failed to resolve latest version from ${TANGLED_REPO}/tags/latest${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${final_url##*/}"
|
||||
|
||||
if [ -z "$VERSION" ] || [ "${VERSION#v}" = "$VERSION" ]; then
|
||||
echo -e "${RED}Unexpected redirect location: ${final_url}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Found latest version: ${VERSION}${NC}"
|
||||
}
|
||||
|
||||
# Build the download URL from version and platform
|
||||
build_download_url() {
|
||||
local version_without_v="${VERSION#v}"
|
||||
DOWNLOAD_URL="${TANGLED_REPO}/tags/${VERSION}/download/{{ .BinaryName }}_${version_without_v}_${OS}_${ARCH}.tar.gz"
|
||||
}
|
||||
|
||||
# Download and install binary
|
||||
install_binary() {
|
||||
echo -e "${YELLOW}Downloading from: ${DOWNLOAD_URL}${NC}"
|
||||
|
||||
local tmp_dir=$(mktemp -d)
|
||||
trap "rm -rf $tmp_dir" EXIT
|
||||
|
||||
if ! curl -fsSL "$DOWNLOAD_URL" -o "$tmp_dir/{{ .BinaryName }}.tar.gz"; then
|
||||
echo -e "${RED}Failed to download release${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Extracting...${NC}"
|
||||
tar -xzf "$tmp_dir/{{ .BinaryName }}.tar.gz" -C "$tmp_dir"
|
||||
|
||||
# Check if we need sudo
|
||||
if [ -w "$INSTALL_DIR" ]; then
|
||||
SUDO=""
|
||||
else
|
||||
SUDO="sudo"
|
||||
echo -e "${YELLOW}Installing to ${INSTALL_DIR} (requires sudo)${NC}"
|
||||
fi
|
||||
|
||||
$SUDO mkdir -p "$INSTALL_DIR"
|
||||
$SUDO install -m 755 "$tmp_dir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
|
||||
|
||||
echo -e "${GREEN}Installed ${BINARY_NAME} to ${INSTALL_DIR}${NC}"
|
||||
}
|
||||
|
||||
# Verify installation
|
||||
verify_installation() {
|
||||
if ! command -v "$BINARY_NAME" &> /dev/null; then
|
||||
echo -e "${RED}${BINARY_NAME} not found in PATH${NC}"
|
||||
echo -e "${YELLOW}You may need to add ${INSTALL_DIR} to your PATH${NC}"
|
||||
echo -e "${YELLOW}Add this to your ~/.bashrc or ~/.zshrc:${NC}"
|
||||
echo -e " export PATH=\"${INSTALL_DIR}:\$PATH\""
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Verification successful!${NC}"
|
||||
"$BINARY_NAME" --version
|
||||
}
|
||||
|
||||
# Configure Docker
|
||||
configure_docker() {
|
||||
echo ""
|
||||
echo -e "${GREEN}Installation complete!${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}To use {{ .DisplayName }} with Docker, configure Docker to use this credential helper:${NC}"
|
||||
echo -e ' echo '\''{"credHelpers": {"{{ .RegistryHost }}": "{{ .Name }}"}}'\'' > ~/.docker/config.json'
|
||||
echo ""
|
||||
echo -e "${YELLOW}Or add to existing config.json:${NC}"
|
||||
echo -e ' {
|
||||
"credHelpers": {
|
||||
"{{ .RegistryHost }}": "{{ .Name }}"
|
||||
}
|
||||
}'
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
echo -e "${GREEN}{{ .DisplayName }} Credential Helper Installer${NC}"
|
||||
echo ""
|
||||
|
||||
detect_platform
|
||||
echo -e "Detected: ${GREEN}${OS} ${ARCH}${NC}"
|
||||
|
||||
if [ -n "${{ .EnvPrefix }}_VERSION" ]; then
|
||||
VERSION="${{ .EnvPrefix }}_VERSION"
|
||||
echo -e "Using specified version: ${GREEN}${VERSION}${NC}"
|
||||
else
|
||||
fetch_latest_version
|
||||
fi
|
||||
|
||||
build_download_url
|
||||
echo -e "Installing version: ${GREEN}${VERSION}${NC}"
|
||||
|
||||
install_binary
|
||||
verify_installation
|
||||
configure_docker
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user