Skip to content

Commit f658df2

Browse files
login changes
1 parent 7f86775 commit f658df2

File tree

10 files changed

+448
-11
lines changed

10 files changed

+448
-11
lines changed

src/cli.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import MaestroOptions, {
1212
ReportFormat,
1313
} from './models/maestro_options';
1414
import Maestro from './providers/maestro';
15+
import Login from './providers/login';
1516

1617
const program = new Command();
1718

@@ -142,10 +143,7 @@ const maestroCommand = program
142143
'Maestro version to use (e.g., "2.0.10").',
143144
)
144145
// Execution mode
145-
.option(
146-
'-q, --quiet',
147-
'Quieter console output without progress updates.',
148-
)
146+
.option('-q, --quiet', 'Quieter console output without progress updates.')
149147
.option(
150148
'--async',
151149
'Start tests and exit immediately without waiting for results.',
@@ -256,4 +254,20 @@ program
256254
}
257255
});
258256

257+
program
258+
.command('login')
259+
.description('Authenticate with TestingBot via browser.')
260+
.action(async () => {
261+
try {
262+
const login = new Login();
263+
const result = await login.run();
264+
if (!result.success) {
265+
process.exitCode = 1;
266+
}
267+
} catch (err) {
268+
logger.error(`Login error: ${err instanceof Error ? err.message : err}`);
269+
process.exitCode = 1;
270+
}
271+
});
272+
259273
export default program;

src/providers/espresso.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ export default class Espresso {
122122
},
123123
);
124124

