Skip to content
Merged
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
460 changes: 459 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"dist"
],
"scripts": {
"build": "tsc",
"build": "tsc && tsc-alias",
"dev": "tsx src/cli/index.ts",
"start": "node dist/cli/index.js",
"clean": "rm -rf dist",
Expand All @@ -37,6 +37,7 @@
"@clack/prompts": "^0.11.0",
"chalk": "^5.6.2",
"commander": "^12.1.0",
"p-wait-for": "^6.0.0",
"zod": "^4.3.5"
},
"devDependencies": {
Expand All @@ -45,10 +46,11 @@
"@typescript-eslint/parser": "^8.51.0",
"eslint": "^9.39.2",
"eslint-plugin-import": "^2.32.0",
"tsc-alias": "^1.8.16",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
}
}
108 changes: 91 additions & 17 deletions src/cli/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,102 @@
import { Command } from "commander";
import { tasks } from "@clack/prompts";
import { writeAuth } from "../../../core/config/auth.js";
import { runCommand } from "../../utils/index.js";
import { log } from "@clack/prompts";
import pWaitFor from "p-wait-for";
import { writeAuth } from "@config/auth.js";
import {
generateDeviceCode,
getTokenFromDeviceCode,
type DeviceCodeResponse,
type TokenResponse,
} from "@api/auth";
import { runCommand, runTask } from "../../utils/index.js";

async function login(): Promise<void> {
await tasks([
async function generateAndDisplayDeviceCode(): Promise<DeviceCodeResponse> {
const deviceCodeResponse = await runTask(
"Generating device code...",
async () => {
return await generateDeviceCode();
},
{
title: "Logging you in",
task: async () => {
await writeAuth({
token: "stub-token-12345",
email: "valid@email.com",
name: "KfirStri",
});

return "Logged in as KfirStri";
successMessage: "Device code generated",
errorMessage: "Failed to generate device code",
}
);

log.info(
`Please visit: ${deviceCodeResponse.verificationUrl}\n` +
`Enter your device code: ${deviceCodeResponse.userCode}`
);

return deviceCodeResponse;
}

async function waitForAuthentication(
deviceCode: string,
expiresIn: number
): Promise<TokenResponse> {
let tokenResponse: TokenResponse | null = null;

try {
await runTask(
"Waiting for you to complete authentication...",
async () => {
await pWaitFor(
async () => {
const result = await getTokenFromDeviceCode(deviceCode);
if (result !== null) {
tokenResponse = result;
return true;
}
return false;
},
{
interval: 2000,
timeout: expiresIn * 1000,
}
);
},
},
]);
{
successMessage: "Authentication completed!",
errorMessage: "Authentication failed",
}
);
} catch (error) {
if (error instanceof Error && error.message.includes("timed out")) {
throw new Error("Authentication timed out. Please try again.");
}
throw error;
}

if (!tokenResponse) {
throw new Error("Failed to retrieve authentication token.");
}

return tokenResponse;
}

async function saveAuthData(token: TokenResponse): Promise<void> {
await writeAuth({
token: token.token,
email: token.email,
name: token.name,
});
}

async function login(): Promise<void> {
const deviceCodeResponse = await generateAndDisplayDeviceCode();

const token = await waitForAuthentication(
deviceCodeResponse.deviceCode,
deviceCodeResponse.expiresIn
);

await saveAuthData(token);

log.success(`Logged in as ${token.name}`);
}

export const loginCommand = new Command("login")
.description("Authenticate with Base44")
.action(async () => {
await runCommand(login);
});

14 changes: 3 additions & 11 deletions src/cli/commands/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
import { Command } from "commander";
import { log } from "@clack/prompts";
import { deleteAuth } from "../../../core/config/auth.js";
import { deleteAuth } from "@config/auth.js";
import { runCommand } from "../../utils/index.js";

async function logout(): Promise<void> {
try {
await deleteAuth();
log.info("Logged out successfully");
} catch (error) {
if (error instanceof Error) {
log.error(error.message);
} else {
log.error("Failed to logout");
}
}
await deleteAuth();
log.info("Logged out successfully");
}

export const logoutCommand = new Command("logout")
Expand Down
15 changes: 3 additions & 12 deletions src/cli/commands/auth/whoami.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
import { Command } from "commander";
import { log } from "@clack/prompts";
import { readAuth } from "../../../core/config/auth.js";
import { readAuth } from "@config/auth.js";
import { runCommand } from "../../utils/index.js";

async function whoami(): Promise<void> {
try {
const auth = await readAuth();
log.info(`Logged in as: ${auth.name} (${auth.email})`);
} catch (error) {
if (error instanceof Error) {
log.error(error.message);
} else {
log.error("Failed to read authentication data");
}
}
const auth = await readAuth();
log.info(`Logged in as: ${auth.name} (${auth.email})`);
}

export const whoamiCommand = new Command("whoami")
.description("Display current authenticated user")
.action(async () => {
await runCommand(whoami);
});

1 change: 1 addition & 0 deletions src/cli/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./packageVersion.js";
export * from "./runCommand.js";
export * from "./runTask.js";

25 changes: 21 additions & 4 deletions src/cli/utils/runCommand.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { intro } from "@clack/prompts";
import { intro, log } from "@clack/prompts";
import chalk from "chalk";
import { AuthApiError, AuthValidationError } from "@core/errors/index.js";

const base44Color = chalk.bgHex("#E86B3C");

Expand All @@ -9,8 +10,24 @@ const base44Color = chalk.bgHex("#E86B3C");
*
* @param commandFn - The async function to execute as the command
*/
export async function runCommand(commandFn: () => Promise<void>): Promise<void> {
export async function runCommand(
commandFn: () => Promise<void>
): Promise<void> {
intro(base44Color(" Base 44 "));
await commandFn();
}

try {
await commandFn();
} catch (e) {
if (e instanceof AuthValidationError) {
const issues = e.issues.map((i) => i.message).join(", ");
log.error(`Invalid response from server: ${issues}`);
} else if (e instanceof AuthApiError) {
log.error(e.message);
} else if (e instanceof Error) {
log.error(e.message);
} else {
log.error(String(e));
}
process.exit(1);
}
}
32 changes: 32 additions & 0 deletions src/cli/utils/runTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { spinner } from "@clack/prompts";

/**
* Wraps an async operation with automatic spinner management.
* The spinner is automatically started, and stopped on both success and error.
*
* @param startMessage - Message to show when spinner starts
* @param operation - The async operation to execute
* @param options - Optional configuration
* @returns The result of the operation
*/
export async function runTask<T>(
startMessage: string,
operation: () => Promise<T>,
options?: {
successMessage?: string;
errorMessage?: string;
}
): Promise<T> {
const s = spinner();
s.start(startMessage);

try {
const result = await operation();
s.stop(options?.successMessage || startMessage);
return result;
} catch (error) {
s.stop(options?.errorMessage || "Failed");
throw error;
}
}

105 changes: 105 additions & 0 deletions src/core/api/auth/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
DeviceCodeResponseSchema,
type DeviceCodeResponse,
TokenResponseSchema,
type TokenResponse,
} from "./schema.js";
import { AuthApiError, AuthValidationError } from "@core/errors/index.js";

