Skip to content

Commit ae34068

Browse files
authored
feat(login) - mock implementation (#5)
* add naive implementation of login in cli - no http calls yet * change the general command to exit with an error * bump package lock * refactor spinner management and errors
1 parent dd2ee80 commit ae34068

16 files changed

Lines changed: 776 additions & 48 deletions

File tree

package-lock.json

Lines changed: 459 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"dist"
1717
],
1818
"scripts": {
19-
"build": "tsc",
19+
"build": "tsc && tsc-alias",
2020
"dev": "tsx src/cli/index.ts",
2121
"start": "node dist/cli/index.js",
2222
"clean": "rm -rf dist",
@@ -37,6 +37,7 @@
3737
"@clack/prompts": "^0.11.0",
3838
"chalk": "^5.6.2",
3939
"commander": "^12.1.0",
40+
"p-wait-for": "^6.0.0",
4041
"zod": "^4.3.5"
4142
},
4243
"devDependencies": {
@@ -45,10 +46,11 @@
4546
"@typescript-eslint/parser": "^8.51.0",
4647
"eslint": "^9.39.2",
4748
"eslint-plugin-import": "^2.32.0",
49+
"tsc-alias": "^1.8.16",
4850
"tsx": "^4.19.2",
4951
"typescript": "^5.7.2"
5052
},
5153
"engines": {
52-
"node": ">=18.0.0"
54+
"node": ">=20.0.0"
5355
}
5456
}

src/cli/commands/auth/login.ts

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,102 @@
11
import { Command } from "commander";
2-
import { tasks } from "@clack/prompts";
3-
import { writeAuth } from "../../../core/config/auth.js";
4-
import { runCommand } from "../../utils/index.js";
2+
import { log } from "@clack/prompts";
3+
import pWaitFor from "p-wait-for";
4+
import { writeAuth } from "@config/auth.js";
5+
import {
6+
generateDeviceCode,
7+
getTokenFromDeviceCode,
8+
type DeviceCodeResponse,
9+
type TokenResponse,
10+
} from "@api/auth";
11+
import { runCommand, runTask } from "../../utils/index.js";
512

