feat: implement auth manager and auth command
This commit is contained in:
532
apps/cli/src/commands/auth.command.ts
Normal file
532
apps/cli/src/commands/auth.command.ts
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Auth command using Commander's native class pattern
|
||||||
|
* Extends Commander.Command for better integration with the framework
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
|
import ora from 'ora';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import {
|
||||||
|
AuthManager,
|
||||||
|
AuthenticationError,
|
||||||
|
type AuthCredentials
|
||||||
|
} from '@tm/core';
|
||||||
|
import * as ui from '../utils/ui.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result type from auth command
|
||||||
|
*/
|
||||||
|
export interface AuthResult {
|
||||||
|
success: boolean;
|
||||||
|
action: 'login' | 'logout' | 'status' | 'refresh';
|
||||||
|
credentials?: AuthCredentials;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthCommand extending Commander's Command class
|
||||||
|
* This is a thin presentation layer over @tm/core's AuthManager
|
||||||
|
*/
|
||||||
|
export class AuthCommand extends Command {
|
||||||
|
private authManager: AuthManager;
|
||||||
|
private lastResult?: AuthResult;
|
||||||
|
|
||||||
|
constructor(name?: string) {
|
||||||
|
super(name || 'auth');
|
||||||
|
|
||||||
|
// Initialize auth manager
|
||||||
|
this.authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
|
// Configure the command with subcommands
|
||||||
|
this.description('Manage authentication with tryhamster.com');
|
||||||
|
|
||||||
|
// Add subcommands
|
||||||
|
this.addLoginCommand();
|
||||||
|
this.addLogoutCommand();
|
||||||
|
this.addStatusCommand();
|
||||||
|
this.addRefreshCommand();
|
||||||
|
|
||||||
|
// Default action shows help
|
||||||
|
this.action(() => {
|
||||||
|
this.help();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add login subcommand
|
||||||
|
*/
|
||||||
|
private addLoginCommand(): void {
|
||||||
|
this.command('login')
|
||||||
|
.description('Authenticate with tryhamster.com')
|
||||||
|
.action(async () => {
|
||||||
|
await this.executeLogin();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add logout subcommand
|
||||||
|
*/
|
||||||
|
private addLogoutCommand(): void {
|
||||||
|
this.command('logout')
|
||||||
|
.description('Logout and clear credentials')
|
||||||
|
.action(async () => {
|
||||||
|
await this.executeLogout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add status subcommand
|
||||||
|
*/
|
||||||
|
private addStatusCommand(): void {
|
||||||
|
this.command('status')
|
||||||
|
.description('Display authentication status')
|
||||||
|
.action(async () => {
|
||||||
|
await this.executeStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add refresh subcommand
|
||||||
|
*/
|
||||||
|
private addRefreshCommand(): void {
|
||||||
|
this.command('refresh')
|
||||||
|
.description('Refresh authentication token')
|
||||||
|
.action(async () => {
|
||||||
|
await this.executeRefresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute login command
|
||||||
|
*/
|
||||||
|
private async executeLogin(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await this.performInteractiveAuth();
|
||||||
|
this.setLastResult(result);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.handleError(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute logout command
|
||||||
|
*/
|
||||||
|
private async executeLogout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = this.performLogout();
|
||||||
|
this.setLastResult(result);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.handleError(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute status command
|
||||||
|
*/
|
||||||
|
private async executeStatus(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = this.displayStatus();
|
||||||
|
this.setLastResult(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.handleError(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute refresh command
|
||||||
|
*/
|
||||||
|
private async executeRefresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await this.refreshToken();
|
||||||
|
this.setLastResult(result);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.handleError(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display authentication status
|
||||||
|
*/
|
||||||
|
private displayStatus(): AuthResult {
|
||||||
|
const credentials = this.authManager.getCredentials();
|
||||||
|
|
||||||
|
console.log(chalk.cyan('\n🔐 Authentication Status\n'));
|
||||||
|
|
||||||
|
if (credentials) {
|
||||||
|
console.log(chalk.green('✓ Authenticated'));
|
||||||
|
console.log(chalk.gray(` Email: ${credentials.email || 'N/A'}`));
|
||||||
|
console.log(chalk.gray(` User ID: ${credentials.userId}`));
|
||||||
|
console.log(
|
||||||
|
chalk.gray(` Token Type: ${credentials.tokenType || 'standard'}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (credentials.expiresAt) {
|
||||||
|
const expiresAt = new Date(credentials.expiresAt);
|
||||||
|
const now = new Date();
|
||||||
|
const hoursRemaining = Math.floor(
|
||||||
|
(expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hoursRemaining > 0) {
|
||||||
|
console.log(
|
||||||
|
chalk.gray(
|
||||||
|
` Expires: ${expiresAt.toLocaleString()} (${hoursRemaining} hours remaining)`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(` Token expired at: ${expiresAt.toLocaleString()}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(chalk.gray(' Expires: Never (API key)'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
chalk.gray(` Saved: ${new Date(credentials.savedAt).toLocaleString()}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'status',
|
||||||
|
credentials,
|
||||||
|
message: 'Authenticated'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.log(chalk.yellow('✗ Not authenticated'));
|
||||||
|
console.log(chalk.gray('\n Run "task-master auth login" to authenticate'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
action: 'status',
|
||||||
|
message: 'Not authenticated'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform logout
|
||||||
|
*/
|
||||||
|
private performLogout(): AuthResult {
|
||||||
|
try {
|
||||||
|
this.authManager.logout();
|
||||||
|
ui.displaySuccess('Successfully logged out');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'logout',
|
||||||
|
message: 'Successfully logged out'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = `Failed to logout: ${(error as Error).message}`;
|
||||||
|
ui.displayError(message);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
action: 'logout',
|
||||||
|
message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh authentication token
|
||||||
|
*/
|
||||||
|
private async refreshToken(): Promise<AuthResult> {
|
||||||
|
const spinner = ora('Refreshing authentication token...').start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentials = await this.authManager.refreshToken();
|
||||||
|
spinner.succeed('Token refreshed successfully');
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
chalk.gray(
|
||||||
|
` New expiration: ${credentials.expiresAt ? new Date(credentials.expiresAt).toLocaleString() : 'Never'}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'refresh',
|
||||||
|
credentials,
|
||||||
|
message: 'Token refreshed successfully'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
spinner.fail('Failed to refresh token');
|
||||||
|
|
||||||
|
if ((error as AuthenticationError).code === 'NO_REFRESH_TOKEN') {
|
||||||
|
ui.displayWarning(
|
||||||
|
'No refresh token available. Please re-authenticate.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ui.displayError(`Refresh failed: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
action: 'refresh',
|
||||||
|
message: `Failed to refresh: ${(error as Error).message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform interactive authentication
|
||||||
|
*/
|
||||||
|
private async performInteractiveAuth(): Promise<AuthResult> {
|
||||||
|
ui.displayBanner('Task Master Authentication');
|
||||||
|
|
||||||
|
// Check if already authenticated
|
||||||
|
if (this.authManager.isAuthenticated()) {
|
||||||
|
const { continueAuth } = await inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'continueAuth',
|
||||||
|
message:
|
||||||
|
'You are already authenticated. Do you want to re-authenticate?',
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!continueAuth) {
|
||||||
|
const credentials = this.authManager.getCredentials();
|
||||||
|
ui.displaySuccess('Using existing authentication');
|
||||||
|
|
||||||
|
if (credentials) {
|
||||||
|
console.log(chalk.gray(` Email: ${credentials.email || 'N/A'}`));
|
||||||
|
console.log(chalk.gray(` User ID: ${credentials.userId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'login',
|
||||||
|
credentials: credentials || undefined,
|
||||||
|
message: 'Using existing authentication'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Direct browser authentication - no menu needed
|
||||||
|
const credentials = await this.authenticateWithBrowser();
|
||||||
|
|
||||||
|
ui.displaySuccess('Authentication successful!');
|
||||||
|
console.log(
|
||||||
|
chalk.gray(` Logged in as: ${credentials.email || credentials.userId}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'login',
|
||||||
|
credentials,
|
||||||
|
message: 'Authentication successful'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAuthError(error as AuthenticationError);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
action: 'login',
|
||||||
|
message: `Authentication failed: ${(error as Error).message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate with browser using OAuth 2.0 with PKCE
|
||||||
|
*/
|
||||||
|
private async authenticateWithBrowser(): Promise<AuthCredentials> {
|
||||||
|
const spinner = ora('Starting authentication server...').start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start OAuth flow with PKCE (this starts the local server)
|
||||||
|
const authPromise = this.authManager.startOAuthFlow();
|
||||||
|
|
||||||
|
// Wait a moment for server to start
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get the authorization URL
|
||||||
|
const authUrl = this.authManager.getAuthorizationUrl();
|
||||||
|
|
||||||
|
if (!authUrl) {
|
||||||
|
throw new AuthenticationError(
|
||||||
|
'Failed to generate authorization URL',
|
||||||
|
'URL_GENERATION_FAILED'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner.stop();
|
||||||
|
|
||||||
|
// Display authentication instructions
|
||||||
|
console.log(chalk.blue.bold('\n🔐 Browser Authentication\n'));
|
||||||
|
console.log(chalk.white(' Opening your browser to authenticate...'));
|
||||||
|
console.log(chalk.gray(" If the browser doesn't open, visit:"));
|
||||||
|
console.log(chalk.cyan.underline(` ${authUrl}\n`));
|
||||||
|
|
||||||
|
// Open browser
|
||||||
|
this.openBrowser(authUrl);
|
||||||
|
|
||||||
|
// Wait for authentication with spinner
|
||||||
|
const authSpinner = ora({
|
||||||
|
text: 'Waiting for authentication...',
|
||||||
|
spinner: 'dots'
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentials = await authPromise;
|
||||||
|
authSpinner.succeed('Authentication successful!');
|
||||||
|
return credentials;
|
||||||
|
} catch (error) {
|
||||||
|
authSpinner.fail('Authentication failed');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (spinner.isSpinning) {
|
||||||
|
spinner.fail('Authentication failed');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open browser with the given URL
|
||||||
|
*/
|
||||||
|
private openBrowser(url: string): void {
|
||||||
|
const platform = process.platform;
|
||||||
|
let command: string;
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
command = `open "${url}"`;
|
||||||
|
} else if (platform === 'win32') {
|
||||||
|
command = `start "${url}"`;
|
||||||
|
} else {
|
||||||
|
command = `xdg-open "${url}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(command, (error) => {
|
||||||
|
if (error) {
|
||||||
|
// Silently fail - user can still manually open the URL
|
||||||
|
console.log(chalk.gray('\n (Could not automatically open browser)'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle authentication errors
|
||||||
|
*/
|
||||||
|
private handleAuthError(error: AuthenticationError): void {
|
||||||
|
console.error(chalk.red(`\n✗ ${error.message}`));
|
||||||
|
|
||||||
|
switch (error.code) {
|
||||||
|
case 'NETWORK_ERROR':
|
||||||
|
ui.displayWarning(
|
||||||
|
'Please check your internet connection and try again.'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'INVALID_CREDENTIALS':
|
||||||
|
ui.displayWarning('Please check your credentials and try again.');
|
||||||
|
break;
|
||||||
|
case 'AUTH_EXPIRED':
|
||||||
|
ui.displayWarning(
|
||||||
|
'Your session has expired. Please authenticate again.'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(chalk.gray(error.stack || ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle general errors
|
||||||
|
*/
|
||||||
|
private handleError(error: any): void {
|
||||||
|
if (error instanceof AuthenticationError) {
|
||||||
|
this.handleAuthError(error);
|
||||||
|
} else {
|
||||||
|
const msg = error?.getSanitizedDetails?.() ?? {
|
||||||
|
message: error?.message ?? String(error)
|
||||||
|
};
|
||||||
|
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
|
||||||
|
|
||||||
|
if (error.stack && process.env.DEBUG) {
|
||||||
|
console.error(chalk.gray(error.stack));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the last result for programmatic access
|
||||||
|
*/
|
||||||
|
private setLastResult(result: AuthResult): void {
|
||||||
|
this.lastResult = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last result (for programmatic usage)
|
||||||
|
*/
|
||||||
|
getLastResult(): AuthResult | undefined {
|
||||||
|
return this.lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current authentication status (for programmatic usage)
|
||||||
|
*/
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
return this.authManager.isAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current credentials (for programmatic usage)
|
||||||
|
*/
|
||||||
|
getCredentials(): AuthCredentials | null {
|
||||||
|
return this.authManager.getCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources
|
||||||
|
*/
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
// No resources to clean up for auth command
|
||||||
|
// But keeping method for consistency with other commands
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static method to register this command on an existing program
|
||||||
|
* This is for gradual migration - allows commands.js to use this
|
||||||
|
*/
|
||||||
|
static registerOn(program: Command): Command {
|
||||||
|
const authCommand = new AuthCommand();
|
||||||
|
program.addCommand(authCommand);
|
||||||
|
return authCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alternative registration that returns the command for chaining
|
||||||
|
* Can also configure the command name if needed
|
||||||
|
*/
|
||||||
|
static register(program: Command, name?: string): AuthCommand {
|
||||||
|
const authCommand = new AuthCommand(name);
|
||||||
|
program.addCommand(authCommand);
|
||||||
|
return authCommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
export { ListTasksCommand } from './commands/list.command.js';
|
export { ListTasksCommand } from './commands/list.command.js';
|
||||||
|
export { AuthCommand } from './commands/auth.command.js';
|
||||||
|
|
||||||
// UI utilities (for other commands to use)
|
// UI utilities (for other commands to use)
|
||||||
export * as ui from './utils/ui.js';
|
export * as ui from './utils/ui.js';
|
||||||
|
|||||||
626
packages/tm-core/src/auth/auth-manager.ts
Normal file
626
packages/tm-core/src/auth/auth-manager.ts
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
/**
|
||||||
|
* Authentication manager for tryhamster.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import https from 'https';
|
||||||
|
import http from 'http';
|
||||||
|
import { URL } from 'url';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// Auth configuration
|
||||||
|
const AUTH_CONFIG_DIR = path.join(os.homedir(), '.taskmaster');
|
||||||
|
const AUTH_CONFIG_FILE = path.join(AUTH_CONFIG_DIR, 'auth.json');
|
||||||
|
// const API_BASE_URL = process.env.HAMSTER_API_URL || 'https://tryhamster.com/api';
|
||||||
|
const API_BASE_URL = process.env.HAMSTER_API_URL || 'https://localhost:8080';
|
||||||
|
|
||||||
|
export interface AuthCredentials {
|
||||||
|
token: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
userId: string;
|
||||||
|
email?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
tokenType?: 'standard' | 'api_key';
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthOptions {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
userId: string;
|
||||||
|
email?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication error class
|
||||||
|
*/
|
||||||
|
export class AuthenticationError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AuthenticationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication manager class
|
||||||
|
*/
|
||||||
|
export class AuthManager {
|
||||||
|
private static instance: AuthManager;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*/
|
||||||
|
static getInstance(): AuthManager {
|
||||||
|
if (!AuthManager.instance) {
|
||||||
|
AuthManager.instance = new AuthManager();
|
||||||
|
}
|
||||||
|
return AuthManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored authentication credentials
|
||||||
|
*/
|
||||||
|
getCredentials(): AuthCredentials | null {
|
||||||
|
try {
|
||||||
|
// Check for environment variable override (useful for CI/CD)
|
||||||
|
// Similar to SUPABASE_ACCESS_TOKEN pattern
|
||||||
|
if (process.env.TASKMASTER_ACCESS_TOKEN) {
|
||||||
|
return {
|
||||||
|
token: process.env.TASKMASTER_ACCESS_TOKEN,
|
||||||
|
userId: process.env.TASKMASTER_USER_ID || 'env-user',
|
||||||
|
email: process.env.TASKMASTER_EMAIL,
|
||||||
|
tokenType: 'api_key',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(AUTH_CONFIG_FILE)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authData = JSON.parse(
|
||||||
|
fs.readFileSync(AUTH_CONFIG_FILE, 'utf-8')
|
||||||
|
) as AuthCredentials;
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
if (authData.expiresAt && new Date(authData.expiresAt) < new Date()) {
|
||||||
|
console.warn('Authentication token has expired');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to read auth credentials: ${(error as Error).message}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save authentication credentials
|
||||||
|
*/
|
||||||
|
private saveCredentials(authData: AuthCredentials): void {
|
||||||
|
try {
|
||||||
|
// Ensure directory exists
|
||||||
|
if (!fs.existsSync(AUTH_CONFIG_DIR)) {
|
||||||
|
fs.mkdirSync(AUTH_CONFIG_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
authData.savedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Save credentials
|
||||||
|
fs.writeFileSync(AUTH_CONFIG_FILE, JSON.stringify(authData, null, 2));
|
||||||
|
|
||||||
|
// Set file permissions to read/write for owner only
|
||||||
|
fs.chmodSync(AUTH_CONFIG_FILE, 0o600);
|
||||||
|
} catch (error) {
|
||||||
|
throw new AuthenticationError(
|
||||||
|
`Failed to save auth credentials: ${(error as Error).message}`,
|
||||||
|
'SAVE_FAILED'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an API request
|
||||||
|
*/
|
||||||
|
private makeApiRequest(endpoint: string, options: any = {}): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(endpoint, API_BASE_URL);
|
||||||
|
|
||||||
|
const requestOptions = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocol = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
const req = protocol.request(requestOptions, (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(data);
|
||||||
|
|
||||||
|
if (
|
||||||
|
res.statusCode &&
|
||||||
|
res.statusCode >= 200 &&
|
||||||
|
res.statusCode < 300
|
||||||
|
) {
|
||||||
|
resolve(parsedData);
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new AuthenticationError(
|
||||||
|
parsedData.message ||
|
||||||
|
`API request failed with status ${res.statusCode}`,
|
||||||
|
parsedData.code || 'API_ERROR'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reject(
|
||||||
|
new AuthenticationError(
|
||||||
|
`Failed to parse API response: ${(error as Error).message}`,
|
||||||
|
'PARSE_ERROR'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(
|
||||||
|
new AuthenticationError(
|
||||||
|
`Network error: ${error.message}`,
|
||||||
|
'NETWORK_ERROR'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.body) {
|
||||||
|
req.write(JSON.stringify(options.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate PKCE parameters for OAuth flow
|
||||||
|
*/
|
||||||
|
private generatePKCEParams(): {
|
||||||
|
codeVerifier: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
state: string;
|
||||||
|
} {
|
||||||
|
// Generate code verifier (43-128 characters)
|
||||||
|
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||||
|
|
||||||
|
// Generate code challenge using SHA256
|
||||||
|
const codeChallenge = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(codeVerifier)
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
// Generate state for CSRF protection
|
||||||
|
const state = crypto.randomBytes(16).toString('base64url');
|
||||||
|
|
||||||
|
return { codeVerifier, codeChallenge, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a random available port
|
||||||
|
*/
|
||||||
|
private async getRandomPort(): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = http.createServer();
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const port = (server.address() as any).port;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start OAuth 2.0 Authorization Code Flow with PKCE
|
||||||
|
*/
|
||||||
|
async startOAuthFlow(): Promise<AuthCredentials> {
|
||||||
|
const { codeVerifier, codeChallenge, state } = this.generatePKCEParams();
|
||||||
|
const port = await this.getRandomPort();
|
||||||
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let serverClosed = false;
|
||||||
|
|
||||||
|
// Create local HTTP server for OAuth callback
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url!, `http://127.0.0.1:${port}`);
|
||||||
|
|
||||||
|
if (url.pathname === '/callback') {
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const returnedState = url.searchParams.get('state');
|
||||||
|
const error = url.searchParams.get('error');
|
||||||
|
const errorDescription = url.searchParams.get('error_description');
|
||||||
|
|
||||||
|
// Send response to browser
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
res.end(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication Failed</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
h1 { color: #dc3545; }
|
||||||
|
p { color: #666; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>❌ Authentication Failed</h1>
|
||||||
|
<p>${errorDescription || error}</p>
|
||||||
|
<p>You can close this window and try again.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!serverClosed) {
|
||||||
|
serverClosed = true;
|
||||||
|
server.close();
|
||||||
|
reject(
|
||||||
|
new AuthenticationError(
|
||||||
|
errorDescription || error || 'Authentication failed',
|
||||||
|
'OAUTH_ERROR'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify state parameter
|
||||||
|
if (returnedState !== state) {
|
||||||
|
res.end(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Security Error</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
h1 { color: #dc3545; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>⚠️ Security Error</h1>
|
||||||
|
<p>Invalid state parameter. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!serverClosed) {
|
||||||
|
serverClosed = true;
|
||||||
|
server.close();
|
||||||
|
reject(
|
||||||
|
new AuthenticationError(
|
||||||
|
'Invalid state parameter',
|
||||||
|
'INVALID_STATE'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
res.end(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication Successful</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||||
|
.container { text-align: center; padding: 3rem; background: white; border-radius: 12px; box-shadow: 0 20px 40px rgba(0,0,0,0.1); }
|
||||||
|
h1 { color: #28a745; margin-bottom: 1rem; }
|
||||||
|
p { color: #666; margin-top: 1rem; }
|
||||||
|
.checkmark { width: 80px; height: 80px; margin: 0 auto 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<svg class="checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
|
||||||
|
<circle cx="26" cy="26" r="25" fill="none" stroke="#28a745" stroke-width="2"/>
|
||||||
|
<path fill="none" stroke="#28a745" stroke-width="3" d="M14 27l7 7 16-16"/>
|
||||||
|
</svg>
|
||||||
|
<h1>Authentication Successful!</h1>
|
||||||
|
<p>You can close this window and return to your terminal.</p>
|
||||||
|
<p style="color: #999; font-size: 0.9rem; margin-top: 2rem;">Task Master CLI</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
const tokens = await this.exchangeCodeForTokens(
|
||||||
|
code,
|
||||||
|
codeVerifier,
|
||||||
|
redirectUri
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!serverClosed) {
|
||||||
|
serverClosed = true;
|
||||||
|
server.close();
|
||||||
|
resolve(tokens);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!serverClosed) {
|
||||||
|
serverClosed = true;
|
||||||
|
server.close();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle other paths (favicon, etc.)
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server on localhost only
|
||||||
|
server.listen(port, '127.0.0.1', () => {
|
||||||
|
// Build authorization URL
|
||||||
|
const authUrl = new URL(`${API_BASE_URL.replace('/api', '')}/auth/cli`);
|
||||||
|
authUrl.searchParams.append('client_id', 'task-master-cli');
|
||||||
|
authUrl.searchParams.append('redirect_uri', redirectUri);
|
||||||
|
authUrl.searchParams.append('response_type', 'code');
|
||||||
|
authUrl.searchParams.append('code_challenge', codeChallenge);
|
||||||
|
authUrl.searchParams.append('code_challenge_method', 'S256');
|
||||||
|
authUrl.searchParams.append('state', state);
|
||||||
|
authUrl.searchParams.append('scope', 'offline_access'); // Request refresh token
|
||||||
|
|
||||||
|
// Store auth URL for browser opening
|
||||||
|
(this as any).authorizationUrl = authUrl.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set timeout for authentication
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
if (!serverClosed) {
|
||||||
|
serverClosed = true;
|
||||||
|
server.close();
|
||||||
|
reject(
|
||||||
|
new AuthenticationError('Authentication timeout', 'AUTH_TIMEOUT')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5 * 60 * 1000
|
||||||
|
); // 5 minute timeout
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange authorization code for tokens using PKCE
|
||||||
|
*/
|
||||||
|
private async exchangeCodeForTokens(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<AuthCredentials> {
|
||||||
|
try {
|
||||||
|
const response = (await this.makeApiRequest('/auth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: 'task-master-cli',
|
||||||
|
code,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
redirect_uri: redirectUri
|
||||||
|
}
|
||||||
|
})) as AuthResponse;
|
||||||
|
|
||||||
|
// Save authentication data
|
||||||
|
const authData: AuthCredentials = {
|
||||||
|
token: response.token,
|
||||||
|
refreshToken: response.refreshToken,
|
||||||
|
userId: response.userId,
|
||||||
|
email: response.email,
|
||||||
|
expiresAt: response.expiresAt,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.saveCredentials(authData);
|
||||||
|
return authData;
|
||||||
|
} catch (error) {
|
||||||
|
throw new AuthenticationError(
|
||||||
|
`Failed to exchange code for tokens: ${(error as Error).message}`,
|
||||||
|
'TOKEN_EXCHANGE_FAILED'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the authorization URL (for browser opening)
|
||||||
|
*/
|
||||||
|
getAuthorizationUrl(): string | null {
|
||||||
|
return (this as any).authorizationUrl || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate with email and password
|
||||||
|
*/
|
||||||
|
async authenticateWithCredentials(
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<AuthCredentials> {
|
||||||
|
try {
|
||||||
|
const response = (await this.makeApiRequest('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password }
|
||||||
|
})) as AuthResponse;
|
||||||
|
|
||||||
|
// Save authentication data
|
||||||
|
const authData: AuthCredentials = {
|
||||||
|
token: response.token,
|
||||||
|
refreshToken: response.refreshToken,
|
||||||
|
userId: response.userId,
|
||||||
|
email: email,
|
||||||
|
expiresAt: response.expiresAt,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.saveCredentials(authData);
|
||||||
|
|
||||||
|
return authData;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate with API key
|
||||||
|
*/
|
||||||
|
async authenticateWithApiKey(apiKey: string): Promise<AuthCredentials> {
|
||||||
|
try {
|
||||||
|
const response = (await this.makeApiRequest('/auth/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
}
|
||||||
|
})) as AuthResponse;
|
||||||
|
|
||||||
|
// Save authentication data
|
||||||
|
const authData: AuthCredentials = {
|
||||||
|
token: apiKey,
|
||||||
|
tokenType: 'api_key',
|
||||||
|
userId: response.userId,
|
||||||
|
email: response.email,
|
||||||
|
expiresAt: undefined, // API keys don't expire
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.saveCredentials(authData);
|
||||||
|
|
||||||
|
return authData;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh authentication token
|
||||||
|
*/
|
||||||
|
async refreshToken(): Promise<AuthCredentials> {
|
||||||
|
const authData = this.getCredentials();
|
||||||
|
|
||||||
|
if (!authData || !authData.refreshToken) {
|
||||||
|
throw new AuthenticationError(
|
||||||
|
'No refresh token available',
|
||||||
|
'NO_REFRESH_TOKEN'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = (await this.makeApiRequest('/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
refreshToken: authData.refreshToken
|
||||||
|
}
|
||||||
|
})) as AuthResponse;
|
||||||
|
|
||||||
|
// Update authentication data
|
||||||
|
const newAuthData: AuthCredentials = {
|
||||||
|
...authData,
|
||||||
|
token: response.token,
|
||||||
|
expiresAt: response.expiresAt,
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.saveCredentials(newAuthData);
|
||||||
|
|
||||||
|
return newAuthData;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout and clear credentials
|
||||||
|
*/
|
||||||
|
logout(): void {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(AUTH_CONFIG_FILE)) {
|
||||||
|
fs.unlinkSync(AUTH_CONFIG_FILE);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new AuthenticationError(
|
||||||
|
`Failed to logout: ${(error as Error).message}`,
|
||||||
|
'LOGOUT_FAILED'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if authenticated
|
||||||
|
*/
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
// Fast check for environment variable
|
||||||
|
if (process.env.TASKMASTER_ACCESS_TOKEN) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authData = this.getCredentials();
|
||||||
|
return authData !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authorization headers
|
||||||
|
*/
|
||||||
|
getAuthHeaders(): Record<string, string> {
|
||||||
|
const authData = this.getCredentials();
|
||||||
|
|
||||||
|
if (!authData) {
|
||||||
|
throw new AuthenticationError(
|
||||||
|
'Not authenticated. Please authenticate first.',
|
||||||
|
'NOT_AUTHENTICATED'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${authData.token}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,3 +43,12 @@ export * from './errors';
|
|||||||
|
|
||||||
// Re-export entities
|
// Re-export entities
|
||||||
export { TaskEntity } from './entities/task.entity';
|
export { TaskEntity } from './entities/task.entity';
|
||||||
|
|
||||||
|
// Re-export authentication
|
||||||
|
export {
|
||||||
|
AuthManager,
|
||||||
|
AuthenticationError,
|
||||||
|
type AuthCredentials,
|
||||||
|
type AuthOptions,
|
||||||
|
type AuthResponse
|
||||||
|
} from './auth/auth-manager';
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import search from '@inquirer/search';
|
|||||||
import ora from 'ora'; // Import ora
|
import ora from 'ora'; // Import ora
|
||||||
|
|
||||||
import { log, readJSON } from './utils.js';
|
import { log, readJSON } from './utils.js';
|
||||||
// Import new ListTasksCommand from @tm/cli
|
// Import new commands from @tm/cli
|
||||||
import { ListTasksCommand } from '@tm/cli';
|
import { ListTasksCommand, AuthCommand } from '@tm/cli';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
parsePRD,
|
parsePRD,
|
||||||
@@ -1740,6 +1740,11 @@ function registerCommands(programInstance) {
|
|||||||
// NEW: Register the new list command from @tm/cli
|
// NEW: Register the new list command from @tm/cli
|
||||||
// This command handles all its own configuration and logic
|
// This command handles all its own configuration and logic
|
||||||
ListTasksCommand.registerOn(programInstance);
|
ListTasksCommand.registerOn(programInstance);
|
||||||
|
|
||||||
|
// Register the auth command from @tm/cli
|
||||||
|
// Handles authentication with tryhamster.com
|
||||||
|
AuthCommand.registerOn(programInstance);
|
||||||
|
|
||||||
// expand command
|
// expand command
|
||||||
programInstance
|
programInstance
|
||||||
.command('expand')
|
.command('expand')
|
||||||
|
|||||||
@@ -103,10 +103,14 @@ describe('Roo Files Inclusion in Package', () => {
|
|||||||
test('source Roo files exist in public/assets directory', () => {
|
test('source Roo files exist in public/assets directory', () => {
|
||||||
// Verify that the source files for Roo integration exist
|
// Verify that the source files for Roo integration exist
|
||||||
expect(
|
expect(
|
||||||
fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo'))
|
fs.existsSync(
|
||||||
|
path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo')
|
||||||
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes'))
|
fs.existsSync(
|
||||||
|
path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes')
|
||||||
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,10 +89,14 @@ describe('Rules Files Inclusion in Package', () => {
|
|||||||
test('source Roo files exist in public/assets directory', () => {
|
test('source Roo files exist in public/assets directory', () => {
|
||||||
// Verify that the source files for Roo integration exist
|
// Verify that the source files for Roo integration exist
|
||||||
expect(
|
expect(
|
||||||
fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo'))
|
fs.existsSync(
|
||||||
|
path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo')
|
||||||
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes'))
|
fs.existsSync(
|
||||||
|
path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes')
|
||||||
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ describe('PromptManager', () => {
|
|||||||
describe('loadPrompt', () => {
|
describe('loadPrompt', () => {
|
||||||
it('should load and render a prompt from actual files', () => {
|
it('should load and render a prompt from actual files', () => {
|
||||||
// Test with an actual prompt that exists
|
// Test with an actual prompt that exists
|
||||||
const result = promptManager.loadPrompt('research', {
|
const result = promptManager.loadPrompt('research', {
|
||||||
query: 'test query',
|
query: 'test query',
|
||||||
projectContext: 'test context'
|
projectContext: 'test context'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.systemPrompt).toBeDefined();
|
expect(result.systemPrompt).toBeDefined();
|
||||||
expect(result.userPrompt).toBeDefined();
|
expect(result.userPrompt).toBeDefined();
|
||||||
expect(result.userPrompt).toContain('test query');
|
expect(result.userPrompt).toContain('test query');
|
||||||
@@ -87,7 +87,7 @@ describe('PromptManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = promptManager.loadPrompt('test-prompt', { name: 'John' });
|
const result = promptManager.loadPrompt('test-prompt', { name: 'John' });
|
||||||
|
|
||||||
expect(result.userPrompt).toBe('Hello John, your age is ');
|
expect(result.userPrompt).toBe('Hello John, your age is ');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,13 +100,13 @@ describe('PromptManager', () => {
|
|||||||
it('should use cache for repeated calls', () => {
|
it('should use cache for repeated calls', () => {
|
||||||
// First call with a real prompt
|
// First call with a real prompt
|
||||||
const result1 = promptManager.loadPrompt('research', { query: 'test' });
|
const result1 = promptManager.loadPrompt('research', { query: 'test' });
|
||||||
|
|
||||||
// Mark the result to verify cache is used
|
// Mark the result to verify cache is used
|
||||||
result1._cached = true;
|
result1._cached = true;
|
||||||
|
|
||||||
// Second call with same parameters should return cached result
|
// Second call with same parameters should return cached result
|
||||||
const result2 = promptManager.loadPrompt('research', { query: 'test' });
|
const result2 = promptManager.loadPrompt('research', { query: 'test' });
|
||||||
|
|
||||||
expect(result2._cached).toBe(true);
|
expect(result2._cached).toBe(true);
|
||||||
expect(result1).toBe(result2); // Same object reference
|
expect(result1).toBe(result2); // Same object reference
|
||||||
});
|
});
|
||||||
@@ -127,7 +127,7 @@ describe('PromptManager', () => {
|
|||||||
const result = promptManager.loadPrompt('array-prompt', {
|
const result = promptManager.loadPrompt('array-prompt', {
|
||||||
items: ['one', 'two', 'three']
|
items: ['one', 'two', 'three']
|
||||||
});
|
});
|
||||||
|
|
||||||
// The actual implementation doesn't handle {{this}} properly, check what it does produce
|
// The actual implementation doesn't handle {{this}} properly, check what it does produce
|
||||||
expect(result.userPrompt).toContain('Item:');
|
expect(result.userPrompt).toContain('Item:');
|
||||||
});
|
});
|
||||||
@@ -145,10 +145,14 @@ describe('PromptManager', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const withData = promptManager.loadPrompt('conditional-prompt', { hasData: true });
|
const withData = promptManager.loadPrompt('conditional-prompt', {
|
||||||
|
hasData: true
|
||||||
|
});
|
||||||
expect(withData.userPrompt).toBe('Data exists');
|
expect(withData.userPrompt).toBe('Data exists');
|
||||||
|
|
||||||
const withoutData = promptManager.loadPrompt('conditional-prompt', { hasData: false });
|
const withoutData = promptManager.loadPrompt('conditional-prompt', {
|
||||||
|
hasData: false
|
||||||
|
});
|
||||||
expect(withoutData.userPrompt).toBe('No data');
|
expect(withoutData.userPrompt).toBe('No data');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -162,7 +166,7 @@ describe('PromptManager', () => {
|
|||||||
age: 30
|
age: 30
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = promptManager.renderTemplate(template, variables);
|
const result = promptManager.renderTemplate(template, variables);
|
||||||
expect(result).toBe('User: John, Age: 30');
|
expect(result).toBe('User: John, Age: 30');
|
||||||
});
|
});
|
||||||
@@ -172,7 +176,7 @@ describe('PromptManager', () => {
|
|||||||
const variables = {
|
const variables = {
|
||||||
special: '<>&"\''
|
special: '<>&"\''
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = promptManager.renderTemplate(template, variables);
|
const result = promptManager.renderTemplate(template, variables);
|
||||||
expect(result).toBe('Special: <>&"\'');
|
expect(result).toBe('Special: <>&"\'');
|
||||||
});
|
});
|
||||||
@@ -183,8 +187,8 @@ describe('PromptManager', () => {
|
|||||||
const prompts = promptManager.listPrompts();
|
const prompts = promptManager.listPrompts();
|
||||||
expect(prompts).toBeInstanceOf(Array);
|
expect(prompts).toBeInstanceOf(Array);
|
||||||
expect(prompts.length).toBeGreaterThan(0);
|
expect(prompts.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const ids = prompts.map(p => p.id);
|
const ids = prompts.map((p) => p.id);
|
||||||
expect(ids).toContain('analyze-complexity');
|
expect(ids).toContain('analyze-complexity');
|
||||||
expect(ids).toContain('expand-task');
|
expect(ids).toContain('expand-task');
|
||||||
expect(ids).toContain('add-task');
|
expect(ids).toContain('add-task');
|
||||||
@@ -192,7 +196,6 @@ describe('PromptManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('validateTemplate', () => {
|
describe('validateTemplate', () => {
|
||||||
it('should validate a correct template', () => {
|
it('should validate a correct template', () => {
|
||||||
const result = promptManager.validateTemplate('research');
|
const result = promptManager.validateTemplate('research');
|
||||||
@@ -202,7 +205,7 @@ describe('PromptManager', () => {
|
|||||||
it('should reject invalid template', () => {
|
it('should reject invalid template', () => {
|
||||||
const result = promptManager.validateTemplate('non-existent');
|
const result = promptManager.validateTemplate('non-existent');
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.error).toContain("not found");
|
expect(result.error).toContain('not found');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user