This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
The Upsun CLI is a Go-based command-line interface for Upsun. The CLI is a hybrid system that wraps a legacy PHP CLI (located in the legacy/ subdirectory) while providing new Go-based commands. It supports multiple vendors through build tags and configuration files.
All targets are in the root Makefile, including those for the legacy PHP CLI. Run make to list them.
Release steps are in docs/development.md, and the CLI's environment variables in docs/configuration.md.
Build a single binary for your platform:
make singleBuild a snapshot for all platforms:
make snapshotRun tests (Go and PHP unit tests):
make test
# or separately:
make test-go
make test-phpRun linters (Go and PHP):
make lint
# or separately:
make lint-go
make lint-phpRemove the phar, command indexes, legacy dependencies and caches (clean-all also removes PHP binaries and dist/):
make cleanThe phar is rebuilt when files in legacy/ change. Building it installs legacy/vendor without dev dependencies; the PHP lint and test targets reinstall them.
Format code:
go fmt ./...Tidy dependencies:
go mod tidyRun a single test:
go test -v -run TestName ./path/to/packageThe CLI operates as a wrapper around a legacy PHP CLI:
- Go layer: Handles new commands (init, list, version, config:install, project:convert, lint) and core infrastructure
- PHP layer: Legacy commands are proxied through
internal/legacy/CLIWrapper - The PHP CLI (platform.phar) is embedded at build time via go:embed
- An index of legacy commands (
commands-<flavor>.json, fromlist --all --format=jsonwith the embedded config) is embedded too, so the Go layer can resolve abbreviations likep:initin the same way as Symfony Console, and answer shell completion (_complete) for command and option names; PHP is only started for argument and option values, or when a user config file or environment variable may change the commands
Entry Point: cmd/platform/main.go
- Loads configuration from YAML (embedded or external)
- Sets up Viper for environment variable handling
- Delegates to commands package
Commands: commands/
root.go: Root command that sets up the Cobra CLI and delegates to legacy CLI when needed- Native Go commands: init, list, version, config:install, project:convert, completion, lint, and with Go auth (below): auth:browser-login (login), auth:api-token-login, auth:logout (logout), auth:token
lint.go: Native config linter,lint(alsovalidate; registered under the namespaced nameapp:config-validate). Validates Flex (.upsun) and Fixed (.platform) config inpkg/lint, reporting all errors at once. It looks for those names only, so it is not registered in CLIs configured with other names.pkg/lintandpkg/registryare public because other Upsun projects import them; their API is not covered by semantic versioning. The Flex JSON schema and the image registry come from meta.upsun.com (make update-schema,make update-registry): schema changes are made in the upsun/meta repository first. Fixed config is checked with the same schema, patched byinternal/schema/fixed-overlay.json(a JSON Merge Patch adding Fixed-only keys such asdisk)- Unrecognized commands are passed to the legacy PHP CLI
Configuration: internal/config/
schema.go: Config struct definition with validation tagsversion.go: Uses runtime/debug to get version info from VCS- Supports vendorization through embedded YAML configs:
config_upsun.go: Default (no build tags) - Upsun CLIconfig_vendor.go: Requires-tags vendor- Custom vendor CLI
- Config can be loaded from external files for testing/development
Legacy Integration: internal/legacy/ and legacy/
internal/legacy/legacy.go: CLIWrapper that manages PHP binary and phar executionlegacy/: The legacy PHP CLI codebase (subtree merged from legacy-cli 5.x)- PHP binaries are embedded per platform via go:embed and build tags
- Uses file locking to prevent concurrent initialization
- Copies PHP binary and phar to cache directory on first run
- The legacy CLI caches API data under the current user's ID (
Api::userCache()), read from the access token's claims (refreshing it first if it has expired) or from the API, so cached data is not shared between users
API Client: internal/api/
- HTTP client for interacting with Upsun API
- Handles authentication, organizations, and resource management
Authentication: internal/auth/
- Authentication is moving from PHP to Go, behind a feature flag:
<PREFIX>GO_AUTH=1(an env var for us and support, read by both Go and PHP). Without it, the legacy PHP CLI handles auth as before, and Go gets tokens from it (LegacyCLIClient) - With Go auth, Go is the only component that stores or refreshes credentials.
auth.Managerresolves tokens (in order: an API token set via config,api.access_token, an API token stored byauth:api-token-login, stored sessions) and refreshes them under a per-session flock (<writable dir>/auth/<id>.lock), re-reading the store under the lock because refresh tokens rotate internal/auth/store: one entry per session ID, in the system keychain (go-keyring) or in<writable dir>/auth/<id>.json. On Linux the keychain is only used if the Secret Service is GNOME Keyring or KWallet, and unlocked: other providers (e.g. KeePassXC) may ask the user to confirm each process that reads a secret- Auth settings are read by
config.Auth()with the legacy CLI's precedence: embedded config, the user'sconfig.yaml, env vars - The legacy CLI gets tokens and auth state from the wrapper over a channel served while PHP runs (
internal/authchannel): a Unix socket in a new directory in the system's temporary directory, the CLI's, or/tmp(<PREFIX>WRAPPER_AUTH_SOCKET), or on Windows a named pipe (<PREFIX>WRAPPER_AUTH_PIPE), that only the current user can open. PHP connects for each request, so that the processes it starts do not inherit a connection, and the wrapper only accepts connections from the PHP process's ID (it answers others with a refusal). In processes that PHP forks (e.g. tunnels), when its connection is refused, or after the wrapper exits, it runs the hiddenauth:internal token|statuscommand instead (via<PREFIX>WRAPPER_EXECUTABLE); other failures of the channel are errors. The wrapper does not catch signals, so a socket directory is left when it is ended by one (e.g. Ctrl+C): a later run removes it, if its process does not exist and its socket refuses connections. With the channel, the wrapper removes the token variables (<PREFIX>TOKEN,<PREFIX>API_TOKEN,<PREFIX>API_ACCESS_TOKEN) from PHP's environment, so that the processes PHP starts (e.g. Git hooks) do not inherit them, and passes<PREFIX>WRAPPER_HAS_TOKENinstead. If the channel cannot be created, a token set in the environment is an error, rather than passed on. In a process that inherits<PREFIX>WRAPPER_HAS_TOKEN=1without a token (e.g.auth:internalfrom a forked PHP process, or the CLI run from a Git hook), Go refuses to get a token rather than use other credentials (auth.ErrTokenWithheld). SSH commands run by the CLI setCLI_SSH_NO_REFRESH=1, so the SSH config's certificate refresh does not run a nested CLI. Go runs the hidden PHP commandsauth:post-login(SSH certificates and config) andauth:export-sessions(a one-time migration of the legacy storage, recorded inauth/.migrated) - Go commands that call the API get their HTTP client and API URL from
newAPIAuth(commands/api_auth.go), which works in both auth modes, and run requests inside itswithLogin, which offers a login on a login-required error or step-up challenge - Integration tests run with Go auth by default. Until legacy auth is no longer used, CI also runs them on Linux with legacy auth (
TEST_CLI_GO_AUTH=0). Tests of Go-only behavior usenewGoAuthCommandFactory
Table Output: internal/table/
- Renders tables with the same
--format,--columnsand--no-headeroptions as the legacy CLI'sTableservice - The
csv,tsvandplainformats match the legacy output, with ANSI escape sequences removed - The
tableformat wraps cells to the terminal width (a port of the legacyAdaptiveTable), and supports ANSI-styled cells. Headers are wrapped too, at word boundaries, only if the table does not fit
Project Initialization: internal/init/ and internal/platformify/
initgenerates a starter configuration with AI (internal/init, using the whatsun library for codebase analysis), or with a guided wizard (internal/platformify, formerly the Platformify project), which also supports Fixed configuration- The wizard detects the stack, asks a few questions, and renders
internal/platformify/templates(flexorfixed, byproject_config_flavor). Runtime and service versions come frompkg/registry, and tests check the output withpkg/lint
PR builds: CI's build-cross job builds the CLI for all five platforms (artifacts cli-<os>-<arch>, kept for 7 days), used by integration-test-cross and by reviewers. The version is 0.0.0-pr<N>+<sha> (or 0.0.0-<branch>+<sha>): versions starting with 0.0.0 are dev builds, which do not check for updates. For pull requests from branches in this repository (not forks, as their code is untrusted), the comment-builds job creates or updates a comment with the download links and gh run download commands.
Nightly: .github/workflows/nightly.yml builds a snapshot daily (NIGHTLY=1 make snapshot-no-nfpm, versioned <next patch>-nightly.<date>+<commit>), and replaces the assets of the nightly prerelease in upsun/cli-nightly (with a GitHub App token), as releases in this repository are immutable. scripts/nightly-assets.sh renames the archives with nightly in place of the version, for the installer's VERSION=nightly. No other channel is published.
winget: GoReleaser only generates the Upsun.CLI manifests. The release workflow's winget job submits them with scripts/winget-submit.sh: it pushes a branch to the upsun/winget-pkgs fork and opens a PR to microsoft/winget-pkgs, with a bot's classic token (WINGET_GITHUB_TOKEN). It also sets the moniker to upsun, as GoReleaser sets it to the package name. It is a separate job so that a failure does not block the rest of the release.
Multi-Vendor Support:
- Uses Go build tags to compile different binaries:
- Default (no tags): Upsun CLI
-tags vendor: Custom vendor CLI
- Configuration is embedded at compile time
- GoReleaser builds multiple variants (upsun, platform, vendor-specific)
PHP Binary Handling:
- PHP binaries are downloaded from upsun/cli-php-builds releases, tagged
php-<PHP_VERSION>-<PHP_BUILD>. They are rebuilt (with a new build number) when PHP or a library has a new release - Their SHA-256 checksums are pinned in
internal/legacy/php_sha256sums.txt(the release'sSHA256SUMS.txt), and verified after download - All platforms use static binaries built with static-php-cli
- Supported platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64
- Extensions included: curl, filter, openssl, pcntl (Unix), phar, posix (Unix), zlib
- On Windows, curl is built with Schannel, which verifies certificates with Windows (its certificate store, distrust list and revocation). No CA file is set, as Schannel would use it instead of the store, unless
SSL_CERT_FILEis set (the wrapper setsopenssl.cafileto it, and the legacy CLI also setsCURLOPT_PROXY_CAINFO, as PHP only passes it to curl for servers: this matches Go). So the legacy CLI must make HTTPS requests with curl (Guzzle), not PHP streams (OpenSSL), and usesCURLSSLOPT_REVOKE_BEST_EFFORT, as roots installed for TLS inspection often have no revocation information
Downloading PHP Binaries:
# Download PHP for current platform only (for development)
make php
# Download all PHP binaries (for release builds)
make download-phpUpgrading PHP Version:
- The
update-phpworkflow runs daily: it finds the latest cli-php-builds release for the current PHP minor version that was published at least 2 days ago (a cooldown, so that problems with new upstream releases can be found first; run the workflow withcooldown_daysset to 0 to skip it, e.g. for a security fix), and opens or updates a pull request that updatesPHP_VERSION,PHP_BUILDand the checksums (withscripts/update-php.sh). The pull request lists the changed source archives (from the releases'sources.json) - For a new PHP minor version: trigger the build workflow at upsun/cli-php-builds with the new version, then set
PHP_VERSIONto it andPHP_BUILDto0(builds are numbered from 1), runCOOLDOWN_DAYS=0 scripts/update-php.sh <title-file> <body-file>, andmake download-phpto check the downloads
Tests use github.com/stretchr/testify for assertions. Table-driven tests are preferred with a "cases" slice containing simple test case structs.
Integration tests (integration-tests/) call t.Parallel(), so they must not share state: no t.Setenv or unsynchronized package variables, and use cmdFactory.useConfig to change the CLI config file. Write output with t.Log (e.g. mockapi.RequestLogger), not to stdout or stderr: it is then attributed to its test, and shown only on failure or with -v. Tests of environment operations use setUpProject, and tests of Git commands (push, clone, fetch) use setUpGitProject, which also serves a bare repository over the mock SSH server. Tests of other SSH commands use serveSSH with an app from sshEnv, and fake remote executables from remoteEnv. The mock SSH server can also serve SFTP (SFTPDir) and forward connections (DialTCP, ListenUnix); tests of commands that start background processes (tunnels) must make sure they exit.
The mock API (pkg/mockapi) validates each successful request and response against the API's OpenAPI spec (pkg/mockapi/openapi.json, refreshed with make update-openapi-spec; CI's non-blocking lint-upstream job checks that it is up to date), and reports failures through the test. Responses may leave out required properties. Routes that are not in the spec are listed in notInSpec, and differences between the spec and the API are patched in patchSpec. It also records every request it receives: assert on its state where possible, and on Requests(method, pathPattern) when the request itself is what is tested. FailNext(method, pathPattern, status, body) makes the next matching request fail, to test error handling: use mockapi.Problem for an API error body, or HTML for a proxy's error page.
make test-integration-coverage runs the integration tests with a CLI built with -cover, and prints a report (scripts/coverage-report): Go statement coverage per package, and the commands and command-specific options that the tests run (logged by buildCommand when INTEGRATION_TESTS_ARGS_LOG is set). CI runs the integration tests this way, and writes the report to the job summary.
make test-integration-php-coverage runs them for PHP line coverage of the legacy CLI: a CLI built with legacy.PHPPath set to legacy/scripts/coverage/php runs the legacy source (TEST_CLI_PHAR_PATH) on the system PHP, which must load PCOV. Each PHP process writes its coverage to PHP_COVERAGE_DIR, and legacy/scripts/coverage/report.php prints a summary by directory and writes an HTML report to dist/php-coverage/html. CI's integration-test-php-coverage job writes the summary to the job summary, and uploads the HTML report.
installer_test.go runs installer.sh (raw install method) against a fake GitHub server, using RELEASES_URL and RELEASES_API_URL, with an environment built from scratch so that real tokens are not used. The test-installer workflow and scripts/test/installer*.sh test the other install methods with real packages.
CI also runs the integration tests on macOS and Windows, using CLIs cross-compiled on Linux (build-cross). Skip tests that cannot work on a platform with t.Skip and a reason. The legacy CLI writes CRLF line endings on Windows: the cmdFactory run helpers convert them to LF.
The CLI uses Viper for configuration. Environment variables use the prefix defined in the config (UPSUN_CLI_ or PLATFORM_CLI_). The prefix is set in the config YAML.
When the root command receives arguments it doesn't recognize, it passes them to the legacy PHP CLI via CLIWrapper.Exec(). The PHP binary and phar are extracted to a cache directory on first use.
To build a vendor-specific CLI:
make vendor-snapshot VENDOR_NAME='Vendor Name' VENDOR_BINARY='vendorcli'
make vendor-release VENDOR_NAME='Vendor Name' VENDOR_BINARY='vendorcli'This requires a config file at internal/config/embedded-config.yaml (downloaded at build time).
Version information is obtained from Go's runtime/debug package (VCS info embedded at build time):
internal/config.Version: Git tag/version (from VCS or module version)internal/config.Commit: Git commit hash (from vcs.revision)internal/config.Date: Build date (from vcs.time)
PHP version is still injected via ldflags:
internal/legacy.PHPVersion: PHP version embedded
The CLI checks for updates from GitHub releases (when Wrapper.GitHubRepo is set in config). The network check runs in a background goroutine and caches the latest known version in state.json; the notice is shown before the command on a later run (see internal/update.go). When a check is running, the CLI waits up to 1 second for it before exiting.
Install-method detection (internal/install.go) tailors or suppresses the notice:
- System package managers (apt, yum/dnf, apk) are detected via a marker file installed by the nfpm packages (
packaging/install-source→/usr/share/<slug>/install-source). Before printing a notice, older package installs without the marker are also detected by querying dpkg/rpm/apk. The notice is suppressed because the OS handles updates. - Homebrew, Scoop, npm, winget, and the bash installer get a tailored upgrade command, built from config fields (
Wrapper.HomebrewTap,Wrapper.NpmPackage,Wrapper.WingetPackage,Wrapper.InstallerURL,Application.Executable). - winget installs are detected by their path (
\WinGet\Packages\). A release reaches winget only after Microsoft merges its manifest PR inmicrosoft/winget-pkgs, so for winget installs the background check reads the newest version there (from the GitHub API) instead of the latest GitHub release. - The notice is throttled to once a week (
LastNotifiedin state) and only shown in an interactive terminal. <PREFIX>INSTALL_METHODforces the method;<PREFIX>UPDATES_CHECK=0disables checks entirely.- With
CLI_CONFIG_FILE(e.g. aconfig:installshim), thewrapper.*keys and the application name, executable and slug come from the embedded config (Config.Embedded()), as the binary is the same. The notice is only shown if there is an upgrade command for the install method, as the binary may be a private copy.
See docs/design/update-message-install-detection.md for the full design, including the planned Phase 2 (opt-in self-update).