Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/nightly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ jobs:
VERSION="nightly-$(date -u +%Y-%m-%d)"
PKG="github.com/NodeOps-app/createos-cli/internal/pkg/version"
CFG="github.com/NodeOps-app/createos-cli/internal/config"
TEL="github.com/NodeOps-app/createos-cli/internal/telemetry"
COMMIT="${{ github.sha }}"
LDFLAGS="-s -w -X ${PKG}.Version=${VERSION} -X ${PKG}.Channel=nightly -X ${PKG}.Commit=${COMMIT} -X ${CFG}.OAuthClientID=${{ secrets.OAUTH_CLIENT_ID }}"
LDFLAGS="-s -w -X ${PKG}.Version=${VERSION} -X ${PKG}.Channel=nightly -X ${PKG}.Commit=${COMMIT} -X ${CFG}.OAuthClientID=${{ secrets.OAUTH_CLIENT_ID }} -X ${TEL}.PostHogAPIKey=${{ secrets.POSTHOG_API_KEY }} -X ${TEL}.PostHogHost=https://us.i.posthog.com"
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY="createos-${{ matrix.goos }}-${{ matrix.goarch }}.exe"
else
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ jobs:
VERSION="${{ github.ref_name }}"
PKG="github.com/NodeOps-app/createos-cli/internal/pkg/version"
CFG="github.com/NodeOps-app/createos-cli/internal/config"
TEL="github.com/NodeOps-app/createos-cli/internal/telemetry"
COMMIT="${{ github.sha }}"
LDFLAGS="-s -w -X ${PKG}.Version=${VERSION} -X ${PKG}.Channel=stable -X ${PKG}.Commit=${COMMIT} -X ${CFG}.OAuthClientID=${{ secrets.OAUTH_CLIENT_ID }}"
LDFLAGS="-s -w -X ${PKG}.Version=${VERSION} -X ${PKG}.Channel=stable -X ${PKG}.Commit=${COMMIT} -X ${CFG}.OAuthClientID=${{ secrets.OAUTH_CLIENT_ID }} -X ${TEL}.PostHogAPIKey=${{ secrets.POSTHOG_API_KEY }} -X ${TEL}.PostHogHost=https://us.i.posthog.com"
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY="createos-${{ matrix.goos }}-${{ matrix.goarch }}.exe"
else
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ When adding a new command:
1. Create the file under `cmd/<group>/`
2. Register it in the group's `NewXxxCommand()` subcommands slice
3. Add it to the manual list in `root.go` Action (the home screen) in alphabetical order
4. Telemetry is automatic — @docs/telemetry.md


## API Client

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,3 +467,12 @@ createos environments list --project <id> -o json
- OAuth session tokens are stored at `~/.createos/.oauth` with `600` permissions (readable only by you).
- Debug mode masks your token in output — only the first 6 and last 4 characters are shown.
- Never share your token or commit it to version control.

## Telemetry

The CLI sends usage telemetry (commands run, version, OS, error categories)
to help us improve the product. Before you run `createos login`, events are
anonymous and tied only to a one-way machine hash. After login, events are
associated with your account ID and may include the project ID for
project-scoped commands. No file paths, command output, or secrets are
collected. To disable, set `CREATEOS_DO_NOT_TRACK=1` in your environment.
151 changes: 135 additions & 16 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import (
"github.com/pterm/pterm"
"github.com/urfave/cli/v2"

"github.com/NodeOps-app/createos-cli/internal/api"
"github.com/NodeOps-app/createos-cli/internal/config"
internaloauth "github.com/NodeOps-app/createos-cli/internal/oauth"
"github.com/NodeOps-app/createos-cli/internal/telemetry"
"github.com/NodeOps-app/createos-cli/internal/terminal"
)

Expand All @@ -18,6 +20,90 @@ const (
oauthCallbackURI = "http://localhost:65341/callback"
)

// captureLoginFailure emits an auth_event with success=false. Called BEFORE
// identity is rebound, so distinct_id is still the machine_id_hash — that's
// expected and correct.
func captureLoginFailure(method string, err error) {
if telemetry.Default == nil {
return
}
reason := ""
if err != nil {
reason = err.Error()
}
telemetry.Default.Capture("auth_event", map[string]any{
"action": "login",
"method": method,
"success": false,
"failure_reason": reason,
})
}

