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
113 changes: 110 additions & 3 deletions src/cli/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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" },
Expand Down Expand Up @@ -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<void>((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",
Expand All @@ -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,
);
Expand Down
1 change: 1 addition & 0 deletions src/core/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface AuthResult {
success: boolean;
accessToken?: string;
expiresAt?: Date;
onboardingPending?: boolean;
}

export interface AuthStatus {
Expand Down
15 changes: 15 additions & 0 deletions src/core/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Environment, string> = {
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
*/
Expand Down
181 changes: 181 additions & 0 deletions src/core/onboarding.ts
Original file line number Diff line number Diff line change
@@ -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",
};
});
}