From 0838698a6cbdd9013a42c7713463d8f0dfc42262 Mon Sep 17 00:00:00 2001 From: "Customer.io Open Source Bot" Date: Wed, 17 Jun 2026 22:04:01 -0400 Subject: [PATCH] Add Customer.io CLI source CioCliPublicExport-RevId: dae0d31999eecc2f3f8d017776dec01541ecd088 --- .npm/postinstall.js | 69 +++++++++ cmd/domains.go | 11 +- cmd/domains_authenticate.go | 278 ++++-------------------------------- cmd/domains_test.go | 31 ++++ cmd/root.go | 2 + cmd/skills_hint.go | 48 +++++++ package.json | 4 + 7 files changed, 191 insertions(+), 252 deletions(-) create mode 100644 .npm/postinstall.js create mode 100644 cmd/skills_hint.go diff --git a/.npm/postinstall.js b/.npm/postinstall.js new file mode 100644 index 0000000..abff2e3 --- /dev/null +++ b/.npm/postinstall.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +const { spawn } = require("child_process"); +const { existsSync } = require("fs"); +const path = require("path"); + +const pkgRoot = path.join(__dirname, ".."); +const rootPkg = require(path.join(pkgRoot, "package.json")); +const optionalDependencies = rootPkg.optionalDependencies || {}; +const platforms = rootPkg.customerioCli?.platforms || []; +const platform = platforms.find( + (candidate) => candidate.os === process.platform && candidate.cpu === process.arch +); + +if (!platform) { + console.log("[cio postinstall] No matching platform found for", process.platform, process.arch); + process.exit(0); +} + +const platformPackage = Object.keys(optionalDependencies).find((packageName) => + packageName.endsWith(`-${platform.npm}`) +); + +if (!platformPackage) { + console.log("[cio postinstall] No platform package found for", platform.npm); + process.exit(0); +} + +let platformPackageRoot; +try { + platformPackageRoot = path.dirname( + require.resolve(`${platformPackage}/package.json`, { paths: [pkgRoot] }) + ); +} catch { + console.log("[cio postinstall] Could not resolve", platformPackage); + process.exit(0); +} + +const binPath = path.join(platformPackageRoot, "bin", `cio${platform.ext || ""}`); + +if (!existsSync(binPath)) { + console.log("[cio postinstall] Binary not found at", binPath); + process.exit(0); +} + +const isGlobal = process.env.npm_config_global === "true"; +const scopeFlag = isGlobal ? "--global" : "--project"; +const spawnOpts = { stdio: "inherit" }; + +// For project installs, npm runs postinstall with cwd inside node_modules. +// INIT_CWD is the directory where the user ran npm install (the project root). +if (!isGlobal && process.env.INIT_CWD) { + spawnOpts.cwd = process.env.INIT_CWD; +} + +console.log(`[cio postinstall] Running: cio skills install ${scopeFlag}`); +const child = spawn(binPath, ["skills", "install", scopeFlag], spawnOpts); + +child.on("error", (err) => { + console.log("[cio postinstall] Failed to run cio skills install:", err.message); + process.exit(0); +}); + +child.on("close", (code) => { + if (code !== 0) { + console.log("[cio postinstall] cio skills install exited with code", code); + } + process.exit(0); +}); diff --git a/cmd/domains.go b/cmd/domains.go index 7592e5d..16cfcff 100644 --- a/cmd/domains.go +++ b/cmd/domains.go @@ -97,11 +97,18 @@ func requireArg(name string) cobra.PositionalArgs { } } -// resolveUIBase returns the UI base URL from CIO_UI_URL or the default. -func resolveUIBase() string { +// resolveUIBase returns the app (UI) base URL for building handoff links. +// It mirrors the cio auth login handoff: an explicit CIO_UI_URL override wins, +// otherwise derive the app origin from the configured API base by stripping a +// leading us./eu. region label (e.g. us.api.example.com -> api.example.com), +// falling back to the production app. +func resolveUIBase(apiBaseURL string) string { if envURL := os.Getenv("CIO_UI_URL"); envURL != "" { return strings.TrimRight(envURL, "/") } + if origin := uiOriginFromAPIBase(apiBaseURL); origin != "" { + return origin + } return "https://fly.customer.io" } diff --git a/cmd/domains_authenticate.go b/cmd/domains_authenticate.go index 55cc8c9..637c890 100644 --- a/cmd/domains_authenticate.go +++ b/cmd/domains_authenticate.go @@ -1,14 +1,10 @@ package cmd import ( - "encoding/base64" "encoding/json" "fmt" "io" - "regexp" - "strconv" - "github.com/customerio/cli/internal/client" "github.com/customerio/cli/internal/output" "github.com/spf13/cobra" ) @@ -18,21 +14,18 @@ var domainsConfigureCmd = &cobra.Command{ Short: "Launch guided DNS setup for a domain via Entri", Long: `Open the Entri DNS setup flow in a browser for the specified domain. -Entri automatically configures your DNS records with your DNS provider — -no manual record entry needed. For manual setup, add the DNS records -shown by 'cio domains verify' at your DNS provider, then re-run verify. +Entri automatically configures your sending authentication DNS records (MX, +SPF, DKIM, DMARC) with your DNS provider — no manual record entry needed. For +manual setup, add the DNS records shown by 'cio domains verify' at your DNS +provider, then re-run verify. -Use --cname to also configure link tracking (CNAME + TXT records) in -the same Entri flow. This calls enable_auto_tls on the domain to generate -the required DNS records. Defaults to email. if no value is given. +To set up link tracking, use 'cio domains link_tracking configure'. The domain argument can be either a domain name (e.g. example.com) or a numeric domain ID. When a name is given, the CLI resolves it to an ID. Examples: - cio domains --env-id 456 configure example.com - cio domains --env-id 456 configure example.com --cname email - cio domains --env-id 456 configure example.com --cname track`, + cio domains --env-id 456 configure example.com`, Args: requireArg("domain"), RunE: runDomainsConfigure, } @@ -56,64 +49,20 @@ Examples: } func init() { - domainsConfigureCmd.Flags().String("cname", "", "Also configure link tracking; value is the subdomain (default: email)") domainsCmd.AddCommand(domainsConfigureCmd) domainsCmd.AddCommand(domainsVerifyCmd) } -// --- Entri types --- +// --- DNS setup (Entri) --- -type entriTokenResponse struct { - Token string `json:"token"` - ApplicationID string `json:"application_id"` - UserID string `json:"user_id"` +// dnsSetupLinkResponse is the response from the dns_setup_link endpoint: a +// short-lived handoff token the browser exchanges for the Entri config at the +// dns-setup page. +type dnsSetupLinkResponse struct { + HandoffToken string `json:"handoff_token"` + ExpiresIn int `json:"expires_in"` } -type entriDNSRecord struct { - Type string `json:"type"` - Host string `json:"host"` - Value string `json:"value"` - TTL int `json:"ttl"` - Priority *int `json:"priority,omitempty"` -} - -type entriConfig struct { - ApplicationID string `json:"applicationId"` - Token string `json:"token"` - UserID string `json:"userId"` - PrefilledDom string `json:"prefilledDomain"` - DNSRecords []entriDNSRecord `json:"dnsRecords"` - ValidateDMARC bool `json:"validateDmarc"` -} - -type domainGetResponse struct { - Domain struct { - ID json.Number `json:"id"` - Domain string `json:"domain"` - DNSRecords []domainDNSRecord `json:"dns_records"` - AutoTLSDomainID int `json:"auto_tls_domain_id"` - } `json:"domain"` - AutoTLSDomain *struct { - Config struct { - DNSRecords []domainDNSRecord `json:"dns_records"` - } `json:"config"` - } `json:"auto_tls_domain"` -} - -type domainDNSRecord struct { - Domain string `json:"domain"` - Type string `json:"type"` - Value string `json:"value"` - Name string `json:"name"` -} - -var mxPriorityRegex = regexp.MustCompile(`^(\d+)\s+(.+)$`) - -const ( - defaultTTL = 300 - defaultMXPriority = 10 -) - func runDomainsConfigure(cmd *cobra.Command, args []string) error { c := clientFromCmd(cmd) if c == nil { @@ -133,99 +82,35 @@ func runDomainsConfigure(cmd *cobra.Command, args []string) error { return err } - ctx := cmd.Context() - cnameFlag, _ := cmd.Flags().GetString("cname") - wantCNAME := cmd.Flags().Changed("cname") - - // Dry run: show what would happen without making mutating API calls. + // Dry run: show what would happen without making the API call. if GetDryRun(cmd) { - dryRun := map[string]any{ + return output.FprintJSON(cmd.OutOrStdout(), map[string]any{ "dry_run": true, "domain": dom.Name, "domain_id": dom.ID, "env_id": envID, - "will_fetch": []string{"GET domain details", "GET entri_token"}, - } - if wantCNAME { - sub := cnameFlag - if sub == "" { - sub = "email" - } - dryRun["will_mutate"] = []string{ - fmt.Sprintf("PUT domain cname=%s.%s", sub, dom.Name), - "POST enable_auto_tls", - } - } - return output.FprintJSON(cmd.OutOrStdout(), dryRun) - } - - // If --cname, enable auto TLS to get link tracking DNS records. - var linkTrackingRecords []domainDNSRecord - if wantCNAME { - records, err := ensureAutoTLS(cmd, c, envID, dom, cnameFlag) - if err != nil { - return err - } - linkTrackingRecords = records - } - - // Get the full domain details (DNS records). - domPath := fmt.Sprintf("/v1/environments/%s/domains/%s", envID, dom.ID) - domRaw, err := c.Do(ctx, "GET", domPath, nil, nil) - if err != nil { - return handleAPIError(err) - } - - var domResp domainGetResponse - if err := json.Unmarshal(domRaw, &domResp); err != nil { - output.PrintError(output.CodeGeneralError, "failed to parse domain response", nil) - return err + "will_fetch": []string{"POST dns_setup_link"}, + }) } - // Get Entri token. - tokenPath := fmt.Sprintf("/v1/environments/%s/domains/%s/entri_token?flow=domain_auth", envID, dom.ID) - tokenRaw, err := c.Do(ctx, "GET", tokenPath, nil, nil) + // Mint a short-lived DNS-setup handoff token. The browser exchanges it at + // the dns-setup page, which resolves the domain's provisioned records + // server-side — so the link stays short enough to survive a terminal and + // the records reflect completed provisioning rather than whatever existed + // when the link was minted. + linkPath := fmt.Sprintf("/v1/environments/%s/domains/%s/dns_setup_link?flow=domain_auth", envID, dom.ID) + raw, err := c.Do(cmd.Context(), "POST", linkPath, nil, nil) if err != nil { return handleAPIError(err) } - var tokenResp entriTokenResponse - if err := json.Unmarshal(tokenRaw, &tokenResp); err != nil { - output.PrintError(output.CodeGeneralError, "failed to parse entri token response", nil) - return err - } - - // Map DNS records to Entri format. - entriRecords := mapDNSRecordsToEntri(domResp.Domain.DNSRecords) - - // Append link tracking records (CNAME + TXT). - for _, r := range linkTrackingRecords { - entriRecords = append(entriRecords, entriDNSRecord{ - Type: r.Type, - Host: r.Domain, - Value: r.Value, - TTL: defaultTTL, - }) - } - - // Build config and encode as base64url. - cfg := entriConfig{ - ApplicationID: tokenResp.ApplicationID, - Token: tokenResp.Token, - UserID: tokenResp.UserID, - PrefilledDom: domResp.Domain.Domain, - DNSRecords: entriRecords, - ValidateDMARC: true, - } - - cfgJSON, err := json.Marshal(cfg) - if err != nil { - output.PrintError(output.CodeGeneralError, "failed to encode configuration", nil) + var resp dnsSetupLinkResponse + if err := json.Unmarshal(raw, &resp); err != nil { + output.PrintError(output.CodeGeneralError, "failed to parse dns setup link response", nil) return err } - encoded := base64.StdEncoding.EncodeToString(cfgJSON) - setupURL := fmt.Sprintf("%s/cli/dns-setup#%s", resolveUIBase(), encoded) + setupURL := fmt.Sprintf("%s/cli/dns-setup#%s", resolveUIBase(c.BaseURL()), resp.HandoffToken) // Human-readable output to stderr. fmt.Fprintf(cmd.ErrOrStderr(), "Open this URL to configure DNS for %s:\n\n %s\n\n", dom.Name, setupURL) @@ -238,113 +123,6 @@ func runDomainsConfigure(cmd *cobra.Command, args []string) error { }) } -// ensureAutoTLS enables auto TLS on the domain (if not already enabled) and -// returns the link tracking DNS records. -func ensureAutoTLS(cmd *cobra.Command, c *client.Client, envID string, dom domainRef, cnameSubdomain string) ([]domainDNSRecord, error) { - ctx := cmd.Context() - - // Set the cname subdomain before enabling auto TLS. - if cnameSubdomain == "" { - cnameSubdomain = "email" - } - if err := validateStringInput("cname subdomain", cnameSubdomain); err != nil { - return nil, fmt.Errorf("invalid --cname value: %w", err) - } - cnameFQDN := cnameSubdomain + "." + dom.Name - - updateBody, _ := json.Marshal(map[string]any{ - "cname": cnameFQDN, - }) - _, err := c.Do(ctx, "PUT", fmt.Sprintf("/v1/environments/%s/domains/%s", envID, dom.ID), nil, updateBody) - if err != nil { - return nil, handleAPIError(err) - } - - // Enable auto TLS (idempotent — 422 if already enabled). - _, err = c.Do(ctx, "POST", fmt.Sprintf("/v1/environments/%s/domains/%s/enable_auto_tls", envID, dom.ID), nil, nil) - if err != nil { - apiErr, ok := err.(*client.APIError) - if !ok || apiErr.StatusCode != 422 { - return nil, handleAPIError(err) - } - } - - // Fetch the domain to get auto TLS DNS records. - domPath := fmt.Sprintf("/v1/environments/%s/domains/%s", envID, dom.ID) - domRaw, err := c.Do(ctx, "GET", domPath, nil, nil) - if err != nil { - return nil, handleAPIError(err) - } - - var resp domainGetResponse - if err := json.Unmarshal(domRaw, &resp); err != nil { - return nil, fmt.Errorf("failed to parse domain response: %w", err) - } - - if resp.AutoTLSDomain == nil || len(resp.AutoTLSDomain.Config.DNSRecords) == 0 { - return nil, fmt.Errorf("auto TLS enabled but no DNS records found; the records may not be generated yet") - } - - return resp.AutoTLSDomain.Config.DNSRecords, nil -} - -// mapDNSRecordsToEntri converts the API's DNS records to Entri format. -func mapDNSRecordsToEntri(records []domainDNSRecord) []entriDNSRecord { - hasValidDMARC := false - for _, r := range records { - if r.Name == "DMARC" && r.Value != "" { - hasValidDMARC = true - break - } - } - - var filtered []domainDNSRecord - for _, r := range records { - if r.Name == "DMARC" && r.Value == "" { - continue - } - filtered = append(filtered, r) - } - if !hasValidDMARC { - filtered = append(filtered, domainDNSRecord{ - Name: "DMARC", - Type: "TXT", - Domain: "_dmarc", - Value: "v=DMARC1; p=none", - }) - } - - validNames := map[string]bool{"Domain": true, "SPF": true, "DKIM": true, "DMARC": true} - var result []entriDNSRecord - for _, r := range filtered { - if !validNames[r.Name] { - continue - } - - rec := entriDNSRecord{ - Type: r.Type, - Host: r.Domain, - Value: r.Value, - TTL: defaultTTL, - } - - if r.Name == "Domain" { - if m := mxPriorityRegex.FindStringSubmatch(r.Value); m != nil { - p, _ := strconv.Atoi(m[1]) - rec.Priority = &p - rec.Value = m[2] - } else { - p := defaultMXPriority - rec.Priority = &p - } - } - - result = append(result, rec) - } - - return result -} - // --- Verify command --- // domainVerifyResponse matches the shape returned by diff --git a/cmd/domains_test.go b/cmd/domains_test.go index 5a28298..b5d2185 100644 --- a/cmd/domains_test.go +++ b/cmd/domains_test.go @@ -51,6 +51,12 @@ func domainServer(t *testing.T) *httptest.Server { return } + // DNS setup link endpoint — returns a short-lived handoff token. + if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/dns_setup_link") { + _, _ = w.Write([]byte(`{"handoff_token":"test.handoff.token","expires_in":300}`)) + return + } + // DNS check endpoint — returns canned verification results. // Used by link_tracking verify; the "domain_auth" flow has moved to // POST /domains/:id/verify below. @@ -340,6 +346,13 @@ func TestDomains_AuthenticateConfigure_Automatic(t *testing.T) { if !strings.Contains(url, "/cli/dns-setup#") { t.Errorf("expected /cli/dns-setup# in stdout URL, got %v", url) } + // The fragment must be exactly the handoff token from dns_setup_link — no + // DNS records embedded — so the link stays short. The page resolves the + // records by exchanging this token. + _, frag, ok := strings.Cut(url, "#") + if !ok || frag != "test.handoff.token" { + t.Errorf("expected fragment to be the handoff token, got %q", frag) + } } func TestDomains_AuthenticateConfigure_CustomUIURL(t *testing.T) { @@ -365,6 +378,24 @@ func TestDomains_AuthenticateConfigure_CustomUIURL(t *testing.T) { } } +func TestResolveUIBase(t *testing.T) { + t.Setenv("CIO_UI_URL", "") + // The app origin is derived from the API base by stripping the us./eu. + // region label, so a regional API host maps to its app host. + if got := resolveUIBase("https://us.api.example.com"); got != "https://api.example.com" { + t.Errorf("expected https://api.example.com, got %q", got) + } + // Production us. host maps to the bare fly app host. + if got := resolveUIBase("https://us.fly.customer.io"); got != "https://fly.customer.io" { + t.Errorf("expected https://fly.customer.io, got %q", got) + } + // CIO_UI_URL overrides everything. + t.Setenv("CIO_UI_URL", "http://localhost:3000/") + if got := resolveUIBase("https://us.api.example.com"); got != "http://localhost:3000" { + t.Errorf("expected CIO_UI_URL override, got %q", got) + } +} + func TestDomains_Verify(t *testing.T) { cases := []struct { name string diff --git a/cmd/root.go b/cmd/root.go index e887152..637fd39 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -83,6 +83,8 @@ func init() { flags.Bool("page-all", false, "Auto-paginate, emit NDJSON") rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + maybeHintSkillsInstall(cmd) + // Select the active profile before any credential access so that auth // commands and credential lookups all resolve against the same profile. if profile, _ := cmd.Flags().GetString("profile"); profile != "" { diff --git a/cmd/skills_hint.go b/cmd/skills_hint.go new file mode 100644 index 0000000..7285a3e --- /dev/null +++ b/cmd/skills_hint.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +// maybeHintSkillsInstall prints a one-time hint to stderr when the bootstrap +// skill hasn't been installed yet. It only fires on an interactive terminal and +// skips commands where the hint would be noise (skills install itself, prime, +// help, completion). +func maybeHintSkillsInstall(cmd *cobra.Command) { + if !shouldHintSkillsInstall(cmd) { + return + } + fmt.Fprintln(cmd.ErrOrStderr(), "Tip: run \"cio skills install\" to set up AI agent skills for Claude Code and Codex.") +} + +func shouldHintSkillsInstall(cmd *cobra.Command) bool { + path := cmd.CommandPath() + if strings.HasPrefix(path, "cio skills") || + strings.HasPrefix(path, "cio prime") || + strings.HasPrefix(path, "cio help") || + strings.HasPrefix(path, "cio completion") { + return false + } + + stderr, ok := cmd.ErrOrStderr().(*os.File) + if !ok || !isTerminalInput(stderr.Fd()) { + return false + } + + home, err := os.UserHomeDir() + if err != nil { + return false + } + + skillPath := filepath.Join(home, ".claude", "skills", "cli", "SKILL.md") + if _, err := os.Stat(skillPath); err == nil { + return false + } + + return true +} diff --git a/package.json b/package.json index f8b1f56..bec2768 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,12 @@ "bin": { "cio": ".npm/run.js" }, + "scripts": { + "postinstall": "node .npm/postinstall.js" + }, "files": [ ".npm/run.js", + ".npm/postinstall.js", "LICENSE", "README.md" ],