diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 7eb53fc..746b935 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,23 @@ 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 +46,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" }, @@ -82,11 +109,86 @@ const authLogin = Command.make( .filter((t) => t.length > 0), ) : undefined; + + // Show agreement links before SSO — skip in non-interactive/CI environments + if (process.stdin.isTTY) { + yield* Effect.promise( + () => + new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + 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 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.tap((status) => + Effect.sync(() => + console.error("[DEBUG] onboarding status response:", JSON.stringify(status)), + ), + ), + Effect.catchAll((err) => { + console.error("[DEBUG] onboarding status error:", err.message); + onboardingError = err.message; + return Effect.succeed(null); + }), + ); + + // New user (PENDING) — complete onboarding via single API call + if (onboardingStatus?.status === "PENDING") { + let onboardingResult: { organizationId: string } | null = null; + 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 +197,11 @@ 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..c5fcfb8 --- /dev/null +++ b/src/core/onboarding.ts @@ -0,0 +1,181 @@ +import { 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); + + console.error("[DEBUG] onboarding/status url:", `${baseUrl}/api/v1/onboarding/status`); + 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) => { + console.error("[DEBUG] onboarding/status HTTP status:", res.status); + 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", + }; + }); +}