// bindIdentityAndCapture runs the post-credential-save identity flow:
// fetch /me, persist Identity (preserving AliasedForUserID for same user),
// rebind telemetry distinct_id to user_id, then emit success auth_event.
// All identity fetching is best-effort — a failure here must NOT fail login.
//
// If /me fails OR SaveIdentity fails, we DELETE any pre-existing .identity
// file and skip RebindIdentity — otherwise stale identity from a previous
// account on this machine would mis-attribute the new login's telemetry.
func bindIdentityAndCapture(apiClient *api.APIClient, method string) {
identityFresh := false
var personProps map[string]any
if apiClient != nil {
if u, err := apiClient.GetUser(); err == nil && u != nil && u.ID != "" {
id := config.Identity{UserID: u.ID}
if existing, _ := config.LoadIdentity(); existing != nil && existing.UserID == u.ID {
id.AliasedForUserID = existing.AliasedForUserID
}
if saveErr := config.SaveIdentity(id); saveErr == nil {
identityFresh = true
personProps = userToPersonProps(u)
}
}
// Silent on /me failure — login still succeeds without user_id.
}

if !identityFresh {
// /me or SaveIdentity failed; drop any stale identity so that a later
// command does not attribute events to a previous user_id.
_ = config.DeleteIdentity()
}

if telemetry.Default != nil {
if identityFresh {
telemetry.Default.SetPersonProperties(personProps)
telemetry.Default.RebindIdentity()
}
telemetry.Default.Capture("auth_event", map[string]any{
"action": "login",
"method": method,
"success": true,
})
}
}

// userToPersonProps maps the API User struct to PostHog Person-level
// properties. These go ONLY to the Person record via Identify; they are
// NOT included in any Capture event payload. Pointer fields are dereferenced
// only when non-nil.
func userToPersonProps(u *api.User) map[string]any {
p := map[string]any{
"email": u.Email,
}
if u.DisplayName != nil && *u.DisplayName != "" {
p["name"] = *u.DisplayName
}
if u.Username != nil && *u.Username != "" {
p["username"] = *u.Username
}
if u.CreatedAt != "" {
// signup_date is immutable — Client uses $set_once for this key.
p["signup_date"] = u.CreatedAt
}
return p
}

