diff --git a/.changeset/devx-314-cli-onboarding.md b/.changeset/devx-314-cli-onboarding.md new file mode 100644 index 0000000..77813a7 --- /dev/null +++ b/.changeset/devx-314-cli-onboarding.md @@ -0,0 +1,10 @@ +--- +"@godaddy/cli": minor +--- + +Add terminal-based agreement acceptance and automated onboarding to `auth login` + +- Agreement prompt (ToS, Privacy Policy, Developer Agreement) is shown on stderr before completing onboarding — only for new users whose org is PENDING, never for returning users +- Onboarding completes automatically after OAuth via a single `POST /api/v1/onboarding/cli` call; no browser redirect to the portal required +- Added `--accept-agreements` flag for non-interactive/CI use; without it, non-TTY runs with a PENDING org emit a structured `AGREEMENTS_REQUIRED` error (`ok: false`, exit 1) +- `org_id` and `onboarding` status are included in the login result envelope diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 7eb53fc..e4d8fcc 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -1,3 +1,4 @@ +import readline from "node:readline"; import * as Command from "@effect/cli/Command"; import * as Options from "@effect/cli/Options"; import * as Effect from "effect/Effect"; @@ -7,9 +8,25 @@ import { authStatusEffect, } from "../../core/auth"; import { envGetEffect } from "../../core/environment"; +import { + checkOnboardingStatusEffect, + completeOnboardingEffect, +} from "../../core/onboarding"; import type { NextAction } from "../agent/types"; import { EnvelopeWriter } from "../services/envelope-writer"; +// --------------------------------------------------------------------------- +// Agreement URLs +// --------------------------------------------------------------------------- + +const AGREEMENT_URLS = { + tos: "https://developer.commerce.godaddy.com/legal/agreements/terms-of-use", + privacy: + "https://developer.commerce.godaddy.com/legal/agreements/privacy-policy", + developer: + "https://developer.commerce.godaddy.com/legal/agreements/developer-agreement", +}; + // --------------------------------------------------------------------------- // Colocated next_actions // --------------------------------------------------------------------------- @@ -31,6 +48,18 @@ const authLoginActions: NextAction[] = [ { command: "godaddy auth logout", description: "Logout" }, ]; +const authLoginOnboardingActions: NextAction[] = [ + { + command: "godaddy application init", + description: "Create your first application", + }, + { + command: "godaddy auth status", + description: "Verify current authentication status", + }, + { command: "godaddy auth logout", description: "Logout" }, +]; + const authLogoutActions: NextAction[] = [ { command: "godaddy auth login", description: "Authenticate again" }, { command: "godaddy auth status", description: "Check auth status" }, @@ -69,8 +98,14 @@ const authLogin = Command.make( ), Options.repeated, ), + acceptAgreements: Options.boolean("accept-agreements").pipe( + Options.withDescription( + "Accept GoDaddy Developer agreements non-interactively (required for CI/scripts)", + ), + Options.withDefault(false), + ), }, - ({ scope }) => + ({ scope, acceptAgreements }) => Effect.gen(function* () { const writer = yield* EnvelopeWriter; const additionalScopes = @@ -82,12 +117,101 @@ const authLogin = Command.make( .filter((t) => t.length > 0), ) : undefined; + const loginResult = yield* authLoginEffect({ additionalScopes }); - const environment = yield* envGetEffect().pipe( - Effect.map(String), - Effect.orElseSucceed(() => "unknown"), + const env = yield* envGetEffect().pipe( + Effect.orElseSucceed(() => "ote" as const), + ); + const environment = String(env); + + // Check onboarding status — non-fatal if the call fails + let onboardingError: string | undefined; + const onboardingStatus = yield* checkOnboardingStatusEffect().pipe( + Effect.catchAll((err) => { + onboardingError = err.message; + return Effect.succeed(null); + }), ); + // New user (PENDING) — collect agreement acceptance then complete onboarding + if (onboardingStatus?.status === "PENDING") { + // Non-interactive without explicit flag: refuse to accept agreements silently + if (!process.stdin.isTTY && !acceptAgreements) { + yield* writer.emitError( + "godaddy auth login", + { + code: "AGREEMENTS_REQUIRED", + message: + "Agreement acceptance is required to complete onboarding. Re-run interactively or pass --accept-agreements to confirm acceptance of the GoDaddy Developer terms.", + }, + "Run: godaddy auth login --accept-agreements", + [ + { + command: "godaddy auth login --accept-agreements", + description: + "Accept GoDaddy Developer agreements non-interactively", + }, + ], + ); + return; + } + + // TTY: show agreement prompt on stderr before recording acceptance + if (process.stdin.isTTY) { + yield* Effect.promise( + () => + new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + const prompt = [ + "", + "By continuing, you agree to the GoDaddy Developer terms:", + "", + ` Terms of Service: ${AGREEMENT_URLS.tos}`, + ` Privacy Policy: ${AGREEMENT_URLS.privacy}`, + ` Developer Agreement: ${AGREEMENT_URLS.developer}`, + "", + "Press Enter to accept and continue...", + ].join("\n"); + rl.question(prompt, () => { + rl.close(); + resolve(); + }); + rl.on("error", () => { + rl.close(); + resolve(); + }); + }), + ); + } + + const onboardingResult = yield* completeOnboardingEffect().pipe( + Effect.catchAll((err) => { + onboardingError = err.message; + return Effect.succeed(null); + }), + ); + + yield* writer.emitSuccess( + "godaddy auth login", + { + authenticated: loginResult.success, + environment, + expires_at: loginResult.expiresAt?.toISOString(), + scopes_requested: additionalScopes, + onboarding: onboardingResult ? "complete" : "failed", + org_id: onboardingResult?.organizationId, + ...(onboardingError + ? { note: `Onboarding error: ${onboardingError}` } + : {}), + }, + authLoginOnboardingActions, + ); + return; + } + yield* writer.emitSuccess( "godaddy auth login", { @@ -95,6 +219,12 @@ const authLogin = Command.make( environment, expires_at: loginResult.expiresAt?.toISOString(), scopes_requested: additionalScopes, + onboarding: + onboardingStatus?.status === "ACTIVE" ? "complete" : undefined, + org_id: onboardingStatus?.orgId, + ...(onboardingStatus === null + ? { note: `Could not verify onboarding status: ${onboardingError}` } + : {}), }, authLoginActions, ); diff --git a/src/core/auth.ts b/src/core/auth.ts index e9f116c..ea7909c 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -45,6 +45,7 @@ export interface AuthResult { success: boolean; accessToken?: string; expiresAt?: Date; + onboardingPending?: boolean; } export interface AuthStatus { diff --git a/src/core/environment.ts b/src/core/environment.ts index 6975bab..eea59d9 100644 --- a/src/core/environment.ts +++ b/src/core/environment.ts @@ -268,6 +268,21 @@ export function getClientId(env: Environment): string { return clientIds[env]; } +/** + * Get the devx-core API base URL for the given environment. + * Can be overridden with DEVX_CORE_URL environment variable. + */ +export function getDevxCoreUrl(env: Environment): string { + if (process.env.DEVX_CORE_URL) return process.env.DEVX_CORE_URL; + + const urls: Record = { + ote: "https://api.developer.commerce.ote-godaddy.com", + prod: "https://api.developer.commerce.godaddy.com", + }; + + return urls[env]; +} + /** * Check if an action requires confirmation in the current environment */ diff --git a/src/core/onboarding.ts b/src/core/onboarding.ts new file mode 100644 index 0000000..539708a --- /dev/null +++ b/src/core/onboarding.ts @@ -0,0 +1,193 @@ +import type { FileSystem } from "@effect/platform/FileSystem"; +import * as Effect from "effect/Effect"; +import { AuthenticationError, ConfigurationError } from "../effect/errors"; +import type { Keychain } from "../effect/services/keychain"; +import { getTokenInfoEffect } from "./auth"; +import { envGetEffect, getDevxCoreUrl } from "./environment"; + +export interface OnboardingStatus { + orgId: string; + status: string; +} + +/** + * Check onboarding status for the authenticated user via devx-core. + * Auto-creates a PENDING org if none exists yet. + */ +export function checkOnboardingStatusEffect(): Effect.Effect< + OnboardingStatus, + ConfigurationError | AuthenticationError, + FileSystem | Keychain +> { + return Effect.gen(function* () { + const tokenInfo = yield* getTokenInfoEffect().pipe( + Effect.mapError( + (err) => + new ConfigurationError({ + message: `Failed to get token: ${err.message}`, + userMessage: "Could not check onboarding status.", + }), + ), + ); + + if (!tokenInfo) { + return yield* Effect.fail( + new AuthenticationError({ + message: "No token available for onboarding status check", + userMessage: "Not authenticated.", + }), + ); + } + + const env = yield* envGetEffect().pipe( + Effect.orElseSucceed(() => "ote" as const), + ); + const baseUrl = getDevxCoreUrl(env); + + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${baseUrl}/api/v1/onboarding/status`, { + method: "POST", + headers: { + Authorization: `Bearer ${tokenInfo.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }).then(async (res) => { + if (res.status === 401) { + throw new AuthenticationError({ + message: "Onboarding status check: unauthorized (401)", + userMessage: "Session expired. Run 'godaddy auth login' again.", + }); + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new ConfigurationError({ + message: `Onboarding status check failed: HTTP ${res.status} ${body}`, + userMessage: "Could not check onboarding status.", + }); + } + return res.json(); + }), + catch: (err) => { + if ( + err instanceof AuthenticationError || + err instanceof ConfigurationError + ) + return err; + return new ConfigurationError({ + message: `Onboarding status check failed: ${err}`, + userMessage: "Could not check onboarding status.", + }); + }, + }); + + const envelope = response as { + success?: boolean; + data?: { id?: string; status?: string }; + }; + const data = + envelope.data ?? (response as { id?: string; status?: string }); + if (!data?.id || !data?.status) { + return yield* Effect.fail( + new ConfigurationError({ + message: "Unexpected onboarding status response shape", + userMessage: "Could not check onboarding status.", + }), + ); + } + + return { orgId: data.id, status: data.status }; + }); +} + +/** + * Complete CLI onboarding in one call — get/create org, accept all agreements, submit. + * Returns the organizationId and whether the org was already active. + */ +export function completeOnboardingEffect(): Effect.Effect< + { organizationId: string; alreadyActive: boolean }, + AuthenticationError | ConfigurationError, + FileSystem | Keychain +> { + return Effect.gen(function* () { + const tokenInfo = yield* getTokenInfoEffect().pipe( + Effect.mapError( + (err) => + new ConfigurationError({ + message: `Failed to get token: ${err.message}`, + userMessage: "Could not complete onboarding.", + }), + ), + ); + + if (!tokenInfo) { + return yield* Effect.fail( + new AuthenticationError({ + message: "No token available for onboarding", + userMessage: "Not authenticated.", + }), + ); + } + + const env = yield* envGetEffect().pipe( + Effect.orElseSucceed(() => "ote" as const), + ); + const baseUrl = getDevxCoreUrl(env); + + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${baseUrl}/api/v1/onboarding/cli`, { + method: "POST", + headers: { + Authorization: `Bearer ${tokenInfo.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }).then(async (res) => { + if (res.status === 401) { + throw new AuthenticationError({ + message: "CLI onboarding: unauthorized (401)", + userMessage: "Session expired. Run 'godaddy auth login' again.", + }); + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new ConfigurationError({ + message: `CLI onboarding failed: HTTP ${res.status} ${body}`, + userMessage: "Could not complete onboarding.", + }); + } + return res.json(); + }), + catch: (err) => { + if ( + err instanceof AuthenticationError || + err instanceof ConfigurationError + ) + return err; + return new ConfigurationError({ + message: `CLI onboarding failed: ${err}`, + userMessage: "Could not complete onboarding.", + }); + }, + }); + + const data = ( + response as { data?: { organizationId?: string; status?: string } } + ).data; + if (!data?.organizationId) { + return yield* Effect.fail( + new ConfigurationError({ + message: "Unexpected CLI onboarding response", + userMessage: "Could not complete onboarding.", + }), + ); + } + + return { + organizationId: data.organizationId, + alreadyActive: data.status === "ACTIVE", + }; + }); +}