# feat(control-plane): resolve account owners by github:handle

`24d24ef`→[main](/content/gh/entireio/cli/commits/main/index.html)·

toothbrush·3w ago·3 files·+82 added/-14 removed

project create --owner now accepts a github-qualified handle (e.g.
github:alice) for --owner-type=account, resolving it to the account ULID
via ResolveHandle — matching the org case, which already accepts a name.
A raw ULID still passes through unchanged for both owner types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

## Sessions

64b9771c3417View transcript

## Changes

3

- cmd/entire/cli
  - Mproject.go+16/-14
  - Mresolveref.go+31
  - Mresolveref_test.go+35

```  
39 unmodified lines
```

Use:   "create <name>",
    Short: "Create a project under an org or account",
    Long: "Creates a project owned by an org or an account. --owner is the " +
    "owning org (name or ULID) or account ULID, and --owner-type selects " +
    "which (org or account).",
    Example: "  # Project under an org (by name)\n" +
    "  entire project create widgets --owner acme --owner-type org\n\n" +
    "  # Project owned by an account\n" +
    "  entire project create widgets --owner 01J0... --owner-type account",
    "  # Project owned by an account (by handle)\n" +
    "  entire project create widgets --owner github:alice --owner-type account",
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        cmd.SilenceUsage = true

return runCoreJSON(cmd, func(ctx context.Context, c *coreapi.Client) (any, error) {
            ownerRef := ownerID
            // Only org owners have a friendly-name index; account owners
            // must be addressed by ULID.
            if ot == coreapi.CreateProjectInputBodyOwnerTypeOrg {
                resolved, err := resolveOrgRef(ctx, c, ownerID)
                if err != nil {
                    return nil, err
                }
                ownerRef = resolved
            }
            // Orgs are addressed by name, accounts by github:handle; both
            // also accept a raw ULID.
            var ownerRef string
            switch ot {
            case coreapi.CreateProjectInputBodyOwnerTypeOrg:
                ownerRef, err = resolveOrgRef(ctx, c, ownerID)
            case coreapi.CreateProjectInputBodyOwnerTypeAccount:
                ownerRef, err = resolveAccountRef(ctx, c, ownerID)
            }
            if err != nil {
                return nil, err
            }
            body := &coreapi.CreateProjectInputBody{
                Name:      args[0],
            }
        })
    },
}

cmd.Flags().StringVar(&ownerID, "owner", "", "owning org (name or ULID), or account ULID (required)")
cmd.Flags().StringVar(&ownerID, "owner", "", "owning org (name or ULID), or account (github:handle or ULID) (required)")
cmd.Flags().StringVar(&ownerType, "owner-type", "org", "owner kind: org or account")
cmd.Flags().StringVar(&region, "region", "", "jurisdiction slug (defaults to the server's home jurisdiction)")
markRequired(cmd, "owner")

```  
48 unmodified lines
```

// resolveAccountRef turns an account reference into its ULID. A ULID passes
// through unchanged; otherwise the ref is a provider-qualified handle (e.g.
// "github:alice") resolved via the control plane. We support github-backed
// user accounts today; other providers will resolve once they exist server-side.
func resolveAccountRef(ctx context.Context, c *coreapi.Client, ref string) (string, error) {
    if looksLikeULID(ref) {
        return ref, nil
    }
    provider, handle, err := parseQualifiedHandle(ref)
    if err != nil {
        return "", err
    }
    id, err := c.ResolveHandle(ctx, coreapi.ResolveHandleParams{Provider: provider, Handle: handle})
    if err != nil {
        return "", err
    }
    return id.AccountId, nil
}

// parseQualifiedHandle splits a provider-qualified handle like "github:alice"
// into its provider ("github") and handle ("alice"). Accounts are addressed by
// this friendly form; a value with no "provider:" prefix is rejected so the
// user gets a clear hint rather than a confusing lookup miss.
func parseQualifiedHandle(ref string) (provider, handle string, err error) {
    provider, handle, ok := strings.Cut(ref, ":")
    if !ok || provider == "" || handle == "" {
        return "", "", fmt.Errorf("account %q must be a qualified handle like \"github:alice\" (or a ULID)", ref)
    }
    return provider, handle, nil
}

// resolveProjectRef turns a project reference (ULID or name) into its ULID. A
// ULID is returned unchanged; a name is looked up via the server's exact-name
// filter (the same call `entire project list --name` uses).

```  
33 unmodified lines
```

func TestParseQualifiedHandle(t *testing.T) {
    t.Parallel()
    tests := []struct {
        in           string
        wantProvider string
        wantHandle   string
        wantErr      bool
    }{
        {in: "github:alice", wantProvider: "github", wantHandle: "alice"},
        {in: "github:alice:bob", wantProvider: "github", wantHandle: "alice:bob"}, // only first colon splits
        {in: "alice", wantErr: true}, // no provider prefix
        {in: "github:", wantErr: true}, // empty handle
        {in: ":alice", wantErr: true}, // empty provider
        {in: "", wantErr: true},
    }
    for _, tt := range tests {
        t.Run(tt.in, func(t *testing.T) {
            t.Parallel()
            provider, handle, err := parseQualifiedHandle(tt.in)
            if tt.wantErr {
                if err == nil {
                    t.Errorf("parseQualifiedHandle(%q) expected error", tt.in)
                }
                return
            }
            if err != nil {
                t.Fatalf("parseQualifiedHandle(%q): %v", tt.in, err)
            }
            if provider != tt.wantProvider || handle != tt.wantHandle {
                t.Errorf("parseQualifiedHandle(%q) = (%q, %q), want (%q, %q)", tt.in, provider, handle, tt.wantProvider, tt.wantHandle)
            }
        })
    }
}

func TestPickOrg(t *testing.T) {
    t.Parallel()
    orgs := []coreapi.Org{
