Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
branches: [ dev ]
workflow_dispatch:

permissions:
contents: read

jobs:
lint-and-test:
runs-on: k8s
Expand All @@ -23,9 +26,12 @@ jobs:
repository: moropo-com/dcd
path: dcd
ssh-key: ${{ secrets.DCD_SSH_DEPLOY_KEY }}
# api/swagger.json is a file, which cone-mode sparse checkout rejects
# as of git 2.51 ("is not a directory") — use non-cone patterns.
sparse-checkout-cone-mode: false
sparse-checkout: |
mock-api
api/swagger.json
/mock-api/
/api/swagger.json

- name: Setup pnpm
uses: pnpm/action-setup@v4
Expand Down Expand Up @@ -64,4 +70,4 @@ jobs:

- name: Security audit
working-directory: ./cli
run: pnpm audit --audit-level moderate --ignore-registry-errors
run: pnpm audit --audit-level moderate
12 changes: 12 additions & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ jobs:

- run: pnpm install --frozen-lockfile

# Prod publishes land on the npm `latest` tag and must only ever come
# from the `production` branch (release-please's prod target branch).
# Without this guard, workflow_dispatch could publish any ref whose
# version lacks a -beta suffix as `latest`, bypassing release-please.
# workflow_call is unaffected: release-please.yml only requests a prod
# release on pushes to `production`, so github.ref_name matches there.
- name: Enforce production branch for prod releases
if: ${{ inputs.release_type == 'prod' && github.ref_name != 'production' }}
run: |
echo "Error: prod releases may only be published from the 'production' branch (got '${{ github.ref_name }}')"
exit 1

# Version validation for production release
- name: Validate Production Version
if: ${{ inputs.release_type == 'prod' }}
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upl

**Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically.

**Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) spins up a loopback HTTP server on 127.0.0.1, generates a `state` token, opens `<frontend>/cli-login?state=...&port=...`, and waits for the browser to redirect back with the encoded Supabase session. The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`.
**Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `<frontend>/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs the session to `POST /cli-login/handoff`; the API verifies `sha256(verifier) === challenge` and returns the session. After claiming, the CLI fetches `/me/orgs` and prompts for an org (the same picker `dcd switch-org` uses). The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`.

**Cross-repo auth surface.** The dcd API's `ApiKeyGuard` accepts either `x-app-api-key` (existing) or `Authorization: Bearer <jwt>` + `x-dcd-org: <id>`. For Bearer it verifies the JWT, checks `user_org_profile` membership, and injects the org's api_key back into the request headers so existing `@Headers(APP_API_KEY_HEADER)` controller code keeps working unchanged. `dcd switch-org` calls `GET /me/orgs`, a JWT-only endpoint at `../dcd/api/src/apps/me/me.controller.ts`.

**Telemetry.** `src/services/telemetry.service.ts` ships lifecycle (`command started` / `command completed` / `command failed`) and error events to the dcd API's `/cli/logs` proxy → Axiom `cli-dev` / `cli-prod`. Wired in at three points: `src/index.ts` wraps `runMain` to record start/success/failure; `src/utils/auth.ts` calls `telemetry.configure({ auth })` from `resolveAuth` so the token never has to be re-derived; `src/utils/cli.ts` `logger.error` calls `telemetry.flushSync()` (which shells out to `curl` because `process.exit` bypasses `beforeExit`) before exiting. Unauthenticated invocations (`--help`, `--version`, `dcd login` pre-success) buffer in memory and drop on exit — by design, since there's no identity to attach. Opt out per-invocation with `DCD_TELEMETRY_DISABLED=1`.
**Telemetry.** `src/services/telemetry.service.ts` ships lifecycle (`command started` / `command completed` / `command failed`) and error events to the dcd API's `/cli/logs` proxy → Axiom `cli-dev` / `cli-prod`. Wired in at three points: `src/index.ts` replicates citty's `runMain` (which would otherwise swallow errors and exit 1) to record start/success/failure and honor `CliError.exitCode`; `src/utils/auth.ts` calls `telemetry.configure({ auth })` from `resolveAuth` so the token never has to be re-derived; `src/utils/cli.ts` `logger.error` calls `telemetry.flushSync()` (which shells out to `curl` because `process.exit` bypasses `beforeExit`) before exiting. Unauthenticated invocations (`--help`, `--version`, `dcd login` pre-success) buffer in memory and drop on exit — by design, since there's no identity to attach. Opt out per-invocation with `DCD_TELEMETRY_DISABLED=1`.
1 change: 0 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ module.exports = tseslint.config(
'dist/**',
'node_modules/**',
'src/types/generated/**',
'src/types/schema.types.ts',
],
},
js.configs.recommended,
Expand Down
26 changes: 21 additions & 5 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@

$ErrorActionPreference = 'Stop'

# Windows PowerShell 5.x defaults to TLS 1.0/1.1, which modern hosts reject.
# PowerShell 6+ negotiates TLS correctly on its own, so only patch 5.x.
if ($PSVersionTable.PSVersion.Major -lt 6) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
}

$DownloadBase = if ($env:DCD_DOWNLOAD_BASE) { $env:DCD_DOWNLOAD_BASE } else { 'https://get.devicecloud.dev' }
$InstallDir = if ($env:DCD_INSTALL_DIR) { $env:DCD_INSTALL_DIR } else { Join-Path $env:USERPROFILE '.dcd\bin' }

Expand Down Expand Up @@ -62,11 +68,21 @@ try {
}

# --- PATH update (user scope) ---
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$pathParts = ($userPath -split ';') | Where-Object { $_ -ne '' }
if ($pathParts -notcontains $InstallDir) {
$newPath = (@($InstallDir) + $pathParts) -join ';'
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
# Read the raw, unexpanded registry value (REG_EXPAND_SZ entries such as
# %USERPROFILE% must survive the round-trip; [Environment]::GetEnvironmentVariable
# would expand and freeze them). Append the install dir rather than rewriting
# the whole value, and skip entirely if it is already present.
$regKey = Get-Item -Path 'HKCU:\Environment'
$rawPath = [string]$regKey.GetValue(
'Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames
)
$normalizedDir = $InstallDir.TrimEnd('\')
$alreadyOnPath = @(
($rawPath -split ';') | Where-Object { $_ -and ($_.TrimEnd('\') -ieq $normalizedDir) }
).Count -gt 0
if (-not $alreadyOnPath) {
$newPath = if ($rawPath -eq '') { $InstallDir } else { $rawPath.TrimEnd(';') + ';' + $InstallDir }
Set-ItemProperty -Path 'HKCU:\Environment' -Name 'Path' -Value $newPath -Type ExpandString
Write-Host ''
Write-Host "Installed dcd $version to $InstallDir\dcd.exe"
Write-Host "Added $InstallDir to your user PATH. Open a new terminal to pick it up."
Expand Down
185 changes: 96 additions & 89 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
# DCD_VERSION Pin a specific version (default: latest)
# DCD_INSTALL_DIR Override install location (default: $HOME/.dcd/bin)
# DCD_DOWNLOAD_BASE Override the download host (default: https://get.devicecloud.dev)
#
# The whole script is wrapped in main() and only invoked on the last line, so
# a truncated download (curl | sh executes as it streams) runs nothing at all.

set -eu

DOWNLOAD_BASE="${DCD_DOWNLOAD_BASE:-https://get.devicecloud.dev}"
INSTALL_DIR="${DCD_INSTALL_DIR:-$HOME/.dcd/bin}"

err() {
printf 'error: %s\n' "$1" >&2
exit 1
Expand All @@ -23,89 +23,96 @@ info() {
printf '%s\n' "$1"
}

# --- detect platform ---
os=$(uname -s)
case "$os" in
Darwin) os_id=darwin ;;
Linux) os_id=linux ;;
*) err "Unsupported OS: $os. Try the Windows installer (install.ps1)." ;;
esac

arch=$(uname -m)
case "$arch" in
arm64|aarch64) arch_id=arm64 ;;
x86_64|amd64) arch_id=x64 ;;
*) err "Unsupported architecture: $arch" ;;
esac

asset="dcd-${os_id}-${arch_id}"

# --- resolve version ---
if [ -n "${DCD_VERSION:-}" ]; then
version="$DCD_VERSION"
else
info "Resolving latest version..."
# /latest.json returns { "version": "5.1.0", ... }
version=$(
curl -fsSL "$DOWNLOAD_BASE/latest.json" \
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -n1
)
[ -z "$version" ] && err "Could not resolve latest version from $DOWNLOAD_BASE/latest.json"
fi

url="$DOWNLOAD_BASE/download/${version}/${asset}"
sums_url="$DOWNLOAD_BASE/download/${version}/SHA256SUMS"

info "Installing dcd ${version} (${os_id}-${arch_id})"
info " from: $url"
info " to: $INSTALL_DIR/dcd"

# --- download ---
mkdir -p "$INSTALL_DIR"
tmp=$(mktemp "${TMPDIR:-/tmp}/dcd-XXXXXX")
trap 'rm -f "$tmp" "$tmp.sums"' EXIT
curl -fSL --progress-bar "$url" -o "$tmp" \
|| err "Download failed: $url"

# --- verify checksum ---
curl -fsSL "$sums_url" -o "$tmp.sums" \
|| err "Could not fetch checksums: $sums_url"
expected=$(grep -F " ${asset}" "$tmp.sums" | awk '{print $1}' | head -n1)
[ -z "$expected" ] && err "SHA256SUMS has no entry for $asset"

if command -v sha256sum >/dev/null 2>&1; then
actual=$(sha256sum "$tmp" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
actual=$(shasum -a 256 "$tmp" | awk '{print $1}')
else
err "Need sha256sum or shasum to verify download"
fi

if [ "$expected" != "$actual" ]; then
err "Checksum mismatch for $asset: expected $expected, got $actual"
fi

# --- install ---
chmod +x "$tmp"
mv "$tmp" "$INSTALL_DIR/dcd"
trap - EXIT # tmp has been moved; nothing to clean up

# --- PATH hint ---
case ":$PATH:" in
*":$INSTALL_DIR:"*)
info ""
info "✓ Installed: $($INSTALL_DIR/dcd --version 2>/dev/null || echo "$version")"
info " Try: dcd --help"
;;
*)
info ""
info "✓ Installed dcd $version to $INSTALL_DIR/dcd"
info ""
info " $INSTALL_DIR is not on your PATH. Add this to your shell rc:"
info " export PATH=\"$INSTALL_DIR:\$PATH\""
info ""
info " Then restart your shell, or run:"
info " export PATH=\"$INSTALL_DIR:\$PATH\""
;;
esac
main() {
DOWNLOAD_BASE="${DCD_DOWNLOAD_BASE:-https://get.devicecloud.dev}"
INSTALL_DIR="${DCD_INSTALL_DIR:-$HOME/.dcd/bin}"

# --- detect platform ---
os=$(uname -s)
case "$os" in
Darwin) os_id=darwin ;;
Linux) os_id=linux ;;
*) err "Unsupported OS: $os. Try the Windows installer (install.ps1)." ;;
esac

arch=$(uname -m)
case "$arch" in
arm64|aarch64) arch_id=arm64 ;;
x86_64|amd64) arch_id=x64 ;;
*) err "Unsupported architecture: $arch" ;;
esac

asset="dcd-${os_id}-${arch_id}"

# --- resolve version ---
if [ -n "${DCD_VERSION:-}" ]; then
version="$DCD_VERSION"
else
info "Resolving latest version..."
# /latest.json returns { "version": "5.1.0", ... }
version=$(
curl -fsSL "$DOWNLOAD_BASE/latest.json" \
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -n1
)
[ -z "$version" ] && err "Could not resolve latest version from $DOWNLOAD_BASE/latest.json"
fi

url="$DOWNLOAD_BASE/download/${version}/${asset}"
sums_url="$DOWNLOAD_BASE/download/${version}/SHA256SUMS"

info "Installing dcd ${version} (${os_id}-${arch_id})"
info " from: $url"
info " to: $INSTALL_DIR/dcd"

# --- download ---
mkdir -p "$INSTALL_DIR"
tmp=$(mktemp "${TMPDIR:-/tmp}/dcd-XXXXXX")
trap 'rm -f "$tmp" "$tmp.sums"' EXIT
curl -fSL --progress-bar "$url" -o "$tmp" \
|| err "Download failed: $url"

# --- verify checksum ---
curl -fsSL "$sums_url" -o "$tmp.sums" \
|| err "Could not fetch checksums: $sums_url"
expected=$(grep -F " ${asset}" "$tmp.sums" | awk '{print $1}' | head -n1)
[ -z "$expected" ] && err "SHA256SUMS has no entry for $asset"

if command -v sha256sum >/dev/null 2>&1; then
actual=$(sha256sum "$tmp" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
actual=$(shasum -a 256 "$tmp" | awk '{print $1}')
else
err "Need sha256sum or shasum to verify download"
fi

if [ "$expected" != "$actual" ]; then
err "Checksum mismatch for $asset: expected $expected, got $actual"
fi

# --- install ---
chmod +x "$tmp"
mv "$tmp" "$INSTALL_DIR/dcd"
trap - EXIT # tmp has been moved; nothing to clean up

# --- PATH hint ---
case ":$PATH:" in
*":$INSTALL_DIR:"*)
info ""
info "✓ Installed: $($INSTALL_DIR/dcd --version 2>/dev/null || echo "$version")"
info " Try: dcd --help"
;;
*)
info ""
info "✓ Installed dcd $version to $INSTALL_DIR/dcd"
info ""
info " $INSTALL_DIR is not on your PATH. Add this to your shell rc:"
info " export PATH=\"$INSTALL_DIR:\$PATH\""
info ""
info " Then restart your shell, or run:"
info " export PATH=\"$INSTALL_DIR:\$PATH\""
;;
esac
}

main "$@"
Loading
Loading