// NewLoginCommand creates the login command.
func NewLoginCommand() *cli.Command {
return &cli.Command{
Expand All @@ -34,15 +120,21 @@ func NewLoginCommand() *cli.Command {
// --token flag: API key flow (works in both TTY and non-TTY)
if token := c.String("token"); token != "" {
if err := config.SaveToken(token); err != nil {
return fmt.Errorf("could not save your token: %w", err)
wrapped := fmt.Errorf("could not save your token: %w", err)
captureLoginFailure("token", wrapped)
return wrapped
}
client := api.NewClient(token, c.String("api-url"), c.Bool("debug"))
bindIdentityAndCapture(&client, "token")
pterm.Success.Println("You're signed in.")
return nil
}

// Non-interactive (CI/script): require --token flag
if !terminal.IsInteractive() {
return fmt.Errorf("non-interactive mode: use --token flag to sign in\n\n Example:\n createos login --token <your-api-token>")
err := fmt.Errorf("non-interactive mode: use --token flag to sign in\n\n Example:\n createos login --token <your-api-token>")
captureLoginFailure("token", err)
return err
}

// Interactive: let user choose auth method
Expand All @@ -54,30 +146,40 @@ func NewLoginCommand() *cli.Command {
WithOptions(options).
Show("How would you like to sign in?")
if err != nil {
return fmt.Errorf("sign in cancelled")
cancel := fmt.Errorf("sign in cancelled")
// Method unknown at this point — choice was never made. Pick
// "browser" since that's the default selection in the picker.
captureLoginFailure("browser", cancel)
return cancel
}

if selected == options[1] {
return loginWithAPIToken()
return loginWithAPIToken(c)
}
return loginWithBrowser()
return loginWithBrowser(c)
},
}
}

func loginWithAPIToken() error {
func loginWithAPIToken(c *cli.Context) error {
token, err := pterm.DefaultInteractiveTextInput.WithMask("*").Show("Paste your API token")
if err != nil || token == "" {
return fmt.Errorf("sign in cancelled")
cancel := fmt.Errorf("sign in cancelled")
captureLoginFailure("token", cancel)
return cancel
}
if err := config.SaveToken(token); err != nil {
return fmt.Errorf("could not save your token: %w", err)
wrapped := fmt.Errorf("could not save your token: %w", err)
captureLoginFailure("token", wrapped)
return wrapped
}
client := api.NewClient(token, c.String("api-url"), c.Bool("debug"))
bindIdentityAndCapture(&client, "token")
pterm.Success.Println("You're signed in.")
return nil
}

func loginWithBrowser() error {
func loginWithBrowser(c *cli.Context) error {
pterm.Info.Println("Starting browser login...")

port := oauthCallbackPort
Expand All @@ -86,17 +188,23 @@ func loginWithBrowser() error {
pterm.Info.Println("Fetching authorization server info...")
meta, err := internaloauth.FetchServerMetadata(config.OAuthIssuerURL)
if err != nil {
return fmt.Errorf("could not reach authorization server: %w", err)
wrapped := fmt.Errorf("could not reach authorization server: %w", err)
captureLoginFailure("browser", wrapped)
return wrapped
}

pkce, err := internaloauth.GeneratePKCE()
if err != nil {
return fmt.Errorf("could not generate security parameters: %w", err)
wrapped := fmt.Errorf("could not generate security parameters: %w", err)
captureLoginFailure("browser", wrapped)
return wrapped
}

state, err := internaloauth.GenerateState()
if err != nil {
return fmt.Errorf("could not generate state: %w", err)
wrapped := fmt.Errorf("could not generate state: %w", err)
captureLoginFailure("browser", wrapped)
return wrapped
}

authURL := internaloauth.BuildAuthURL(
Expand All @@ -120,11 +228,15 @@ func loginWithBrowser() error {

code, returnedState, err := internaloauth.StartCallbackServer(port)
if err != nil {
return fmt.Errorf("login was not completed: %w", err)
wrapped := fmt.Errorf("login was not completed: %w", err)
captureLoginFailure("browser", wrapped)
return wrapped
}

if returnedState != state {
return fmt.Errorf("invalid state parameter — possible CSRF attack, login aborted")
wrapped := fmt.Errorf("invalid state parameter — possible CSRF attack, login aborted")
captureLoginFailure("browser", wrapped)
return wrapped
}

pterm.Info.Println("Completing sign in...")
Expand All @@ -136,7 +248,9 @@ func loginWithBrowser() error {
pkce.Verifier,
)
if err != nil {
return fmt.Errorf("could not complete sign in: %w", err)
wrapped := fmt.Errorf("could not complete sign in: %w", err)
captureLoginFailure("browser", wrapped)
return wrapped
}

expiresAt := time.Now().Unix() + int64(tokenResp.ExpiresIn)
Expand All @@ -150,9 +264,14 @@ func loginWithBrowser() error {
TokenEndpoint: meta.TokenEndpoint,
}
if err := config.SaveOAuthSession(session); err != nil {
return fmt.Errorf("could not save your session: %w", err)
wrapped := fmt.Errorf("could not save your session: %w", err)
captureLoginFailure("browser", wrapped)
return wrapped
}

client := api.NewClientWithAccessToken(tokenResp.AccessToken, c.String("api-url"), c.Bool("debug"))
bindIdentityAndCapture(&client, "browser")

fmt.Println()
pterm.Success.Println("You're signed in.")
return nil
Expand Down
11 changes: 11 additions & 0 deletions cmd/auth/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/urfave/cli/v2"

"github.com/NodeOps-app/createos-cli/internal/config"
"github.com/NodeOps-app/createos-cli/internal/telemetry"
)

// NewLogoutCommand creates the logout command
Expand All @@ -24,6 +25,16 @@ func NewLogoutCommand() *cli.Command {
if err := config.DeleteOAuthSession(); err != nil {
return fmt.Errorf("could not clear your session: %w", err)
}
// Phase 4: clear identity binding so the next login can re-Alias
// against the post-login user_id without inheriting stale state.
_ = config.DeleteIdentity()

if telemetry.Default != nil {
telemetry.Default.Capture("auth_event", map[string]any{
"action": "logout",
"success": true,
})
}

fmt.Println("You've been signed out successfully.")
return nil
Expand Down
Loading