6-
async function login(): Promise<void> {
7-
await tasks([
13+
async function generateAndDisplayDeviceCode(): Promise<DeviceCodeResponse> {
14+
const deviceCodeResponse = await runTask(
15+
"Generating device code...",
16+
async () => {
17+
return await generateDeviceCode();
18+
},
819
{
9-
title: "Logging you in",
10-
task: async () => {
11-
await writeAuth({
12-
token: "stub-token-12345",
13-
email: "valid@email.com",
14-
name: "KfirStri",
15-
});
16-
17-
return "Logged in as KfirStri";
20+
successMessage: "Device code generated",
21+
errorMessage: "Failed to generate device code",
22+
}
23+
);
24+
25+
log.info(
26+
`Please visit: ${deviceCodeResponse.verificationUrl}\n` +
27+
`Enter your device code: ${deviceCodeResponse.userCode}`
28+
);
29+
30+
return deviceCodeResponse;
31+
}
32+
33+
async function waitForAuthentication(
34+
deviceCode: string,
35+
expiresIn: number
36+
): Promise<TokenResponse> {
37+
let tokenResponse: TokenResponse | null = null;
38+
39+
try {
40+
await runTask(
41+
"Waiting for you to complete authentication...",
42+
async () => {
43+
await pWaitFor(
44+
async () => {
45+
const result = await getTokenFromDeviceCode(deviceCode);
46+
if (result !== null) {
47+
tokenResponse = result;
48+
return true;
49+
}
50+
return false;
51+
},
52+
{
53+
interval: 2000,
54+
timeout: expiresIn * 1000,
55+
}
56+
);
1857
},
19-
},
20-
]);
58+
{
59+
successMessage: "Authentication completed!",
60+
errorMessage: "Authentication failed",
61+
}
62+
);
63+
} catch (error) {
64+
if (error instanceof Error && error.message.includes("timed out")) {
65+
throw new Error("Authentication timed out. Please try again.");
66+
}
67+
throw error;
68+
}
69+
70+
if (!tokenResponse) {
71+
throw new Error("Failed to retrieve authentication token.");
72+
}
73+
74+
return tokenResponse;
75+
}
76+
77+
async function saveAuthData(token: TokenResponse): Promise<void> {
78+
await writeAuth({
79+
token: token.token,
80+
email: token.email,
81+
name: token.name,
82+
});
83+
}
84+
85+
async function login(): Promise<void> {
86+
const deviceCodeResponse = await generateAndDisplayDeviceCode();
87+
88+
const token = await waitForAuthentication(
89+
deviceCodeResponse.deviceCode,
90+
deviceCodeResponse.expiresIn
91+
);
92+
93+
await saveAuthData(token);
94+
95+
log.success(`Logged in as ${token.name}`);
2196
}
2297

2398
export const loginCommand = new Command("login")
2499
.description("Authenticate with Base44")
25100
.action(async () => {
26101
await runCommand(login);
27102
});
28-

src/cli/commands/auth/logout.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,11 @@
11
import { Command } from "commander";
22
import { log } from "@clack/prompts";
3-
import { deleteAuth } from "../../../core/config/auth.js";
3+
import { deleteAuth } from "@config/auth.js";
44
import { runCommand } from "../../utils/index.js";
55

66
async function logout(): Promise<void> {
7-
try {
8-
await deleteAuth();
9-
log.info("Logged out successfully");
10-
} catch (error) {
11-
if (error instanceof Error) {
12-
log.error(error.message);
13-
} else {
14-
log.error("Failed to logout");
15-
}
16-
}
7+
await deleteAuth();
8+
log.info("Logged out successfully");
179
}
1810

1911
export const logoutCommand = new Command("logout")

src/cli/commands/auth/whoami.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,15 @@
11
import { Command } from "commander";
22
import { log } from "@clack/prompts";
3-
import { readAuth } from "../../../core/config/auth.js";
3+
import { readAuth } from "@config/auth.js";
44
import { runCommand } from "../../utils/index.js";
55

66
async function whoami(): Promise<void> {
7-
try {
8-
const auth = await readAuth();
9-
log.info(`Logged in as: ${auth.name} (${auth.email})`);
10-
} catch (error) {
11-
if (error instanceof Error) {
12-
log.error(error.message);
13-
} else {
14-
log.error("Failed to read authentication data");
15-
}
16-
}
7+
const auth = await readAuth();
8+
log.info(`Logged in as: ${auth.name} (${auth.email})`);
179
}
1810

1911
export const whoamiCommand = new Command("whoami")
2012
.description("Display current authenticated user")
2113
.action(async () => {
2214
await runCommand(whoami);
2315
});
24-

src/cli/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./packageVersion.js";
22
export * from "./runCommand.js";
3+
export * from "./runTask.js";
34

src/cli/utils/runCommand.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { intro } from "@clack/prompts";
1+
import { intro, log } from "@clack/prompts";
22
import chalk from "chalk";
3+
import { AuthApiError, AuthValidationError } from "@core/errors/index.js";
34

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

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

18+
try {
19+
await commandFn();
20+
} catch (e) {
21+
if (e instanceof AuthValidationError) {
22+
const issues = e.issues.map((i) => i.message).join(", ");
23+
log.error(`Invalid response from server: ${issues}`);
24+
} else if (e instanceof AuthApiError) {
25+
log.error(e.message);
26+
} else if (e instanceof Error) {
27+
log.error(e.message);
28+
} else {
29+
log.error(String(e));
30+
}
31+
process.exit(1);
32+
}
33+
}