async function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

const deviceCodeToTokenMap = new Map<
string,
{ startTime: number; readyAfter: number }
>();

export async function generateDeviceCode(): Promise<DeviceCodeResponse> {
try {
await delay(1000);

const deviceCode = `device-code-${Date.now()}`;

deviceCodeToTokenMap.set(deviceCode, {
startTime: Date.now(),
readyAfter: 5000,
});

const mockResponse: DeviceCodeResponse = {
deviceCode,
userCode: "ABCD-1234",
verificationUrl: "https://app.base44.com/verify",
expiresIn: 600,
};

const result = DeviceCodeResponseSchema.safeParse(mockResponse);
if (!result.success) {
throw new AuthValidationError(
"Invalid device code response from server",
result.error.issues.map((issue) => ({
message: issue.message,
path: issue.path.map(String),
}))
);
}

return result.data;
} catch (error) {
if (error instanceof AuthValidationError) {
throw error;
}
throw new AuthApiError(
"Failed to generate device code",
error instanceof Error ? error : new Error(String(error))
);
}
}

export async function getTokenFromDeviceCode(
deviceCode: string
): Promise<TokenResponse | null> {
try {
await delay(1000);

const deviceInfo = deviceCodeToTokenMap.get(deviceCode);

if (!deviceInfo) {
return null;
}

const elapsed = Date.now() - deviceInfo.startTime;

if (elapsed < deviceInfo.readyAfter) {
return null;
}

const mockResponse: TokenResponse = {
token: "mock-token-" + Date.now(),
email: "stam@lala.com",
name: "Test User",
};

const result = TokenResponseSchema.safeParse(mockResponse);
if (!result.success) {
throw new AuthValidationError(
"Invalid token response from server",
result.error.issues.map((issue) => ({
message: issue.message,
path: issue.path.map(String),
}))
);
}

deviceCodeToTokenMap.delete(deviceCode);
return result.data;
} catch (error) {
if (error instanceof AuthValidationError || error instanceof AuthApiError) {
throw error;
}
throw new AuthApiError(
"Failed to retrieve token from device code",
error instanceof Error ? error : new Error(String(error))
);
}
}
4 changes: 4 additions & 0 deletions src/core/api/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './client.js';
export * from './schema.js';
export { AuthApiError, AuthValidationError } from '@core/errors/index.js';

Loading