125+
// Check for version update notification
126+
const latestVersion = response.headers?.['x-testingbotctl-version'];
127+
utils.checkForUpdate(latestVersion);
128+
125129
const result = response.data;
126130
if (result.success === false) {
127131
throw new TestingBotError(`Running Espresso test failed`, {

src/providers/login.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import http from 'node:http';
2+
import { URL } from 'node:url';
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import os from 'node:os';
6+
import logger from '../logger';
7+
8+
const AUTH_URL = 'https://testingbot.com/auth';
9+
10+
export interface LoginResult {
11+
success: boolean;
12+
message: string;
13+
}
14+
15+
export default class Login {
16+
private server: http.Server | null = null;
17+
private port: number = 0;
18+
19+
public async run(): Promise<LoginResult> {
20+
try {
21+
// Start local server to receive callback
22+
this.port = await this.startServer();
23+
24+
// Open browser to auth URL
25+
const authUrl = `${AUTH_URL}?port=${this.port}&identifier=testingbotctl`;
26+
logger.info('Opening browser for authentication...');
27+
logger.info(
28+
`\nIf the browser does not open automatically, visit:\n\n ${authUrl}\n`,
29+
);
30+
31+
await this.openBrowser(authUrl);
32+
33+
// Wait for callback (handled by server)
34+
const credentials = await this.waitForCallback();
35+
36+
// Save credentials
37+
await this.saveCredentials(credentials.key, credentials.secret);
38+
39+
logger.info('Authentication successful!');
40+
logger.info(`Credentials saved to ~/.testingbot`);
41+
42+
return { success: true, message: 'Authentication successful' };
43+
} catch (error) {
44+
const message = error instanceof Error ? error.message : String(error);
45+
logger.error(`Authentication failed: ${message}`);
46+
return { success: false, message };
47+
} finally {
48+
this.stopServer();
49+
}
50+
}
51+
52+
private startServer(): Promise<number> {
53+
return new Promise((resolve, reject) => {
54+
this.server = http.createServer();
55+
56+
this.server.on('error', (err) => {
57+
reject(new Error(`Failed to start local server: ${err.message}`));
58+
});
59+
60+
// Listen on random available port
61+
this.server.listen(0, '127.0.0.1', () => {
62+
const address = this.server?.address();
63+
if (address && typeof address === 'object') {
64+
resolve(address.port);
65+
} else {
66+
reject(new Error('Failed to get server port'));
67+
}
68+
});
69+
});
70+
}
71+
72+
private stopServer(): void {
73+
if (this.server) {
74+
this.server.close();
75+
this.server = null;
76+
}
77+
}
78+
79+
private waitForCallback(): Promise<{ key: string; secret: string }> {
80+
return new Promise((resolve, reject) => {
81+
const timeout = setTimeout(
82+
() => {
83+
reject(new Error('Authentication timed out after 5 minutes'));
84+
},
85+
5 * 60 * 1000,
86+
);
87+
88+
this.server?.on('request', (req, res) => {
89+
if (!req.url) {
90+
res.writeHead(400);
91+
res.end('Bad request');
92+
return;
93+
}
94+
95+
const url = new URL(req.url, `http://127.0.0.1:${this.port}`);
96+
97+
if (url.pathname === '/callback') {
98+
const key = url.searchParams.get('key');
99+
const secret = url.searchParams.get('secret');
100+
const error = url.searchParams.get('error');
101+
102+
if (error) {
103+
clearTimeout(timeout);
104+
this.sendErrorResponse(res, error);
105+
reject(new Error(error));
106+
return;
107+
}
108+
109+
if (key && secret) {
110+
clearTimeout(timeout);
111+
this.sendSuccessResponse(res);
112+
resolve({ key, secret });
113+
} else {
114+
res.writeHead(400);
115+
res.end('Missing credentials');
116+
}
117+
} else {
118+
res.writeHead(404);
119+
res.end('Not found');
120+
}
121+
});
122+
});
123+
}
124+
125+
private sendSuccessResponse(res: http.ServerResponse): void {
126+
const html = `<!DOCTYPE html>
127+
<html>
128+
<head>
129+
<title>Authentication Successful</title>
130+
<style>
131+
body {
132+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
133+
background: #f5f5f5;
134+
min-height: 100vh;
135+
display: flex;
136+
align-items: center;
137+
justify-content: center;
138+
margin: 0;
139+
}
140+
.container {
141+
background: white;
142+
padding: 2rem;
143+
border-radius: 8px;
144+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
145+
max-width: 400px;
146+
text-align: center;
147+
}
148+
.icon {
149+
width: 64px;
150+
height: 64px;
151+
margin-bottom: 1rem;
152+
}
153+
h1 {
154+
color: #333;
155+
margin-bottom: 0.5rem;
156+
}
157+
p {
158+
color: #666;
159+
}
160+
</style>
161+
</head>
162+
<body>
163+
<div class="container">
164+
<svg class="icon" fill="none" stroke="#22c55e" viewBox="0 0 24 24">
165+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
166+
</svg>
167+
<h1>Authentication Successful!</h1>
168+
<p>You can close this window and return to the CLI.</p>
169+
</div>
170+
</body>
171+
</html>`;
172+
173+
res.writeHead(200, { 'Content-Type': 'text/html' });
174+
res.end(html);
175+
}
176+
177+
private sendErrorResponse(res: http.ServerResponse, error: string): void {
178+
const html = `<!DOCTYPE html>
179+
<html>
180+
<head>
181+
<title>Authentication Failed</title>
182+
<style>
183+
body {
184+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
185+
background: #f5f5f5;
186+
min-height: 100vh;
187+
display: flex;
188+
align-items: center;
189+
justify-content: center;
190+
margin: 0;
191+
}
192+
.container {
193+
background: white;
194+
padding: 2rem;
195+
border-radius: 8px;
196+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
197+
max-width: 400px;
198+
text-align: center;
199+
}
200+
.icon {
201+
width: 64px;
202+
height: 64px;
203+
margin-bottom: 1rem;
204+
}
205+
h1 {
206+
color: #333;
207+
margin-bottom: 0.5rem;
208+
}
209+
p {
210+
color: #666;
211+
}
212+
.error {
213+
color: #ef4444;
214+
}
215+
</style>
216+
</head>
217+
<body>
218+
<div class="container">
219+
<svg class="icon" fill="none" stroke="#ef4444" viewBox="0 0 24 24">
220+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
221+
</svg>
222+
<h1>Authentication Failed</h1>
223+
<p class="error">${error}</p>
224+
<p>Please try again or contact support.</p>
225+
</div>
226+
</body>
227+
</html>`;
228+
229+
res.writeHead(200, { 'Content-Type': 'text/html' });
230+
res.end(html);
231+
}
232+
233+
private async saveCredentials(key: string, secret: string): Promise<void> {
234+
const filePath = path.join(os.homedir(), '.testingbot');
235+
await fs.promises.writeFile(filePath, `${key}:${secret}`, { mode: 0o600 });
236+
}
237+
238+
private async openBrowser(url: string): Promise<void> {
239+
const { exec } = await import('node:child_process');
240+
const { promisify } = await import('node:util');
241+
const execAsync = promisify(exec);
242+
243+
const platform = process.platform;
244+
245+
try {
246+
if (platform === 'darwin') {
247+
await execAsync(`open "${url}"`);
248+
} else if (platform === 'win32') {
249+
await execAsync(`start "" "${url}"`);
250+
} else {
251+
// Linux and others
252+
await execAsync(`xdg-open "${url}"`);
253+
}
254+
} catch {
255+
// Browser open failed, user will need to open manually
256+
// Message already displayed in run()
257+
}
258+
}
259+
}

src/providers/maestro.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,10 @@ export default class Maestro {
465465
},
466466
);
467467

468+
// Check for version update notification
469+
const latestVersion = response.headers?.['x-testingbotctl-version'];
470+
utils.checkForUpdate(latestVersion);
471+
468472
const result = response.data;
469473
if (result.success === false) {
470474
throw new TestingBotError(`Running Maestro test failed`, {

src/providers/xcuitest.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ export default class XCUITest {
118118
},
119119
);
120120

121+
// Check for version update notification
122+
const latestVersion = response.headers?.['x-testingbotctl-version'];
123+
utils.checkForUpdate(latestVersion);
124+
121125
const result = response.data;
122126
if (result.success === false) {
123127
throw new TestingBotError(`Running XCUITest failed`, {

src/utils.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,56 @@
11
import packageJson from '../package.json';
2+
import logger from './logger';
3+
import colors from 'colors';
4+
5+
let versionCheckDisplayed = false;
6+
27
export default {
38
getUserAgent(): string {
49
return `TestingBot-CTL-${packageJson.version}`;
510
},
11+
12+
getCurrentVersion(): string {
13+
return packageJson.version;
14+
},
15+
16+
/**
17+
* Compare two semver version strings
18+
* Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
19+
*/
20+
compareVersions(v1: string, v2: string): number {
21+
const parts1 = v1.split('.').map(Number);
22+
const parts2 = v2.split('.').map(Number);
23+
24+
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
25+
const p1 = parts1[i] || 0;
26+
const p2 = parts2[i] || 0;
27+
if (p1 < p2) return -1;
28+
if (p1 > p2) return 1;
29+
}
30+
return 0;
31+
},
32+
33+
/**
34+
* Check if a newer version is available and display update notice
35+
*/
36+
checkForUpdate(latestVersion: string | undefined): void {
37+
if (!latestVersion || versionCheckDisplayed) {
38+
return;
39+
}
40+
41+
const currentVersion = this.getCurrentVersion();
42+
if (this.compareVersions(currentVersion, latestVersion) < 0) {
43+
versionCheckDisplayed = true;
44+
logger.warn(
45+
colors.yellow(
46+
`\n📦 A new version of testingbotctl is available: ${colors.green(latestVersion)} (current: ${currentVersion})`,
47+
),
48+
);
49+
logger.warn(
50+
colors.yellow(
51+
` Run ${colors.cyan('npm update -g testingbotctl')} to update.\n`,
52+
),
53+
);
54+
}
55+
},
656
};

0 commit comments

Comments
 (0)