src/cli/utils/runTask.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { spinner } from "@clack/prompts";
2+
3+
/**
4+
* Wraps an async operation with automatic spinner management.
5+
* The spinner is automatically started, and stopped on both success and error.
6+
*
7+
* @param startMessage - Message to show when spinner starts
8+
* @param operation - The async operation to execute
9+
* @param options - Optional configuration
10+
* @returns The result of the operation
11+
*/
12+
export async function runTask<T>(
13+
startMessage: string,
14+
operation: () => Promise<T>,
15+
options?: {
16+
successMessage?: string;
17+
errorMessage?: string;
18+
}
19+
): Promise<T> {
20+
const s = spinner();
21+
s.start(startMessage);
22+
23+
try {
24+
const result = await operation();
25+
s.stop(options?.successMessage || startMessage);
26+
return result;
27+
} catch (error) {
28+
s.stop(options?.errorMessage || "Failed");
29+
throw error;
30+
}
31+
}
32+

src/core/api/auth/client.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {
2+
DeviceCodeResponseSchema,
3+
type DeviceCodeResponse,
4+
TokenResponseSchema,
5+
type TokenResponse,
6+
} from "./schema.js";
7+
import { AuthApiError, AuthValidationError } from "@core/errors/index.js";
8+
9+
async function delay(ms: number): Promise<void> {
10+
return new Promise((resolve) => setTimeout(resolve, ms));
11+
}
12+
13+
const deviceCodeToTokenMap = new Map<
14+
string,
15+
{ startTime: number; readyAfter: number }
16+
>();
17+
18+
export async function generateDeviceCode(): Promise<DeviceCodeResponse> {
19+
try {
20+
await delay(1000);
21+
22+
const deviceCode = `device-code-${Date.now()}`;
23+
24+
deviceCodeToTokenMap.set(deviceCode, {
25+
startTime: Date.now(),
26+
readyAfter: 5000,
27+
});
28+
29+
const mockResponse: DeviceCodeResponse = {
30+
deviceCode,
31+
userCode: "ABCD-1234",
32+
verificationUrl: "https://app.base44.com/verify",
33+
expiresIn: 600,
34+
};
35+
36+
const result = DeviceCodeResponseSchema.safeParse(mockResponse);
37+
if (!result.success) {
38+
throw new AuthValidationError(
39+
"Invalid device code response from server",
40+
result.error.issues.map((issue) => ({
41+
message: issue.message,
42+
path: issue.path.map(String),
43+
}))
44+
);
45+
}
46+
47+
return result.data;
48+
} catch (error) {
49+
if (error instanceof AuthValidationError) {
50+
throw error;
51+
}
52+
throw new AuthApiError(
53+
"Failed to generate device code",
54+
error instanceof Error ? error : new Error(String(error))
55+
);
56+
}
57+
}
58+
59+
export async function getTokenFromDeviceCode(
60+
deviceCode: string
61+
): Promise<TokenResponse | null> {
62+
try {
63+
await delay(1000);
64+
65+
const deviceInfo = deviceCodeToTokenMap.get(deviceCode);
66+
67+
if (!deviceInfo) {
68+
return null;
69+
}
70+
71+
const elapsed = Date.now() - deviceInfo.startTime;
72+
73+
if (elapsed < deviceInfo.readyAfter) {
74+
return null;
75+
}
76+
77+
const mockResponse: TokenResponse = {
78+
token: "mock-token-" + Date.now(),
79+
email: "stam@lala.com",
80+
name: "Test User",
81+
};
82+
83+
const result = TokenResponseSchema.safeParse(mockResponse);
84+
if (!result.success) {
85+
throw new AuthValidationError(
86+
"Invalid token response from server",
87+
result.error.issues.map((issue) => ({
88+
message: issue.message,
89+
path: issue.path.map(String),
90+
}))
91+
);
92+
}
93+
94+
deviceCodeToTokenMap.delete(deviceCode);
95+
return result.data;
96+
} catch (error) {
97+
if (error instanceof AuthValidationError || error instanceof AuthApiError) {
98+
throw error;
99+
}
100+
throw new AuthApiError(
101+
"Failed to retrieve token from device code",
102+
error instanceof Error ? error : new Error(String(error))
103+
);
104+
}
105+
}

src/core/api/auth/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './client.js';
2+
export * from './schema.js';
3+
export { AuthApiError, AuthValidationError } from '@core/errors/index.js';
4+

0 commit comments

Comments
 (0)