Compare commits
9 Commits
ralph/add.
...
ralph/impl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94bff8391c | ||
|
|
022280024c | ||
|
|
84056d63cd | ||
|
|
37a8955494 | ||
|
|
538d745023 | ||
|
|
900ccbe960 | ||
|
|
70ef1298db | ||
|
|
91b5f8186e | ||
|
|
6dd910fc52 |
@@ -29,6 +29,7 @@
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^12.1.0",
|
||||
"inquirer": "^9.2.10",
|
||||
"open": "^10.2.0",
|
||||
"ora": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
514
apps/cli/src/commands/auth.command.ts
Normal file
514
apps/cli/src/commands/auth.command.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* @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, { type Ora } from 'ora';
|
||||
import open from 'open';
|
||||
import {
|
||||
AuthManager,
|
||||
AuthenticationError,
|
||||
type AuthCredentials
|
||||
} from '@tm/core/auth';
|
||||
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);
|
||||
}
|
||||
|
||||
// Exit cleanly after successful authentication
|
||||
// Small delay to ensure all output is flushed
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
} catch (error: any) {
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute logout command
|
||||
*/
|
||||
private async executeLogout(): Promise<void> {
|
||||
try {
|
||||
const result = await 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 async performLogout(): Promise<AuthResult> {
|
||||
try {
|
||||
await 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> {
|
||||
let authSpinner: Ora | null = null;
|
||||
|
||||
try {
|
||||
// Use AuthManager's new unified OAuth flow method with callbacks
|
||||
const credentials = await this.authManager.authenticateWithOAuth({
|
||||
// Callback to handle browser opening
|
||||
openBrowser: async (authUrl) => {
|
||||
await open(authUrl);
|
||||
},
|
||||
timeout: 5 * 60 * 1000, // 5 minutes
|
||||
|
||||
// Callback when auth URL is ready
|
||||
onAuthUrl: (authUrl) => {
|
||||
// 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`));
|
||||
},
|
||||
|
||||
// Callback when waiting for authentication
|
||||
onWaitingForAuth: () => {
|
||||
authSpinner = ora({
|
||||
text: 'Waiting for authentication...',
|
||||
spinner: 'dots'
|
||||
}).start();
|
||||
},
|
||||
|
||||
// Callback on success
|
||||
onSuccess: () => {
|
||||
if (authSpinner) {
|
||||
authSpinner.succeed('Authentication successful!');
|
||||
}
|
||||
},
|
||||
|
||||
// Callback on error
|
||||
onError: () => {
|
||||
if (authSpinner) {
|
||||
authSpinner.fail('Authentication failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
export { ListTasksCommand } from './commands/list.command.js';
|
||||
export { AuthCommand } from './commands/auth.command.js';
|
||||
|
||||
// UI utilities (for other commands to use)
|
||||
export * as ui from './utils/ui.js';
|
||||
|
||||
166
package-lock.json
generated
166
package-lock.json
generated
@@ -69,6 +69,7 @@
|
||||
"@changesets/changelog-github": "^0.5.1",
|
||||
"@changesets/cli": "^2.28.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"dotenv-mono": "^1.5.1",
|
||||
"execa": "^8.0.1",
|
||||
"ink": "^5.0.1",
|
||||
"jest": "^29.7.0",
|
||||
@@ -100,6 +101,7 @@
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^12.1.0",
|
||||
"inquirer": "^9.2.10",
|
||||
"open": "^10.2.0",
|
||||
"ora": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -8947,6 +8949,80 @@
|
||||
"version": "0.0.22",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
|
||||
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz",
|
||||
"integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/node-fetch": {
|
||||
"version": "2.6.15",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
|
||||
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "1.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.3.tgz",
|
||||
"integrity": "sha512-rg3DmmZQKEVCreXq6Am29hMVe1CzemXyIWVYyyua69y6XubfP+DzGfLxME/1uvdgwqdoaPbtjBDpEBhqxq1ZwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.4.tgz",
|
||||
"integrity": "sha512-e/FYIWjvQJHOCNACWehnKvg26zosju3694k0NMUNb+JGLdvHJzEa29ZVVLmawd2kvx4hdbv8mxSqfttRnH3+DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.13",
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.18.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.11.0.tgz",
|
||||
"integrity": "sha512-Y+kx/wDgd4oasAgoAq0bsbQojwQ+ejIif8uczZ9qufRHWFLMU5cODT+ApHsSrDufqUcVKt+eyxtOXSkeh2v9ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.57.0.tgz",
|
||||
"integrity": "sha512-h9ttcL0MY4h+cGqZl95F/RuqccuRBjHU9B7Qqvw0Da+pPK2sUlU1/UdvyqUGj37UsnSphr9pdGfeXjesYkBcyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.71.1",
|
||||
"@supabase/functions-js": "2.4.5",
|
||||
"@supabase/node-fetch": "2.6.15",
|
||||
"@supabase/postgrest-js": "1.21.3",
|
||||
"@supabase/realtime-js": "2.15.4",
|
||||
"@supabase/storage-js": "^2.10.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@szmarczak/http-timer": {
|
||||
"version": "5.0.1",
|
||||
"dev": true,
|
||||
@@ -9343,6 +9419,12 @@
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.8",
|
||||
"dev": true,
|
||||
@@ -9400,6 +9482,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.33",
|
||||
"dev": true,
|
||||
@@ -11087,7 +11178,6 @@
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"run-applescript": "^7.0.0"
|
||||
@@ -12480,7 +12570,6 @@
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.2.1",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bundle-name": "^4.1.0",
|
||||
@@ -12495,7 +12584,6 @@
|
||||
},
|
||||
"node_modules/default-browser-id": {
|
||||
"version": "5.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -12542,7 +12630,6 @@
|
||||
},
|
||||
"node_modules/define-lazy-prop": {
|
||||
"version": "3.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -12837,6 +12924,52 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv-expand": {
|
||||
"version": "12.0.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.2.tgz",
|
||||
"integrity": "sha512-lXpXz2ZE1cea1gL4sz2Ipj8y4PiVjytYr3Ij0SWoms1PGxIv7m2CRKuRuCRtHdVuvM/hNJPMxt5PbhboNC4dPQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv-mono": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-mono/-/dotenv-mono-1.5.1.tgz",
|
||||
"integrity": "sha512-dt7bK/WKQvL0gcdTxjI7wD4MhVR5F4bCk70XMAgnrbWN3fdhpyhWCypYbZalr/vjLURLA7Ib9/VCzazRLJnp1Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"dotenv": "^17.2.0",
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"minimist": "^1.2.8"
|
||||
},
|
||||
"bin": {
|
||||
"dotenv": "dist/cli.js",
|
||||
"dotenv-mono": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv-mono/node_modules/dotenv": {
|
||||
"version": "17.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
|
||||
"integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
@@ -15897,7 +16030,6 @@
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "3.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
@@ -16011,7 +16143,6 @@
|
||||
},
|
||||
"node_modules/is-inside-container": {
|
||||
"version": "1.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^3.0.0"
|
||||
@@ -16335,7 +16466,6 @@
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "3.1.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-inside-container": "^1.0.0"
|
||||
@@ -19601,7 +19731,6 @@
|
||||
"version": "1.2.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
@@ -20629,7 +20758,8 @@
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "10.2.0",
|
||||
"devOptional": true,
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-browser": "^5.2.1",
|
||||
@@ -22702,7 +22832,6 @@
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -25886,7 +26015,6 @@
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.2",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -25906,7 +26034,6 @@
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.1.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-wsl": "^3.1.0"
|
||||
@@ -26153,17 +26280,32 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"packages/logger": {
|
||||
"name": "@tm/logger",
|
||||
"version": "1.0.0",
|
||||
"extraneous": true,
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.5",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
"packages/tm-core": {
|
||||
"name": "@tm/core",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.57.0",
|
||||
"chalk": "^5.3.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/node": "^20.11.30",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
"dotenv-mono": "^1.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.3",
|
||||
|
||||
@@ -116,8 +116,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
|
||||
"@changesets/changelog-github": "^0.5.1",
|
||||
"@changesets/cli": "^2.28.1",
|
||||
"dotenv-mono": "^1.5.1",
|
||||
|
||||
"@types/jest": "^29.5.14",
|
||||
"execa": "^8.0.1",
|
||||
"ink": "^5.0.1",
|
||||
|
||||
7021
packages/tm-core/package-lock.json
generated
7021
packages/tm-core/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,58 @@
|
||||
"types": "./src/index.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./auth": {
|
||||
"types": "./src/auth/index.ts",
|
||||
"import": "./dist/auth/index.js",
|
||||
"require": "./dist/auth/index.js"
|
||||
},
|
||||
"./storage": {
|
||||
"types": "./src/storage/index.ts",
|
||||
"import": "./dist/storage/index.js",
|
||||
"require": "./dist/storage/index.js"
|
||||
},
|
||||
"./config": {
|
||||
"types": "./src/config/index.ts",
|
||||
"import": "./dist/config/index.js",
|
||||
"require": "./dist/config/index.js"
|
||||
},
|
||||
"./providers": {
|
||||
"types": "./src/providers/index.ts",
|
||||
"import": "./dist/providers/index.js",
|
||||
"require": "./dist/providers/index.js"
|
||||
},
|
||||
"./services": {
|
||||
"types": "./src/services/index.ts",
|
||||
"import": "./dist/services/index.js",
|
||||
"require": "./dist/services/index.js"
|
||||
},
|
||||
"./errors": {
|
||||
"types": "./src/errors/index.ts",
|
||||
"import": "./dist/errors/index.js",
|
||||
"require": "./dist/errors/index.js"
|
||||
},
|
||||
"./logger": {
|
||||
"types": "./src/logger/index.ts",
|
||||
"import": "./dist/logger/index.js",
|
||||
"require": "./dist/logger/index.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types/index.ts",
|
||||
"import": "./dist/types/index.js",
|
||||
"require": "./dist/types/index.js"
|
||||
},
|
||||
"./interfaces": {
|
||||
"types": "./src/interfaces/index.ts",
|
||||
"import": "./dist/interfaces/index.js",
|
||||
"require": "./dist/interfaces/index.js"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./src/utils/index.ts",
|
||||
"import": "./dist/utils/index.js",
|
||||
"require": "./dist/utils/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
@@ -26,12 +77,15 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.57.0",
|
||||
"chalk": "^5.3.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/node": "^20.11.30",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
"dotenv-mono": "^1.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.3",
|
||||
|
||||
150
packages/tm-core/src/auth/auth-manager.test.ts
Normal file
150
packages/tm-core/src/auth/auth-manager.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Tests for AuthManager singleton behavior
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock the logger to verify warnings (must be hoisted before SUT import)
|
||||
const mockLogger = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../logger/index.js', () => ({
|
||||
getLogger: () => mockLogger
|
||||
}));
|
||||
|
||||
// Spy on CredentialStore constructor to verify config propagation
|
||||
const CredentialStoreSpy = vi.fn();
|
||||
vi.mock('./credential-store.js', () => {
|
||||
return {
|
||||
CredentialStore: class {
|
||||
constructor(config: any) {
|
||||
CredentialStoreSpy(config);
|
||||
this.getCredentials = vi.fn(() => null);
|
||||
}
|
||||
getCredentials() {
|
||||
return null;
|
||||
}
|
||||
saveCredentials() {}
|
||||
clearCredentials() {}
|
||||
hasValidCredentials() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock OAuthService to avoid side effects
|
||||
vi.mock('./oauth-service.js', () => {
|
||||
return {
|
||||
OAuthService: class {
|
||||
constructor() {}
|
||||
authenticate() {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
getAuthorizationUrl() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock SupabaseAuthClient to avoid side effects
|
||||
vi.mock('../clients/supabase-client.js', () => {
|
||||
return {
|
||||
SupabaseAuthClient: class {
|
||||
constructor() {}
|
||||
refreshSession() {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
signOut() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Import SUT after mocks
|
||||
import { AuthManager } from './auth-manager.js';
|
||||
|
||||
describe('AuthManager Singleton', () => {
|
||||
beforeEach(() => {
|
||||
// Reset singleton before each test
|
||||
AuthManager.resetInstance();
|
||||
vi.clearAllMocks();
|
||||
CredentialStoreSpy.mockClear();
|
||||
});
|
||||
|
||||
it('should return the same instance on multiple calls', () => {
|
||||
const instance1 = AuthManager.getInstance();
|
||||
const instance2 = AuthManager.getInstance();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should use config on first call', () => {
|
||||
const config = {
|
||||
baseUrl: 'https://test.auth.com',
|
||||
configDir: '/test/config',
|
||||
configFile: '/test/config/auth.json'
|
||||
};
|
||||
|
||||
const instance = AuthManager.getInstance(config);
|
||||
expect(instance).toBeDefined();
|
||||
|
||||
// Assert that CredentialStore was constructed with the provided config
|
||||
expect(CredentialStoreSpy).toHaveBeenCalledTimes(1);
|
||||
expect(CredentialStoreSpy).toHaveBeenCalledWith(config);
|
||||
|
||||
// Verify the config is passed to internal components through observable behavior
|
||||
// getCredentials would look in the configured file path
|
||||
const credentials = instance.getCredentials();
|
||||
expect(credentials).toBeNull(); // File doesn't exist, but config was propagated correctly
|
||||
});
|
||||
|
||||
it('should warn when config is provided after initialization', () => {
|
||||
// Clear previous calls
|
||||
mockLogger.warn.mockClear();
|
||||
|
||||
// First call with config
|
||||
AuthManager.getInstance({ baseUrl: 'https://first.auth.com' });
|
||||
|
||||
// Second call with different config
|
||||
AuthManager.getInstance({ baseUrl: 'https://second.auth.com' });
|
||||
|
||||
// Verify warning was logged
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/config.*after initialization.*ignored/i)
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn when no config is provided after initialization', () => {
|
||||
// Clear previous calls
|
||||
mockLogger.warn.mockClear();
|
||||
|
||||
// First call with config
|
||||
AuthManager.getInstance({ configDir: '/test/config' });
|
||||
|
||||
// Second call without config
|
||||
AuthManager.getInstance();
|
||||
|
||||
// Verify no warning was logged
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow resetting the instance', () => {
|
||||
const instance1 = AuthManager.getInstance();
|
||||
|
||||
// Reset the instance
|
||||
AuthManager.resetInstance();
|
||||
|
||||
// Get new instance
|
||||
const instance2 = AuthManager.getInstance();
|
||||
|
||||
// They should be different instances
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
});
|
||||
136
packages/tm-core/src/auth/auth-manager.ts
Normal file
136
packages/tm-core/src/auth/auth-manager.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Authentication manager for Task Master CLI
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthCredentials,
|
||||
OAuthFlowOptions,
|
||||
AuthenticationError,
|
||||
AuthConfig
|
||||
} from './types.js';
|
||||
import { CredentialStore } from './credential-store.js';
|
||||
import { OAuthService } from './oauth-service.js';
|
||||
import { SupabaseAuthClient } from '../clients/supabase-client.js';
|
||||
import { getLogger } from '../logger/index.js';
|
||||
|
||||
/**
|
||||
* Authentication manager class
|
||||
*/
|
||||
export class AuthManager {
|
||||
private static instance: AuthManager | null = null;
|
||||
private credentialStore: CredentialStore;
|
||||
private oauthService: OAuthService;
|
||||
private supabaseClient: SupabaseAuthClient;
|
||||
|
||||
private constructor(config?: Partial<AuthConfig>) {
|
||||
this.credentialStore = new CredentialStore(config);
|
||||
this.supabaseClient = new SupabaseAuthClient();
|
||||
this.oauthService = new OAuthService(this.credentialStore, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(config?: Partial<AuthConfig>): AuthManager {
|
||||
if (!AuthManager.instance) {
|
||||
AuthManager.instance = new AuthManager(config);
|
||||
} else if (config) {
|
||||
// Warn if config is provided after initialization
|
||||
const logger = getLogger('AuthManager');
|
||||
logger.warn(
|
||||
'getInstance called with config after initialization; config is ignored.'
|
||||
);
|
||||
}
|
||||
return AuthManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing)
|
||||
*/
|
||||
static resetInstance(): void {
|
||||
AuthManager.instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored authentication credentials
|
||||
*/
|
||||
getCredentials(): AuthCredentials | null {
|
||||
return this.credentialStore.getCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth 2.0 Authorization Code Flow with browser handling
|
||||
*/
|
||||
async authenticateWithOAuth(
|
||||
options: OAuthFlowOptions = {}
|
||||
): Promise<AuthCredentials> {
|
||||
return this.oauthService.authenticate(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL (for browser opening)
|
||||
*/
|
||||
getAuthorizationUrl(): string | null {
|
||||
return this.oauthService.getAuthorizationUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication token
|
||||
*/
|
||||
async refreshToken(): Promise<AuthCredentials> {
|
||||
const authData = this.credentialStore.getCredentials({
|
||||
allowExpired: true
|
||||
});
|
||||
|
||||
if (!authData || !authData.refreshToken) {
|
||||
throw new AuthenticationError(
|
||||
'No refresh token available',
|
||||
'NO_REFRESH_TOKEN'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Supabase client to refresh the token
|
||||
const response = await this.supabaseClient.refreshSession(
|
||||
authData.refreshToken
|
||||
);
|
||||
|
||||
// Update authentication data
|
||||
const newAuthData: AuthCredentials = {
|
||||
...authData,
|
||||
token: response.token,
|
||||
refreshToken: response.refreshToken,
|
||||
expiresAt: response.expiresAt,
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.credentialStore.saveCredentials(newAuthData);
|
||||
return newAuthData;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear credentials
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
// First try to sign out from Supabase to revoke tokens
|
||||
await this.supabaseClient.signOut();
|
||||
} catch (error) {
|
||||
// Log but don't throw - we still want to clear local credentials
|
||||
getLogger('AuthManager').warn('Failed to sign out from Supabase:', error);
|
||||
}
|
||||
|
||||
// Always clear local credentials (removes auth.json file)
|
||||
this.credentialStore.clearCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return this.credentialStore.hasValidCredentials();
|
||||
}
|
||||
}
|
||||
37
packages/tm-core/src/auth/config.ts
Normal file
37
packages/tm-core/src/auth/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Centralized authentication configuration
|
||||
*/
|
||||
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { AuthConfig } from './types.js';
|
||||
|
||||
// Single base domain for all URLs
|
||||
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsup's env option
|
||||
// Default: https://tryhamster.com for production
|
||||
const BASE_DOMAIN =
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN || // This gets replaced at build time by tsup
|
||||
'https://tryhamster.com';
|
||||
|
||||
/**
|
||||
* Default authentication configuration
|
||||
* All URL configuration is derived from the single BASE_DOMAIN
|
||||
*/
|
||||
export const DEFAULT_AUTH_CONFIG: AuthConfig = {
|
||||
// Base domain for all services
|
||||
baseUrl: BASE_DOMAIN,
|
||||
|
||||
// Configuration directory and file paths
|
||||
configDir: path.join(os.homedir(), '.taskmaster'),
|
||||
configFile: path.join(os.homedir(), '.taskmaster', 'auth.json')
|
||||
};
|
||||
|
||||
/**
|
||||
* Get merged configuration with optional overrides
|
||||
*/
|
||||
export function getAuthConfig(overrides?: Partial<AuthConfig>): AuthConfig {
|
||||
return {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
575
packages/tm-core/src/auth/credential-store.test.ts
Normal file
575
packages/tm-core/src/auth/credential-store.test.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* Tests for CredentialStore with numeric and string timestamp handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { CredentialStore } from './credential-store.js';
|
||||
import { AuthenticationError } from './types.js';
|
||||
import type { AuthCredentials } from './types.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('fs');
|
||||
|
||||
// Mock logger
|
||||
const mockLogger = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../logger/index.js', () => ({
|
||||
getLogger: () => mockLogger
|
||||
}));
|
||||
|
||||
describe('CredentialStore', () => {
|
||||
let store: CredentialStore;
|
||||
const testDir = '/test/config';
|
||||
const configFile = '/test/config/auth.json';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store = new CredentialStore({
|
||||
configDir: testDir,
|
||||
configFile: configFile,
|
||||
baseUrl: 'https://api.test.com'
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getCredentials with timestamp migration', () => {
|
||||
it('should handle string ISO timestamp correctly', () => {
|
||||
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
|
||||
const mockCredentials: AuthCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-123',
|
||||
email: 'test@example.com',
|
||||
expiresAt: futureDate.toISOString(),
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify(mockCredentials)
|
||||
);
|
||||
|
||||
const result = store.getCredentials();
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.token).toBe('test-token');
|
||||
// The timestamp should be normalized to numeric milliseconds
|
||||
expect(typeof result?.expiresAt).toBe('number');
|
||||
expect(result?.expiresAt).toBe(futureDate.getTime());
|
||||
});
|
||||
|
||||
it('should handle numeric timestamp correctly', () => {
|
||||
const futureTimestamp = Date.now() + 7200000; // 2 hours from now
|
||||
const mockCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-456',
|
||||
email: 'test2@example.com',
|
||||
expiresAt: futureTimestamp,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify(mockCredentials)
|
||||
);
|
||||
|
||||
const result = store.getCredentials();
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.token).toBe('test-token');
|
||||
// Numeric timestamp should remain as-is
|
||||
expect(typeof result?.expiresAt).toBe('number');
|
||||
expect(result?.expiresAt).toBe(futureTimestamp);
|
||||
});
|
||||
|
||||
it('should reject invalid string timestamp', () => {
|
||||
const mockCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-789',
|
||||
expiresAt: 'invalid-date-string',
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify(mockCredentials)
|
||||
);
|
||||
|
||||
const result = store.getCredentials();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'No valid expiration time provided for token'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject NaN timestamp', () => {
|
||||
const mockCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-nan',
|
||||
expiresAt: NaN,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify(mockCredentials)
|
||||
);
|
||||
|
||||
const result = store.getCredentials();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'No valid expiration time provided for token'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject Infinity timestamp', () => {
|
||||
const mockCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-inf',
|
||||
expiresAt: Infinity,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify(mockCredentials)
|
||||
);
|
||||
|
||||
const result = store.getCredentials();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'No valid expiration time provided for token'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing expiresAt field', () => {
|
||||
const mockCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-no-expiry',
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
// No expiresAt field
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify(mockCredentials)
|
||||
);
|
||||
|
||||
const result = store.getCredentials();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'No valid expiration time provided for token'
|
||||
);
|
||||
});
|
||||
|
||||
it('should check token expiration correctly', () => {
|
||||
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
|
||||
const mockCredentials = {
|
||||
token: 'expired-token',
|
||||
userId: 'user-expired',
|
||||
expiresAt: expiredTimestamp,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify(mockCredentials)
|
||||
);
|
||||
|
||||
const result = store.getCredentials();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Authentication token has expired'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow expired tokens when requested', () => {
|
||||
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
|
||||
const mockCredentials = {
|
||||
token: 'expired-token',
|
||||
userId: 'user-expired',
|
||||
expiresAt: expiredTimestamp,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify(mockCredentials)
|
||||
);
|
||||
|
||||
const result = store.getCredentials({ allowExpired: true });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.token).toBe('expired-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveCredentials with timestamp normalization', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('should normalize string timestamp to ISO string when saving', () => {
|
||||
const futureDate = new Date(Date.now() + 3600000);
|
||||
const credentials: AuthCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-123',
|
||||
expiresAt: futureDate.toISOString(),
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
store.saveCredentials(credentials);
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.tmp'),
|
||||
expect.stringContaining('"expiresAt":'),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
// Check that the written data contains a valid ISO string
|
||||
const writtenData = vi.mocked(fs.writeFileSync).mock
|
||||
.calls[0][1] as string;
|
||||
const parsed = JSON.parse(writtenData);
|
||||
expect(typeof parsed.expiresAt).toBe('string');
|
||||
expect(new Date(parsed.expiresAt).toISOString()).toBe(parsed.expiresAt);
|
||||
});
|
||||
|
||||
it('should convert numeric timestamp to ISO string when saving', () => {
|
||||
const futureTimestamp = Date.now() + 7200000;
|
||||
const credentials: AuthCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-456',
|
||||
expiresAt: futureTimestamp,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
store.saveCredentials(credentials);
|
||||
|
||||
const writtenData = vi.mocked(fs.writeFileSync).mock
|
||||
.calls[0][1] as string;
|
||||
const parsed = JSON.parse(writtenData);
|
||||
expect(typeof parsed.expiresAt).toBe('string');
|
||||
expect(new Date(parsed.expiresAt).getTime()).toBe(futureTimestamp);
|
||||
});
|
||||
|
||||
it('should reject invalid string timestamp when saving', () => {
|
||||
const credentials: AuthCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-789',
|
||||
expiresAt: 'invalid-date' as any,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
let err: unknown;
|
||||
try {
|
||||
store.saveCredentials(credentials);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(AuthenticationError);
|
||||
expect((err as Error).message).toContain('Invalid expiresAt format');
|
||||
});
|
||||
|
||||
it('should reject NaN timestamp when saving', () => {
|
||||
const credentials: AuthCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-nan',
|
||||
expiresAt: NaN as any,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
let err: unknown;
|
||||
try {
|
||||
store.saveCredentials(credentials);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(AuthenticationError);
|
||||
expect((err as Error).message).toContain('Invalid expiresAt format');
|
||||
});
|
||||
|
||||
it('should reject Infinity timestamp when saving', () => {
|
||||
const credentials: AuthCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-inf',
|
||||
expiresAt: Infinity as any,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
let err: unknown;
|
||||
try {
|
||||
store.saveCredentials(credentials);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(AuthenticationError);
|
||||
expect((err as Error).message).toContain('Invalid expiresAt format');
|
||||
});
|
||||
|
||||
it('should handle missing expiresAt when saving', () => {
|
||||
const credentials: AuthCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-no-expiry',
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
// No expiresAt
|
||||
};
|
||||
|
||||
store.saveCredentials(credentials);
|
||||
|
||||
const writtenData = vi.mocked(fs.writeFileSync).mock
|
||||
.calls[0][1] as string;
|
||||
const parsed = JSON.parse(writtenData);
|
||||
expect(parsed.expiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not mutate the original credentials object', () => {
|
||||
const originalTimestamp = Date.now() + 3600000;
|
||||
const credentials: AuthCredentials = {
|
||||
token: 'test-token',
|
||||
userId: 'user-123',
|
||||
expiresAt: originalTimestamp,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const originalCredentialsCopy = { ...credentials };
|
||||
|
||||
store.saveCredentials(credentials);
|
||||
|
||||
// Original object should not be modified
|
||||
expect(credentials).toEqual(originalCredentialsCopy);
|
||||
expect(credentials.expiresAt).toBe(originalTimestamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('corrupt file handling', () => {
|
||||
it('should quarantine corrupt file on JSON parse error', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
||||
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
||||
|
||||
const result = store.getCredentials();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(fs.renameSync).toHaveBeenCalledWith(
|
||||
configFile,
|
||||
expect.stringContaining('.corrupt-')
|
||||
);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Quarantined corrupt auth file')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle quarantine failure gracefully', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
||||
vi.mocked(fs.renameSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
const result = store.getCredentials();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not quarantine corrupt file')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCredentials', () => {
|
||||
it('should delete the auth file when it exists', () => {
|
||||
// Mock file exists
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.unlinkSync).mockImplementation(() => undefined);
|
||||
|
||||
store.clearCredentials();
|
||||
|
||||
expect(fs.existsSync).toHaveBeenCalledWith('/test/config/auth.json');
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith('/test/config/auth.json');
|
||||
});
|
||||
|
||||
it('should not throw when auth file does not exist', () => {
|
||||
// Mock file does not exist
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
// Should not throw
|
||||
expect(() => store.clearCredentials()).not.toThrow();
|
||||
|
||||
// Should not try to unlink non-existent file
|
||||
expect(fs.unlinkSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw AuthenticationError when unlink fails', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.unlinkSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
let err: unknown;
|
||||
try {
|
||||
store.clearCredentials();
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
|
||||
expect(err).toBeInstanceOf(AuthenticationError);
|
||||
expect((err as Error).message).toContain('Failed to clear credentials');
|
||||
expect((err as Error).message).toContain('Permission denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasValidCredentials', () => {
|
||||
it('should return true when valid unexpired credentials exist', () => {
|
||||
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
|
||||
const credentials = {
|
||||
token: 'valid-token',
|
||||
userId: 'user-123',
|
||||
expiresAt: futureDate.toISOString(),
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
||||
|
||||
expect(store.hasValidCredentials()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when credentials are expired', () => {
|
||||
const pastDate = new Date(Date.now() - 3600000); // 1 hour ago
|
||||
const credentials = {
|
||||
token: 'expired-token',
|
||||
userId: 'user-123',
|
||||
expiresAt: pastDate.toISOString(),
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
||||
|
||||
expect(store.hasValidCredentials()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no credentials exist', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
expect(store.hasValidCredentials()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when file contains invalid JSON', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
||||
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
||||
|
||||
expect(store.hasValidCredentials()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for credentials without expiry', () => {
|
||||
const credentials = {
|
||||
token: 'no-expiry-token',
|
||||
userId: 'user-123',
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
||||
|
||||
// Credentials without expiry are considered invalid
|
||||
expect(store.hasValidCredentials()).toBe(false);
|
||||
|
||||
// Should log warning about missing expiration
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'No valid expiration time provided for token'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use allowExpired=false by default', () => {
|
||||
// Spy on getCredentials to verify it's called with correct params
|
||||
const getCredentialsSpy = vi.spyOn(store, 'getCredentials');
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
store.hasValidCredentials();
|
||||
|
||||
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupCorruptFiles', () => {
|
||||
it('should remove old corrupt files', () => {
|
||||
const now = Date.now();
|
||||
const oldFile = 'auth.json.corrupt-' + (now - 8 * 24 * 60 * 60 * 1000); // 8 days old
|
||||
const newFile = 'auth.json.corrupt-' + (now - 1000); // 1 second old
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||
{ name: oldFile, isFile: () => true },
|
||||
{ name: newFile, isFile: () => true },
|
||||
{ name: 'auth.json', isFile: () => true }
|
||||
] as any);
|
||||
vi.mocked(fs.statSync).mockImplementation((filePath) => {
|
||||
if (filePath.includes(oldFile)) {
|
||||
return { mtimeMs: now - 8 * 24 * 60 * 60 * 1000 } as any;
|
||||
}
|
||||
return { mtimeMs: now - 1000 } as any;
|
||||
});
|
||||
vi.mocked(fs.unlinkSync).mockImplementation(() => undefined);
|
||||
|
||||
store.cleanupCorruptFiles();
|
||||
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining(oldFile)
|
||||
);
|
||||
expect(fs.unlinkSync).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining(newFile)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle cleanup errors gracefully', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readdirSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => store.cleanupCorruptFiles()).not.toThrow();
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error during corrupt file cleanup')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
241
packages/tm-core/src/auth/credential-store.ts
Normal file
241
packages/tm-core/src/auth/credential-store.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Credential storage and management
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { AuthCredentials, AuthenticationError, AuthConfig } from './types.js';
|
||||
import { getAuthConfig } from './config.js';
|
||||
import { getLogger } from '../logger/index.js';
|
||||
|
||||
/**
|
||||
* CredentialStore manages the persistence and retrieval of authentication credentials.
|
||||
*
|
||||
* Runtime vs Persisted Shape:
|
||||
* - When retrieved (getCredentials): expiresAt is normalized to number (milliseconds since epoch)
|
||||
* - When persisted (saveCredentials): expiresAt is stored as ISO string for readability
|
||||
*
|
||||
* This normalization ensures consistent runtime behavior while maintaining
|
||||
* human-readable persisted format in the auth.json file.
|
||||
*/
|
||||
export class CredentialStore {
|
||||
private logger = getLogger('CredentialStore');
|
||||
private config: AuthConfig;
|
||||
// Clock skew tolerance for expiry checks (30 seconds)
|
||||
private readonly CLOCK_SKEW_MS = 30_000;
|
||||
|
||||
constructor(config?: Partial<AuthConfig>) {
|
||||
this.config = getAuthConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored authentication credentials
|
||||
* @returns AuthCredentials with expiresAt as number (milliseconds) for runtime use
|
||||
*/
|
||||
getCredentials(options?: { allowExpired?: boolean }): AuthCredentials | null {
|
||||
try {
|
||||
if (!fs.existsSync(this.config.configFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authData = JSON.parse(
|
||||
fs.readFileSync(this.config.configFile, 'utf-8')
|
||||
) as AuthCredentials;
|
||||
|
||||
// Normalize/migrate timestamps to numeric (handles both number and ISO string)
|
||||
let expiresAtMs: number | undefined;
|
||||
if (typeof authData.expiresAt === 'number') {
|
||||
expiresAtMs = Number.isFinite(authData.expiresAt)
|
||||
? authData.expiresAt
|
||||
: undefined;
|
||||
} else if (typeof authData.expiresAt === 'string') {
|
||||
const parsed = Date.parse(authData.expiresAt);
|
||||
expiresAtMs = Number.isNaN(parsed) ? undefined : parsed;
|
||||
} else {
|
||||
expiresAtMs = undefined;
|
||||
}
|
||||
|
||||
// Validate expiration time for tokens
|
||||
if (expiresAtMs === undefined) {
|
||||
this.logger.warn('No valid expiration time provided for token');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update the authData with normalized timestamp
|
||||
authData.expiresAt = expiresAtMs;
|
||||
|
||||
// Check if the token has expired (with clock skew tolerance)
|
||||
const now = Date.now();
|
||||
const allowExpired = options?.allowExpired ?? false;
|
||||
if (now >= expiresAtMs - this.CLOCK_SKEW_MS && !allowExpired) {
|
||||
this.logger.warn(
|
||||
'Authentication token has expired or is about to expire',
|
||||
{
|
||||
expiresAt: authData.expiresAt,
|
||||
currentTime: new Date(now).toISOString(),
|
||||
skewWindow: `${this.CLOCK_SKEW_MS / 1000}s`
|
||||
}
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return valid token
|
||||
return authData;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to read auth credentials: ${(error as Error).message}`
|
||||
);
|
||||
|
||||
// Quarantine corrupt file to prevent repeated errors
|
||||
try {
|
||||
if (fs.existsSync(this.config.configFile)) {
|
||||
const corruptFile = `${this.config.configFile}.corrupt-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
fs.renameSync(this.config.configFile, corruptFile);
|
||||
this.logger.warn(`Quarantined corrupt auth file to: ${corruptFile}`);
|
||||
}
|
||||
} catch (quarantineError) {
|
||||
// If we can't quarantine, log but don't throw
|
||||
this.logger.debug(
|
||||
`Could not quarantine corrupt file: ${(quarantineError as Error).message}`
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save authentication credentials
|
||||
* @param authData - Credentials with expiresAt as number or string (will be persisted as ISO string)
|
||||
*/
|
||||
saveCredentials(authData: AuthCredentials): void {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(this.config.configDir)) {
|
||||
fs.mkdirSync(this.config.configDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// Add timestamp without mutating caller's object
|
||||
authData = { ...authData, savedAt: new Date().toISOString() };
|
||||
|
||||
// Validate and normalize expiresAt timestamp
|
||||
if (authData.expiresAt !== undefined) {
|
||||
let validTimestamp: number | undefined;
|
||||
|
||||
if (typeof authData.expiresAt === 'number') {
|
||||
validTimestamp = Number.isFinite(authData.expiresAt)
|
||||
? authData.expiresAt
|
||||
: undefined;
|
||||
} else if (typeof authData.expiresAt === 'string') {
|
||||
const parsed = Date.parse(authData.expiresAt);
|
||||
validTimestamp = Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
if (validTimestamp === undefined) {
|
||||
throw new AuthenticationError(
|
||||
`Invalid expiresAt format: ${authData.expiresAt}`,
|
||||
'SAVE_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
// Store as ISO string for consistency
|
||||
authData.expiresAt = new Date(validTimestamp).toISOString();
|
||||
}
|
||||
|
||||
// Save credentials atomically with secure permissions
|
||||
const tempFile = `${this.config.configFile}.tmp`;
|
||||
fs.writeFileSync(tempFile, JSON.stringify(authData, null, 2), {
|
||||
mode: 0o600
|
||||
});
|
||||
fs.renameSync(tempFile, this.config.configFile);
|
||||
} catch (error) {
|
||||
throw new AuthenticationError(
|
||||
`Failed to save auth credentials: ${(error as Error).message}`,
|
||||
'SAVE_FAILED',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored credentials
|
||||
*/
|
||||
clearCredentials(): void {
|
||||
try {
|
||||
if (fs.existsSync(this.config.configFile)) {
|
||||
fs.unlinkSync(this.config.configFile);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new AuthenticationError(
|
||||
`Failed to clear credentials: ${(error as Error).message}`,
|
||||
'CLEAR_FAILED',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials exist and are valid
|
||||
*/
|
||||
hasValidCredentials(): boolean {
|
||||
const credentials = this.getCredentials({ allowExpired: false });
|
||||
return credentials !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration
|
||||
*/
|
||||
getConfig(): AuthConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old corrupt auth files
|
||||
* Removes corrupt files older than the specified age
|
||||
*/
|
||||
cleanupCorruptFiles(maxAgeMs: number = 7 * 24 * 60 * 60 * 1000): void {
|
||||
try {
|
||||
const dir = path.dirname(this.config.configFile);
|
||||
const baseName = path.basename(this.config.configFile);
|
||||
const prefix = `${baseName}.corrupt-`;
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const file = entry.name;
|
||||
|
||||
// Check if file matches pattern: baseName.corrupt-{timestamp}
|
||||
if (!file.startsWith(prefix)) continue;
|
||||
const suffix = file.slice(prefix.length);
|
||||
if (!/^\d+$/.test(suffix)) continue; // Fixed regex, not from variable input
|
||||
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
const age = now - stats.mtimeMs;
|
||||
|
||||
if (age > maxAgeMs) {
|
||||
fs.unlinkSync(filePath);
|
||||
this.logger.debug(`Cleaned up old corrupt file: ${file}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors for individual file cleanup
|
||||
this.logger.debug(
|
||||
`Could not clean up corrupt file ${file}: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't throw - this is a cleanup operation
|
||||
this.logger.debug(
|
||||
`Error during corrupt file cleanup: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/tm-core/src/auth/index.ts
Normal file
21
packages/tm-core/src/auth/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Authentication module exports
|
||||
*/
|
||||
|
||||
export { AuthManager } from './auth-manager.js';
|
||||
export { CredentialStore } from './credential-store.js';
|
||||
export { OAuthService } from './oauth-service.js';
|
||||
|
||||
export type {
|
||||
AuthCredentials,
|
||||
OAuthFlowOptions,
|
||||
AuthConfig,
|
||||
CliData
|
||||
} from './types.js';
|
||||
|
||||
export { AuthenticationError } from './types.js';
|
||||
|
||||
export {
|
||||
DEFAULT_AUTH_CONFIG,
|
||||
getAuthConfig
|
||||
} from './config.js';
|
||||
346
packages/tm-core/src/auth/oauth-service.ts
Normal file
346
packages/tm-core/src/auth/oauth-service.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* OAuth 2.0 Authorization Code Flow service
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
import crypto from 'crypto';
|
||||
import os from 'os';
|
||||
import {
|
||||
AuthCredentials,
|
||||
AuthenticationError,
|
||||
OAuthFlowOptions,
|
||||
AuthConfig,
|
||||
CliData
|
||||
} from './types.js';
|
||||
import { CredentialStore } from './credential-store.js';
|
||||
import { SupabaseAuthClient } from '../clients/supabase-client.js';
|
||||
import { getAuthConfig } from './config.js';
|
||||
import { getLogger } from '../logger/index.js';
|
||||
import packageJson from '../../../../package.json' with { type: 'json' };
|
||||
|
||||
export class OAuthService {
|
||||
private logger = getLogger('OAuthService');
|
||||
private credentialStore: CredentialStore;
|
||||
private supabaseClient: SupabaseAuthClient;
|
||||
private baseUrl: string;
|
||||
private authorizationUrl: string | null = null;
|
||||
private originalState: string | null = null;
|
||||
private authorizationReady: Promise<void> | null = null;
|
||||
private resolveAuthorizationReady: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
credentialStore: CredentialStore,
|
||||
config: Partial<AuthConfig> = {}
|
||||
) {
|
||||
this.credentialStore = credentialStore;
|
||||
this.supabaseClient = new SupabaseAuthClient();
|
||||
const authConfig = getAuthConfig(config);
|
||||
this.baseUrl = authConfig.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth 2.0 Authorization Code Flow with browser handling
|
||||
*/
|
||||
async authenticate(options: OAuthFlowOptions = {}): Promise<AuthCredentials> {
|
||||
const {
|
||||
openBrowser,
|
||||
timeout = 300000, // 5 minutes default
|
||||
onAuthUrl,
|
||||
onWaitingForAuth,
|
||||
onSuccess,
|
||||
onError
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Start the OAuth flow (starts local server)
|
||||
const authPromise = this.startFlow(timeout);
|
||||
|
||||
// Wait for server to be ready and URL to be generated
|
||||
if (this.authorizationReady) {
|
||||
await this.authorizationReady;
|
||||
}
|
||||
|
||||
// Get the authorization URL
|
||||
const authUrl = this.getAuthorizationUrl();
|
||||
|
||||
if (!authUrl) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to generate authorization URL',
|
||||
'URL_GENERATION_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
// Notify about the auth URL
|
||||
if (onAuthUrl) {
|
||||
onAuthUrl(authUrl);
|
||||
}
|
||||
|
||||
// Open browser if callback provided
|
||||
if (openBrowser) {
|
||||
try {
|
||||
await openBrowser(authUrl);
|
||||
this.logger.debug('Browser opened successfully with URL:', authUrl);
|
||||
} catch (error) {
|
||||
// Log the error but don't throw - user can still manually open the URL
|
||||
this.logger.warn('Failed to open browser automatically:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify that we're waiting for authentication
|
||||
if (onWaitingForAuth) {
|
||||
onWaitingForAuth();
|
||||
}
|
||||
|
||||
// Wait for authentication to complete
|
||||
const credentials = await authPromise;
|
||||
|
||||
// Notify success
|
||||
if (onSuccess) {
|
||||
onSuccess(credentials);
|
||||
}
|
||||
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
const authError =
|
||||
error instanceof AuthenticationError
|
||||
? error
|
||||
: new AuthenticationError(
|
||||
`OAuth authentication failed: ${(error as Error).message}`,
|
||||
'OAUTH_FAILED',
|
||||
error
|
||||
);
|
||||
|
||||
// Notify error
|
||||
if (onError) {
|
||||
onError(authError);
|
||||
}
|
||||
|
||||
throw authError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the OAuth flow (internal implementation)
|
||||
*/
|
||||
private async startFlow(timeout: number = 300000): Promise<AuthCredentials> {
|
||||
const state = this.generateState();
|
||||
|
||||
// Store the original state for verification
|
||||
this.originalState = state;
|
||||
|
||||
// Create a promise that will resolve when the server is ready
|
||||
this.authorizationReady = new Promise<void>((resolve) => {
|
||||
this.resolveAuthorizationReady = resolve;
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
// Create local HTTP server for OAuth callback
|
||||
const server = http.createServer();
|
||||
|
||||
// Start server on localhost only, bind to port 0 for automatic port assignment
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Failed to get server address'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
const callbackUrl = `http://localhost:${port}/callback`;
|
||||
|
||||
// Set up request handler after we know the port
|
||||
server.on('request', async (req, res) => {
|
||||
const url = new URL(req.url!, `http://127.0.0.1:${port}`);
|
||||
|
||||
if (url.pathname === '/callback') {
|
||||
await this.handleCallback(
|
||||
url,
|
||||
res,
|
||||
server,
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId
|
||||
);
|
||||
} else {
|
||||
// Handle other paths (favicon, etc.)
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Prepare CLI data object (server handles OAuth/PKCE)
|
||||
const cliData: CliData = {
|
||||
callback: callbackUrl,
|
||||
state: state,
|
||||
name: 'Task Master CLI',
|
||||
version: this.getCliVersion(),
|
||||
device: os.hostname(),
|
||||
user: os.userInfo().username,
|
||||
platform: os.platform(),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Build authorization URL for web app sign-in page
|
||||
const authUrl = new URL(`${this.baseUrl}/auth/sign-in`);
|
||||
|
||||
// Encode CLI data as base64
|
||||
const cliParam = Buffer.from(JSON.stringify(cliData)).toString(
|
||||
'base64'
|
||||
);
|
||||
|
||||
// Set the single CLI parameter with all encoded data
|
||||
authUrl.searchParams.append('cli', cliParam);
|
||||
|
||||
// Store auth URL for browser opening
|
||||
this.authorizationUrl = authUrl.toString();
|
||||
|
||||
this.logger.info(
|
||||
`OAuth session started - ${cliData.name} v${cliData.version} on port ${port}`
|
||||
);
|
||||
this.logger.debug('CLI data:', cliData);
|
||||
|
||||
// Signal that the server is ready and URL is available
|
||||
if (this.resolveAuthorizationReady) {
|
||||
this.resolveAuthorizationReady();
|
||||
this.resolveAuthorizationReady = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Set timeout for authentication
|
||||
timeoutId = setTimeout(() => {
|
||||
if (server.listening) {
|
||||
server.close();
|
||||
// Clean up the readiness promise if still pending
|
||||
if (this.resolveAuthorizationReady) {
|
||||
this.resolveAuthorizationReady();
|
||||
this.resolveAuthorizationReady = null;
|
||||
}
|
||||
reject(
|
||||
new AuthenticationError('Authentication timeout', 'AUTH_TIMEOUT')
|
||||
);
|
||||
}
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback
|
||||
*/
|
||||
private async handleCallback(
|
||||
url: URL,
|
||||
res: http.ServerResponse,
|
||||
server: http.Server,
|
||||
resolve: (value: AuthCredentials) => void,
|
||||
reject: (error: any) => void,
|
||||
timeoutId?: NodeJS.Timeout
|
||||
): Promise<void> {
|
||||
// Server now returns tokens directly instead of code
|
||||
const type = url.searchParams.get('type');
|
||||
const returnedState = url.searchParams.get('state');
|
||||
const accessToken = url.searchParams.get('access_token');
|
||||
const refreshToken = url.searchParams.get('refresh_token');
|
||||
const expiresIn = url.searchParams.get('expires_in');
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
// Server handles displaying success/failure, just close connection
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
|
||||
if (error) {
|
||||
if (server.listening) {
|
||||
server.close();
|
||||
}
|
||||
reject(
|
||||
new AuthenticationError(
|
||||
errorDescription || error || 'Authentication failed',
|
||||
'OAUTH_ERROR'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify state parameter for CSRF protection
|
||||
if (returnedState !== this.originalState) {
|
||||
if (server.listening) {
|
||||
server.close();
|
||||
}
|
||||
reject(
|
||||
new AuthenticationError('Invalid state parameter', 'INVALID_STATE')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle direct token response from server
|
||||
if (
|
||||
accessToken &&
|
||||
(type === 'oauth_success' || type === 'session_transfer')
|
||||
) {
|
||||
try {
|
||||
this.logger.info(`Received tokens via ${type}`);
|
||||
|
||||
// Get user info using the access token if possible
|
||||
const user = await this.supabaseClient.getUser(accessToken);
|
||||
|
||||
// Calculate expiration time
|
||||
const expiresAt = expiresIn
|
||||
? new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString()
|
||||
: undefined;
|
||||
|
||||
// Save authentication data
|
||||
const authData: AuthCredentials = {
|
||||
token: accessToken,
|
||||
refreshToken: refreshToken || undefined,
|
||||
userId: user?.id || 'unknown',
|
||||
email: user?.email,
|
||||
expiresAt: expiresAt,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.credentialStore.saveCredentials(authData);
|
||||
|
||||
if (server.listening) {
|
||||
server.close();
|
||||
}
|
||||
// Clear timeout since authentication succeeded
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
resolve(authData);
|
||||
} catch (error) {
|
||||
if (server.listening) {
|
||||
server.close();
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
if (server.listening) {
|
||||
server.close();
|
||||
}
|
||||
reject(new AuthenticationError('No access token received', 'NO_TOKEN'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate state for OAuth flow
|
||||
*/
|
||||
private generateState(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLI version from package.json if available
|
||||
*/
|
||||
private getCliVersion(): string {
|
||||
return packageJson.version || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL (for browser opening)
|
||||
*/
|
||||
getAuthorizationUrl(): string | null {
|
||||
return this.authorizationUrl;
|
||||
}
|
||||
}
|
||||
87
packages/tm-core/src/auth/types.ts
Normal file
87
packages/tm-core/src/auth/types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Authentication types and interfaces
|
||||
*/
|
||||
|
||||
export interface AuthCredentials {
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
userId: string;
|
||||
email?: string;
|
||||
expiresAt?: string | number;
|
||||
tokenType?: 'standard';
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
export interface OAuthFlowOptions {
|
||||
/** Callback to open the browser with the auth URL. If not provided, browser won't be opened */
|
||||
openBrowser?: (url: string) => Promise<void>;
|
||||
/** Timeout for the OAuth flow in milliseconds. Default: 300000 (5 minutes) */
|
||||
timeout?: number;
|
||||
/** Callback to be invoked with the authorization URL */
|
||||
onAuthUrl?: (url: string) => void;
|
||||
/** Callback to be invoked when waiting for authentication */
|
||||
onWaitingForAuth?: () => void;
|
||||
/** Callback to be invoked on successful authentication */
|
||||
onSuccess?: (credentials: AuthCredentials) => void;
|
||||
/** Callback to be invoked on authentication error */
|
||||
onError?: (error: AuthenticationError) => void;
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
baseUrl: string;
|
||||
configDir: string;
|
||||
configFile: string;
|
||||
}
|
||||
|
||||
export interface CliData {
|
||||
callback: string;
|
||||
state: string;
|
||||
name: string;
|
||||
version: string;
|
||||
device?: string;
|
||||
user?: string;
|
||||
platform?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error codes
|
||||
*/
|
||||
export type AuthErrorCode =
|
||||
| 'AUTH_TIMEOUT'
|
||||
| 'AUTH_EXPIRED'
|
||||
| 'OAUTH_FAILED'
|
||||
| 'OAUTH_ERROR'
|
||||
| 'OAUTH_CANCELED'
|
||||
| 'URL_GENERATION_FAILED'
|
||||
| 'INVALID_STATE'
|
||||
| 'NO_TOKEN'
|
||||
| 'TOKEN_EXCHANGE_FAILED'
|
||||
| 'INVALID_CREDENTIALS'
|
||||
| 'NO_REFRESH_TOKEN'
|
||||
| 'NOT_AUTHENTICATED'
|
||||
| 'NETWORK_ERROR'
|
||||
| 'CONFIG_MISSING'
|
||||
| 'SAVE_FAILED'
|
||||
| 'CLEAR_FAILED'
|
||||
| 'STORAGE_ERROR'
|
||||
| 'NOT_SUPPORTED'
|
||||
| 'REFRESH_FAILED'
|
||||
| 'INVALID_RESPONSE';
|
||||
|
||||
/**
|
||||
* Authentication error class
|
||||
*/
|
||||
export class AuthenticationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: AuthErrorCode,
|
||||
public cause?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
if (cause && cause instanceof Error) {
|
||||
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
packages/tm-core/src/clients/index.ts
Normal file
5
packages/tm-core/src/clients/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Client exports
|
||||
*/
|
||||
|
||||
export { SupabaseAuthClient } from './supabase-client.js';
|
||||
154
packages/tm-core/src/clients/supabase-client.ts
Normal file
154
packages/tm-core/src/clients/supabase-client.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Supabase client for authentication
|
||||
*/
|
||||
|
||||
import { createClient, SupabaseClient, User } from '@supabase/supabase-js';
|
||||
import { AuthenticationError } from '../auth/types.js';
|
||||
import { getLogger } from '../logger/index.js';
|
||||
|
||||
export class SupabaseAuthClient {
|
||||
private client: SupabaseClient | null = null;
|
||||
private logger = getLogger('SupabaseAuthClient');
|
||||
|
||||
/**
|
||||
* Initialize Supabase client
|
||||
*/
|
||||
private getClient(): SupabaseClient {
|
||||
if (!this.client) {
|
||||
// Get Supabase configuration from environment - using TM_PUBLIC prefix
|
||||
const supabaseUrl = process.env.TM_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.TM_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new AuthenticationError(
|
||||
'Supabase configuration missing. Please set TM_PUBLIC_SUPABASE_URL and TM_PUBLIC_SUPABASE_ANON_KEY environment variables.',
|
||||
'CONFIG_MISSING'
|
||||
);
|
||||
}
|
||||
|
||||
this.client = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
autoRefreshToken: true,
|
||||
persistSession: false, // We handle persistence ourselves
|
||||
detectSessionInUrl: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: Code exchange is now handled server-side
|
||||
* The server returns tokens directly to avoid PKCE issues
|
||||
* This method is kept for potential future use
|
||||
*/
|
||||
async exchangeCodeForSession(_code: string): Promise<{
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
userId: string;
|
||||
email?: string;
|
||||
expiresAt?: string;
|
||||
}> {
|
||||
throw new AuthenticationError(
|
||||
'Code exchange is handled server-side. CLI receives tokens directly.',
|
||||
'NOT_SUPPORTED'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token
|
||||
*/
|
||||
async refreshSession(refreshToken: string): Promise<{
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: string;
|
||||
}> {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
|
||||
this.logger.info('Refreshing session...');
|
||||
|
||||
// Set the session with refresh token
|
||||
const { data, error } = await client.auth.refreshSession({
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
if (error) {
|
||||
this.logger.error('Failed to refresh session:', error);
|
||||
throw new AuthenticationError(
|
||||
`Failed to refresh session: ${error.message}`,
|
||||
'REFRESH_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.session) {
|
||||
throw new AuthenticationError(
|
||||
'No session data returned',
|
||||
'INVALID_RESPONSE'
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info('Successfully refreshed session');
|
||||
|
||||
return {
|
||||
token: data.session.access_token,
|
||||
refreshToken: data.session.refresh_token,
|
||||
expiresAt: data.session.expires_at
|
||||
? new Date(data.session.expires_at * 1000).toISOString()
|
||||
: undefined
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AuthenticationError(
|
||||
`Failed to refresh session: ${(error as Error).message}`,
|
||||
'REFRESH_FAILED'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user details from token
|
||||
*/
|
||||
async getUser(token: string): Promise<User | null> {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
|
||||
// Get user with the token
|
||||
const { data, error } = await client.auth.getUser(token);
|
||||
|
||||
if (error) {
|
||||
this.logger.warn('Failed to get user:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.user;
|
||||
} catch (error) {
|
||||
this.logger.error('Error getting user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out (revoke tokens)
|
||||
* Note: This requires the user to be authenticated with the current session.
|
||||
* For remote token revocation, a server-side admin API with service_role key would be needed.
|
||||
*/
|
||||
async signOut(): Promise<void> {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
|
||||
// Sign out the current session with global scope to revoke all refresh tokens
|
||||
const { error } = await client.auth.signOut({ scope: 'global' });
|
||||
|
||||
if (error) {
|
||||
this.logger.warn('Failed to sign out:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Error during sign out:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ describe('ConfigManager', () => {
|
||||
|
||||
it('should return storage configuration', () => {
|
||||
const storage = manager.getStorageConfig();
|
||||
expect(storage).toEqual({ type: 'file' });
|
||||
expect(storage).toEqual({ type: 'auto', apiConfigured: false });
|
||||
});
|
||||
|
||||
it('should return API storage configuration when configured', async () => {
|
||||
@@ -206,7 +206,65 @@ describe('ConfigManager', () => {
|
||||
expect(storage).toEqual({
|
||||
type: 'api',
|
||||
apiEndpoint: 'https://api.example.com',
|
||||
apiAccessToken: 'token123'
|
||||
apiAccessToken: 'token123',
|
||||
apiConfigured: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should return auto storage configuration with apiConfigured flag', async () => {
|
||||
// Create a new instance with auto storage config and partial API settings
|
||||
vi.mocked(ConfigMerger).mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
addSource: vi.fn(),
|
||||
clearSources: vi.fn(),
|
||||
merge: vi.fn().mockReturnValue({
|
||||
storage: {
|
||||
type: 'auto',
|
||||
apiEndpoint: 'https://api.example.com'
|
||||
// No apiAccessToken - partial config
|
||||
}
|
||||
}),
|
||||
getSources: vi.fn().mockReturnValue([])
|
||||
}) as any
|
||||
);
|
||||
|
||||
const autoManager = await ConfigManager.create(testProjectRoot);
|
||||
|
||||
const storage = autoManager.getStorageConfig();
|
||||
expect(storage).toEqual({
|
||||
type: 'auto',
|
||||
apiEndpoint: 'https://api.example.com',
|
||||
apiAccessToken: undefined,
|
||||
apiConfigured: true // true because apiEndpoint is provided
|
||||
});
|
||||
});
|
||||
|
||||
it('should return auto storage with apiConfigured false when no API settings', async () => {
|
||||
// Create a new instance with auto storage but no API settings
|
||||
vi.mocked(ConfigMerger).mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
addSource: vi.fn(),
|
||||
clearSources: vi.fn(),
|
||||
merge: vi.fn().mockReturnValue({
|
||||
storage: {
|
||||
type: 'auto'
|
||||
// No API settings at all
|
||||
}
|
||||
}),
|
||||
getSources: vi.fn().mockReturnValue([])
|
||||
}) as any
|
||||
);
|
||||
|
||||
const autoManager = await ConfigManager.create(testProjectRoot);
|
||||
|
||||
const storage = autoManager.getStorageConfig();
|
||||
expect(storage).toEqual({
|
||||
type: 'auto',
|
||||
apiEndpoint: undefined,
|
||||
apiAccessToken: undefined,
|
||||
apiConfigured: false // false because no API settings
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,11 +309,11 @@ describe('ConfigManager', () => {
|
||||
expect(manager.getProjectRoot()).toBe(testProjectRoot);
|
||||
});
|
||||
|
||||
it('should check if using API storage', () => {
|
||||
expect(manager.isUsingApiStorage()).toBe(false);
|
||||
it('should check if API is explicitly configured', () => {
|
||||
expect(manager.isApiExplicitlyConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect API storage', () => {
|
||||
it('should detect when API is explicitly configured', () => {
|
||||
// Update config for current instance
|
||||
(manager as any).config = {
|
||||
storage: {
|
||||
@@ -265,7 +323,7 @@ describe('ConfigManager', () => {
|
||||
}
|
||||
};
|
||||
|
||||
expect(manager.isUsingApiStorage()).toBe(true);
|
||||
expect(manager.isApiExplicitlyConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* maintainability, testability, and separation of concerns.
|
||||
*/
|
||||
|
||||
import type { PartialConfiguration } from '../interfaces/configuration.interface.js';
|
||||
import type {
|
||||
PartialConfiguration,
|
||||
RuntimeStorageConfig
|
||||
} from '../interfaces/configuration.interface.js';
|
||||
import { ConfigLoader } from './services/config-loader.service.js';
|
||||
import {
|
||||
ConfigMerger,
|
||||
@@ -134,26 +137,28 @@ export class ConfigManager {
|
||||
/**
|
||||
* Get storage configuration
|
||||
*/
|
||||
getStorageConfig(): {
|
||||
type: 'file' | 'api';
|
||||
apiEndpoint?: string;
|
||||
apiAccessToken?: string;
|
||||
} {
|
||||
getStorageConfig(): RuntimeStorageConfig {
|
||||
const storage = this.config.storage;
|
||||
|
||||
if (
|
||||
storage?.type === 'api' &&
|
||||
storage.apiEndpoint &&
|
||||
storage.apiAccessToken
|
||||
) {
|
||||
// Return the configured type (including 'auto')
|
||||
const storageType = storage?.type || 'auto';
|
||||
const basePath = storage?.basePath ?? this.projectRoot;
|
||||
|
||||
if (storageType === 'api' || storageType === 'auto') {
|
||||
return {
|
||||
type: 'api',
|
||||
apiEndpoint: storage.apiEndpoint,
|
||||
apiAccessToken: storage.apiAccessToken
|
||||
type: storageType,
|
||||
basePath,
|
||||
apiEndpoint: storage?.apiEndpoint,
|
||||
apiAccessToken: storage?.apiAccessToken,
|
||||
apiConfigured: Boolean(storage?.apiEndpoint || storage?.apiAccessToken)
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'file' };
|
||||
return {
|
||||
type: storageType,
|
||||
basePath,
|
||||
apiConfigured: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,9 +189,10 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using API storage
|
||||
* Check if explicitly configured to use API storage
|
||||
* Excludes 'auto' type
|
||||
*/
|
||||
isUsingApiStorage(): boolean {
|
||||
isApiExplicitlyConfigured(): boolean {
|
||||
return this.getStorageConfig().type === 'api';
|
||||
}
|
||||
|
||||
@@ -219,6 +225,7 @@ export class ConfigManager {
|
||||
await this.persistence.saveConfig(this.config);
|
||||
|
||||
// Re-initialize to respect precedence
|
||||
this.initialized = false;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
@@ -269,12 +276,4 @@ export class ConfigManager {
|
||||
getConfigSources() {
|
||||
return this.merger.getSources();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for configuration changes (placeholder for future)
|
||||
*/
|
||||
watch(_callback: (config: PartialConfiguration) => void): () => void {
|
||||
console.warn('Configuration watching not yet implemented');
|
||||
return () => {}; // Return no-op unsubscribe function
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,11 @@ describe('EnvironmentConfigProvider', () => {
|
||||
provider = new EnvironmentConfigProvider(); // Reset provider
|
||||
config = provider.loadConfig();
|
||||
expect(config.storage?.type).toBe('api');
|
||||
|
||||
process.env.TASKMASTER_STORAGE_TYPE = 'auto';
|
||||
provider = new EnvironmentConfigProvider(); // Reset provider
|
||||
config = provider.loadConfig();
|
||||
expect(config.storage?.type).toBe('auto');
|
||||
});
|
||||
|
||||
it('should handle nested configuration paths', () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export class EnvironmentConfigProvider {
|
||||
{
|
||||
env: 'TASKMASTER_STORAGE_TYPE',
|
||||
path: ['storage', 'type'],
|
||||
validate: (v: string) => ['file', 'api'].includes(v)
|
||||
validate: (v: string) => ['file', 'api', 'auto'].includes(v)
|
||||
},
|
||||
{ env: 'TASKMASTER_API_ENDPOINT', path: ['storage', 'apiEndpoint'] },
|
||||
{ env: 'TASKMASTER_API_TOKEN', path: ['storage', 'apiAccessToken'] },
|
||||
|
||||
@@ -9,19 +9,19 @@ export {
|
||||
createTaskMasterCore,
|
||||
type TaskMasterCoreOptions,
|
||||
type ListTasksResult
|
||||
} from './task-master-core';
|
||||
} from './task-master-core.js';
|
||||
|
||||
// Re-export types
|
||||
export type * from './types';
|
||||
export type * from './types/index.js';
|
||||
|
||||
// Re-export interfaces (types only to avoid conflicts)
|
||||
export type * from './interfaces';
|
||||
export type * from './interfaces/index.js';
|
||||
|
||||
// Re-export constants
|
||||
export * from './constants';
|
||||
export * from './constants/index.js';
|
||||
|
||||
// Re-export providers
|
||||
export * from './providers';
|
||||
export * from './providers/index.js';
|
||||
|
||||
// Re-export storage (selectively to avoid conflicts)
|
||||
export {
|
||||
@@ -29,17 +29,29 @@ export {
|
||||
ApiStorage,
|
||||
StorageFactory,
|
||||
type ApiStorageConfig
|
||||
} from './storage';
|
||||
export { PlaceholderStorage, type StorageAdapter } from './storage';
|
||||
} from './storage/index.js';
|
||||
export { PlaceholderStorage, type StorageAdapter } from './storage/index.js';
|
||||
|
||||
// Re-export parser
|
||||
export * from './parser';
|
||||
export * from './parser/index.js';
|
||||
|
||||
// Re-export utilities
|
||||
export * from './utils';
|
||||
export * from './utils/index.js';
|
||||
|
||||
// Re-export errors
|
||||
export * from './errors';
|
||||
export * from './errors/index.js';
|
||||
|
||||
// Re-export entities
|
||||
export { TaskEntity } from './entities/task.entity';
|
||||
export { TaskEntity } from './entities/task.entity.js';
|
||||
|
||||
// Re-export authentication
|
||||
export {
|
||||
AuthManager,
|
||||
AuthenticationError,
|
||||
type AuthCredentials,
|
||||
type OAuthFlowOptions,
|
||||
type AuthConfig
|
||||
} from './auth/index.js';
|
||||
|
||||
// Re-export logger
|
||||
export { getLogger, createLogger, setGlobalLogger } from './logger/index.js';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* This file defines the contract for configuration management
|
||||
*/
|
||||
|
||||
import type { TaskComplexity, TaskPriority } from '../types/index';
|
||||
import type { TaskComplexity, TaskPriority } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Model configuration for different AI roles
|
||||
@@ -74,17 +74,48 @@ export interface TagSettings {
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage and persistence settings
|
||||
* Storage type options
|
||||
* - 'file': Local file system storage
|
||||
* - 'api': Remote API storage (Hamster integration)
|
||||
* - 'auto': Automatically detect based on auth status
|
||||
*/
|
||||
export interface StorageSettings {
|
||||
export type StorageType = 'file' | 'api' | 'auto';
|
||||
|
||||
/**
|
||||
* Runtime storage configuration used for storage backend selection
|
||||
* This is what getStorageConfig() returns and what StorageFactory expects
|
||||
*/
|
||||
export interface RuntimeStorageConfig {
|
||||
/** Storage backend type */
|
||||
type: 'file' | 'api';
|
||||
/** Base path for file storage */
|
||||
type: StorageType;
|
||||
/** Base path for file storage (if configured) */
|
||||
basePath?: string;
|
||||
/** API endpoint for API storage (Hamster integration) */
|
||||
apiEndpoint?: string;
|
||||
/** Access token for API authentication */
|
||||
apiAccessToken?: string;
|
||||
/**
|
||||
* Indicates whether API is configured (has endpoint or token)
|
||||
* @computed Derived automatically from presence of apiEndpoint or apiAccessToken
|
||||
* @internal Should not be set manually - computed by ConfigManager
|
||||
*/
|
||||
readonly apiConfigured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage and persistence settings
|
||||
* Extended storage settings including file operation preferences
|
||||
*/
|
||||
export interface StorageSettings
|
||||
extends Omit<RuntimeStorageConfig, 'apiConfigured'> {
|
||||
/** Base path for file storage */
|
||||
basePath?: string;
|
||||
/**
|
||||
* Indicates whether API is configured
|
||||
* @computed Derived automatically from presence of apiEndpoint or apiAccessToken
|
||||
* @internal Should not be set manually in user config - computed by ConfigManager
|
||||
*/
|
||||
readonly apiConfigured?: boolean;
|
||||
/** Enable automatic backups */
|
||||
enableBackup: boolean;
|
||||
/** Maximum number of backups to retain */
|
||||
@@ -388,7 +419,7 @@ export const DEFAULT_CONFIG_VALUES = {
|
||||
NAMING_CONVENTION: 'kebab-case' as const
|
||||
},
|
||||
STORAGE: {
|
||||
TYPE: 'file' as const,
|
||||
TYPE: 'auto' as const,
|
||||
ENCODING: 'utf8' as BufferEncoding,
|
||||
MAX_BACKUPS: 5
|
||||
},
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
*/
|
||||
|
||||
// Storage interfaces
|
||||
export type * from './storage.interface';
|
||||
export * from './storage.interface';
|
||||
export type * from './storage.interface.js';
|
||||
export * from './storage.interface.js';
|
||||
|
||||
// AI Provider interfaces
|
||||
export type * from './ai-provider.interface';
|
||||
export * from './ai-provider.interface';
|
||||
export type * from './ai-provider.interface.js';
|
||||
export * from './ai-provider.interface.js';
|
||||
|
||||
// Configuration interfaces
|
||||
export type * from './configuration.interface';
|
||||
export * from './configuration.interface';
|
||||
export type * from './configuration.interface.js';
|
||||
export * from './configuration.interface.js';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* This file defines the contract for all storage implementations
|
||||
*/
|
||||
|
||||
import type { Task, TaskMetadata } from '../types/index';
|
||||
import type { Task, TaskMetadata } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Interface for storage operations on tasks
|
||||
|
||||
59
packages/tm-core/src/logger/factory.ts
Normal file
59
packages/tm-core/src/logger/factory.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @fileoverview Logger factory and singleton management
|
||||
*/
|
||||
|
||||
import { Logger, type LoggerConfig } from './logger.js';
|
||||
|
||||
// Global logger instance
|
||||
let globalLogger: Logger | null = null;
|
||||
|
||||
// Named logger instances
|
||||
const loggers = new Map<string, Logger>();
|
||||
|
||||
/**
|
||||
* Create a new logger instance
|
||||
*/
|
||||
export function createLogger(config?: LoggerConfig): Logger {
|
||||
return new Logger(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a named logger instance
|
||||
*/
|
||||
export function getLogger(name?: string, config?: LoggerConfig): Logger {
|
||||
// If no name provided, return global logger
|
||||
if (!name) {
|
||||
if (!globalLogger) {
|
||||
globalLogger = createLogger(config);
|
||||
}
|
||||
return globalLogger;
|
||||
}
|
||||
|
||||
// Check if named logger exists
|
||||
if (!loggers.has(name)) {
|
||||
loggers.set(
|
||||
name,
|
||||
createLogger({
|
||||
prefix: name,
|
||||
...config
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return loggers.get(name)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the global logger instance
|
||||
*/
|
||||
export function setGlobalLogger(logger: Logger): void {
|
||||
globalLogger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all logger instances (useful for testing)
|
||||
*/
|
||||
export function clearLoggers(): void {
|
||||
globalLogger = null;
|
||||
loggers.clear();
|
||||
}
|
||||
8
packages/tm-core/src/logger/index.ts
Normal file
8
packages/tm-core/src/logger/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @fileoverview Logger package for Task Master
|
||||
* Provides centralized logging with support for different modes and levels
|
||||
*/
|
||||
|
||||
export { Logger, LogLevel } from './logger.js';
|
||||
export type { LoggerConfig } from './logger.js';
|
||||
export { createLogger, getLogger, setGlobalLogger } from './factory.js';
|
||||
242
packages/tm-core/src/logger/logger.ts
Normal file
242
packages/tm-core/src/logger/logger.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @fileoverview Core logger implementation
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
export enum LogLevel {
|
||||
SILENT = 0,
|
||||
ERROR = 1,
|
||||
WARN = 2,
|
||||
INFO = 3,
|
||||
DEBUG = 4
|
||||
}
|
||||
|
||||
export interface LoggerConfig {
|
||||
level?: LogLevel;
|
||||
silent?: boolean;
|
||||
prefix?: string;
|
||||
timestamp?: boolean;
|
||||
colors?: boolean;
|
||||
// MCP mode silences all output
|
||||
mcpMode?: boolean;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private config: Required<LoggerConfig>;
|
||||
private static readonly DEFAULT_CONFIG: Required<LoggerConfig> = {
|
||||
level: LogLevel.WARN,
|
||||
silent: false,
|
||||
prefix: '',
|
||||
timestamp: false,
|
||||
colors: true,
|
||||
mcpMode: false
|
||||
};
|
||||
|
||||
constructor(config: LoggerConfig = {}) {
|
||||
// Check environment variables
|
||||
const envConfig: LoggerConfig = {};
|
||||
|
||||
// Check for MCP mode
|
||||
if (
|
||||
process.env.MCP_MODE === 'true' ||
|
||||
process.env.TASK_MASTER_MCP === 'true'
|
||||
) {
|
||||
envConfig.mcpMode = true;
|
||||
}
|
||||
|
||||
// Check for silent mode
|
||||
if (
|
||||
process.env.TASK_MASTER_SILENT === 'true' ||
|
||||
process.env.TM_SILENT === 'true'
|
||||
) {
|
||||
envConfig.silent = true;
|
||||
}
|
||||
|
||||
// Check for log level
|
||||
if (process.env.TASK_MASTER_LOG_LEVEL || process.env.TM_LOG_LEVEL) {
|
||||
const levelStr = (
|
||||
process.env.TASK_MASTER_LOG_LEVEL ||
|
||||
process.env.TM_LOG_LEVEL ||
|
||||
''
|
||||
).toUpperCase();
|
||||
if (levelStr in LogLevel) {
|
||||
envConfig.level = LogLevel[levelStr as keyof typeof LogLevel];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for no colors
|
||||
if (
|
||||
process.env.NO_COLOR === 'true' ||
|
||||
process.env.TASK_MASTER_NO_COLOR === 'true'
|
||||
) {
|
||||
envConfig.colors = false;
|
||||
}
|
||||
|
||||
// Merge configs: defaults < constructor < environment
|
||||
this.config = {
|
||||
...Logger.DEFAULT_CONFIG,
|
||||
...config,
|
||||
...envConfig
|
||||
};
|
||||
|
||||
// MCP mode overrides everything to be silent
|
||||
if (this.config.mcpMode) {
|
||||
this.config.silent = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logging is enabled for a given level
|
||||
*/
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
if (this.config.silent || this.config.mcpMode) {
|
||||
return false;
|
||||
}
|
||||
return level <= this.config.level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a log message
|
||||
*/
|
||||
private formatMessage(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
...args: any[]
|
||||
): string {
|
||||
let formatted = '';
|
||||
|
||||
// Add timestamp if enabled
|
||||
if (this.config.timestamp) {
|
||||
const timestamp = new Date().toISOString();
|
||||
formatted += this.config.colors
|
||||
? chalk.gray(`[${timestamp}] `)
|
||||
: `[${timestamp}] `;
|
||||
}
|
||||
|
||||
// Add prefix if configured
|
||||
if (this.config.prefix) {
|
||||
formatted += this.config.colors
|
||||
? chalk.cyan(`[${this.config.prefix}] `)
|
||||
: `[${this.config.prefix}] `;
|
||||
}
|
||||
|
||||
// Skip level indicator for cleaner output
|
||||
// We can still color the message based on level
|
||||
if (this.config.colors) {
|
||||
switch (level) {
|
||||
case LogLevel.ERROR:
|
||||
message = chalk.red(message);
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
message = chalk.yellow(message);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
// Info stays default color
|
||||
break;
|
||||
case LogLevel.DEBUG:
|
||||
message = chalk.gray(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the message
|
||||
formatted += message;
|
||||
|
||||
// Add any additional arguments
|
||||
if (args.length > 0) {
|
||||
formatted +=
|
||||
' ' +
|
||||
args
|
||||
.map((arg) =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
*/
|
||||
error(message: string, ...args: any[]): void {
|
||||
if (!this.shouldLog(LogLevel.ERROR)) return;
|
||||
console.error(this.formatMessage(LogLevel.ERROR, message, ...args));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning message
|
||||
*/
|
||||
warn(message: string, ...args: any[]): void {
|
||||
if (!this.shouldLog(LogLevel.WARN)) return;
|
||||
console.warn(this.formatMessage(LogLevel.WARN, message, ...args));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an info message
|
||||
*/
|
||||
info(message: string, ...args: any[]): void {
|
||||
if (!this.shouldLog(LogLevel.INFO)) return;
|
||||
console.log(this.formatMessage(LogLevel.INFO, message, ...args));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message
|
||||
*/
|
||||
debug(message: string, ...args: any[]): void {
|
||||
if (!this.shouldLog(LogLevel.DEBUG)) return;
|
||||
console.log(this.formatMessage(LogLevel.DEBUG, message, ...args));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message without any formatting (raw output)
|
||||
* Useful for CLI output that should appear as-is
|
||||
*/
|
||||
log(message: string, ...args: any[]): void {
|
||||
if (this.config.silent || this.config.mcpMode) return;
|
||||
|
||||
if (args.length > 0) {
|
||||
console.log(message, ...args);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update logger configuration
|
||||
*/
|
||||
setConfig(config: Partial<LoggerConfig>): void {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...config
|
||||
};
|
||||
|
||||
// MCP mode always overrides to silent
|
||||
if (this.config.mcpMode) {
|
||||
this.config.silent = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): Readonly<Required<LoggerConfig>> {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with a prefix
|
||||
*/
|
||||
child(prefix: string, config?: Partial<LoggerConfig>): Logger {
|
||||
const childPrefix = this.config.prefix
|
||||
? `${this.config.prefix}:${prefix}`
|
||||
: prefix;
|
||||
|
||||
return new Logger({
|
||||
...this.config,
|
||||
...config,
|
||||
prefix: childPrefix
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* This file exports all parsing-related classes and functions
|
||||
*/
|
||||
|
||||
import type { PlaceholderTask } from '../types/index';
|
||||
import type { PlaceholderTask } from '../types/index.js';
|
||||
|
||||
// Parser implementations will be defined here
|
||||
// export * from './prd-parser.js';
|
||||
|
||||
6
packages/tm-core/src/services/index.ts
Normal file
6
packages/tm-core/src/services/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Services module exports
|
||||
* Provides business logic and service layer functionality
|
||||
*/
|
||||
|
||||
export { TaskService } from './task-service.js';
|
||||
@@ -22,8 +22,8 @@ export interface TaskListResult {
|
||||
filtered: number;
|
||||
/** The tag these tasks belong to (only present if explicitly provided) */
|
||||
tag?: string;
|
||||
/** Storage type being used */
|
||||
storageType: 'file' | 'api';
|
||||
/** Storage type being used - includes 'auto' for automatic detection */
|
||||
storageType: 'file' | 'api' | 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,8 +64,8 @@ export class TaskService {
|
||||
const storageConfig = this.configManager.getStorageConfig();
|
||||
const projectRoot = this.configManager.getProjectRoot();
|
||||
|
||||
this.storage = StorageFactory.create(
|
||||
{ storage: storageConfig } as any,
|
||||
this.storage = StorageFactory.createFromStorageConfig(
|
||||
storageConfig,
|
||||
projectRoot
|
||||
);
|
||||
|
||||
@@ -166,7 +166,7 @@ export class TaskService {
|
||||
byStatus: Record<TaskStatus, number>;
|
||||
withSubtasks: number;
|
||||
blocked: number;
|
||||
storageType: 'file' | 'api';
|
||||
storageType: 'file' | 'api' | 'auto';
|
||||
}> {
|
||||
const result = await this.getTaskList({
|
||||
tag,
|
||||
@@ -334,7 +334,7 @@ export class TaskService {
|
||||
/**
|
||||
* Get current storage type
|
||||
*/
|
||||
getStorageType(): 'file' | 'api' {
|
||||
getStorageType(): 'file' | 'api' | 'auto' {
|
||||
return this.configManager.getStorageConfig().type;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,40 @@
|
||||
*/
|
||||
|
||||
import type { IStorage } from '../interfaces/storage.interface.js';
|
||||
import type { IConfiguration } from '../interfaces/configuration.interface.js';
|
||||
import { FileStorage } from './file-storage';
|
||||
import type {
|
||||
IConfiguration,
|
||||
RuntimeStorageConfig,
|
||||
StorageSettings
|
||||
} from '../interfaces/configuration.interface.js';
|
||||
import { FileStorage } from './file-storage/index.js';
|
||||
import { ApiStorage } from './api-storage.js';
|
||||
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
||||
import { AuthManager } from '../auth/auth-manager.js';
|
||||
import { getLogger } from '../logger/index.js';
|
||||
|
||||
/**
|
||||
* Factory for creating storage implementations based on configuration
|
||||
*/
|
||||
export class StorageFactory {
|
||||
/**
|
||||
* Create a storage implementation from runtime storage config
|
||||
* This is the preferred method when you have a RuntimeStorageConfig
|
||||
* @param storageConfig - Runtime storage configuration
|
||||
* @param projectPath - Project root path (for file storage)
|
||||
* @returns Storage implementation
|
||||
*/
|
||||
static createFromStorageConfig(
|
||||
storageConfig: RuntimeStorageConfig,
|
||||
projectPath: string
|
||||
): IStorage {
|
||||
// Wrap the storage config in the expected format, including projectPath
|
||||
// This ensures ApiStorage receives the projectPath for projectId
|
||||
return StorageFactory.create(
|
||||
{ storage: storageConfig, projectPath } as Partial<IConfiguration>,
|
||||
projectPath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a storage implementation based on configuration
|
||||
* @param config - Configuration object
|
||||
@@ -22,15 +47,83 @@ export class StorageFactory {
|
||||
config: Partial<IConfiguration>,
|
||||
projectPath: string
|
||||
): IStorage {
|
||||
const storageType = config.storage?.type || 'file';
|
||||
const storageType = config.storage?.type || 'auto';
|
||||
|
||||
const logger = getLogger('StorageFactory');
|
||||
|
||||
switch (storageType) {
|
||||
case 'file':
|
||||
logger.debug('📁 Using local file storage');
|
||||
return StorageFactory.createFileStorage(projectPath, config);
|
||||
|
||||
case 'api':
|
||||
if (!StorageFactory.isHamsterAvailable(config)) {
|
||||
const missing: string[] = [];
|
||||
if (!config.storage?.apiEndpoint) missing.push('apiEndpoint');
|
||||
if (!config.storage?.apiAccessToken) missing.push('apiAccessToken');
|
||||
|
||||
// Check if authenticated via AuthManager
|
||||
const authManager = AuthManager.getInstance();
|
||||
if (!authManager.isAuthenticated()) {
|
||||
throw new TaskMasterError(
|
||||
`API storage not fully configured (${missing.join(', ') || 'credentials missing'}). Run: tm auth login, or set the missing field(s).`,
|
||||
ERROR_CODES.MISSING_CONFIGURATION,
|
||||
{ storageType: 'api', missing }
|
||||
);
|
||||
}
|
||||
// Use auth token from AuthManager
|
||||
const credentials = authManager.getCredentials();
|
||||
if (credentials) {
|
||||
// Merge with existing storage config, ensuring required fields
|
||||
const nextStorage: StorageSettings = {
|
||||
...(config.storage as StorageSettings),
|
||||
type: 'api',
|
||||
apiAccessToken: credentials.token,
|
||||
apiEndpoint:
|
||||
config.storage?.apiEndpoint ||
|
||||
process.env.HAMSTER_API_URL ||
|
||||
'https://tryhamster.com/api'
|
||||
};
|
||||
config.storage = nextStorage;
|
||||
}
|
||||
}
|
||||
logger.info('☁️ Using API storage');
|
||||
return StorageFactory.createApiStorage(config);
|
||||
|
||||
case 'auto':
|
||||
// Auto-detect based on authentication status
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
// First check if API credentials are explicitly configured
|
||||
if (StorageFactory.isHamsterAvailable(config)) {
|
||||
logger.info('☁️ Using API storage (configured)');
|
||||
return StorageFactory.createApiStorage(config);
|
||||
}
|
||||
|
||||
// Then check if authenticated via AuthManager
|
||||
if (authManager.isAuthenticated()) {
|
||||
const credentials = authManager.getCredentials();
|
||||
if (credentials) {
|
||||
// Configure API storage with auth credentials
|
||||
const nextStorage: StorageSettings = {
|
||||
...(config.storage as StorageSettings),
|
||||
type: 'api',
|
||||
apiAccessToken: credentials.token,
|
||||
apiEndpoint:
|
||||
config.storage?.apiEndpoint ||
|
||||
process.env.HAMSTER_API_URL ||
|
||||
'https://tryhamster.com/api'
|
||||
};
|
||||
config.storage = nextStorage;
|
||||
logger.info('☁️ Using API storage (authenticated)');
|
||||
return StorageFactory.createApiStorage(config);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to file storage
|
||||
logger.debug('📁 Using local file storage');
|
||||
return StorageFactory.createFileStorage(projectPath, config);
|
||||
|
||||
default:
|
||||
throw new TaskMasterError(
|
||||
`Unknown storage type: ${storageType}`,
|
||||
@@ -125,6 +218,11 @@ export class StorageFactory {
|
||||
// File storage doesn't require additional config
|
||||
break;
|
||||
|
||||
case 'auto':
|
||||
// Auto storage is valid - it will determine the actual type at runtime
|
||||
// No specific validation needed as it will fall back to file if API not configured
|
||||
break;
|
||||
|
||||
default:
|
||||
errors.push(`Unknown storage type: ${storageType}`);
|
||||
}
|
||||
@@ -157,7 +255,8 @@ export class StorageFactory {
|
||||
await apiStorage.initialize();
|
||||
return apiStorage;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
const logger = getLogger('StorageFactory');
|
||||
logger.warn(
|
||||
'Failed to initialize API storage, falling back to file storage:',
|
||||
error
|
||||
);
|
||||
|
||||
99
packages/tm-core/src/subpath-exports.test.ts
Normal file
99
packages/tm-core/src/subpath-exports.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Test file documenting subpath export usage
|
||||
* This demonstrates how consumers can use granular imports for better tree-shaking
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('Subpath Exports', () => {
|
||||
it('should allow importing from auth subpath', async () => {
|
||||
// Instead of: import { AuthManager } from '@tm/core';
|
||||
// Use: import { AuthManager } from '@tm/core/auth';
|
||||
const authModule = await import('./auth');
|
||||
expect(authModule.AuthManager).toBeDefined();
|
||||
expect(authModule.AuthenticationError).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow importing from storage subpath', async () => {
|
||||
// Instead of: import { FileStorage } from '@tm/core';
|
||||
// Use: import { FileStorage } from '@tm/core/storage';
|
||||
const storageModule = await import('./storage');
|
||||
expect(storageModule.FileStorage).toBeDefined();
|
||||
expect(storageModule.ApiStorage).toBeDefined();
|
||||
expect(storageModule.StorageFactory).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow importing from config subpath', async () => {
|
||||
// Instead of: import { ConfigManager } from '@tm/core';
|
||||
// Use: import { ConfigManager } from '@tm/core/config';
|
||||
const configModule = await import('./config');
|
||||
expect(configModule.ConfigManager).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow importing from errors subpath', async () => {
|
||||
// Instead of: import { TaskMasterError } from '@tm/core';
|
||||
// Use: import { TaskMasterError } from '@tm/core/errors';
|
||||
const errorsModule = await import('./errors');
|
||||
expect(errorsModule.TaskMasterError).toBeDefined();
|
||||
expect(errorsModule.ERROR_CODES).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow importing from logger subpath', async () => {
|
||||
// Instead of: import { getLogger } from '@tm/core';
|
||||
// Use: import { getLogger } from '@tm/core/logger';
|
||||
const loggerModule = await import('./logger');
|
||||
expect(loggerModule.getLogger).toBeDefined();
|
||||
expect(loggerModule.createLogger).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow importing from providers subpath', async () => {
|
||||
// Instead of: import { BaseProvider } from '@tm/core';
|
||||
// Use: import { BaseProvider } from '@tm/core/providers';
|
||||
const providersModule = await import('./providers');
|
||||
expect(providersModule.BaseProvider).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow importing from services subpath', async () => {
|
||||
// Instead of: import { TaskService } from '@tm/core';
|
||||
// Use: import { TaskService } from '@tm/core/services';
|
||||
const servicesModule = await import('./services');
|
||||
expect(servicesModule.TaskService).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow importing from utils subpath', async () => {
|
||||
// Instead of: import { generateId } from '@tm/core';
|
||||
// Use: import { generateId } from '@tm/core/utils';
|
||||
const utilsModule = await import('./utils');
|
||||
expect(utilsModule.generateId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Usage Examples for Consumers:
|
||||
*
|
||||
* 1. Import only authentication (smaller bundle):
|
||||
* ```typescript
|
||||
* import { AuthManager, AuthenticationError } from '@tm/core/auth';
|
||||
* ```
|
||||
*
|
||||
* 2. Import only storage (no auth code bundled):
|
||||
* ```typescript
|
||||
* import { FileStorage, StorageFactory } from '@tm/core/storage';
|
||||
* ```
|
||||
*
|
||||
* 3. Import only errors (minimal bundle):
|
||||
* ```typescript
|
||||
* import { TaskMasterError, ERROR_CODES } from '@tm/core/errors';
|
||||
* ```
|
||||
*
|
||||
* 4. Still support convenience imports (larger bundle but better DX):
|
||||
* ```typescript
|
||||
* import { AuthManager, FileStorage, TaskMasterError } from '@tm/core';
|
||||
* ```
|
||||
*
|
||||
* Benefits:
|
||||
* - Better tree-shaking: unused modules are not bundled
|
||||
* - Clearer dependencies: explicit about what parts of the library you use
|
||||
* - Faster builds: bundlers can optimize better with granular imports
|
||||
* - Smaller bundles: especially important for browser/edge deployments
|
||||
*/
|
||||
@@ -152,7 +152,7 @@ export class TaskMasterCore {
|
||||
/**
|
||||
* Get current storage type
|
||||
*/
|
||||
getStorageType(): 'file' | 'api' {
|
||||
getStorageType(): 'file' | 'api' | 'auto' {
|
||||
return this.taskService.getStorageType();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,29 +3,17 @@
|
||||
* This file exports all utility functions and helper classes
|
||||
*/
|
||||
|
||||
// Utility implementations will be defined here
|
||||
// export * from './validation.js';
|
||||
// export * from './formatting.js';
|
||||
// export * from './file-utils.js';
|
||||
// export * from './async-utils.js';
|
||||
// Export ID generation utilities
|
||||
export {
|
||||
generateTaskId as generateId, // Alias for backward compatibility
|
||||
generateTaskId,
|
||||
generateSubtaskId,
|
||||
isValidTaskId,
|
||||
isValidSubtaskId,
|
||||
getParentTaskId
|
||||
} from './id-generator.js';
|
||||
|
||||
// Placeholder exports - these will be implemented in later tasks
|
||||
|
||||
/**
|
||||
* Generates a unique ID for tasks
|
||||
* @deprecated This is a placeholder function that will be properly implemented in later tasks
|
||||
*/
|
||||
export function generateTaskId(): string {
|
||||
return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a task ID format
|
||||
* @deprecated This is a placeholder function that will be properly implemented in later tasks
|
||||
*/
|
||||
export function isValidTaskId(id: string): boolean {
|
||||
return typeof id === 'string' && id.length > 0;
|
||||
}
|
||||
// Additional utility exports
|
||||
|
||||
/**
|
||||
* Formats a date for task timestamps
|
||||
|
||||
@@ -23,17 +23,24 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"types": ["node"],
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/types": ["./src/types"],
|
||||
"@/providers": ["./src/providers"],
|
||||
"@/storage": ["./src/storage"],
|
||||
"@/auth": ["./src/auth"],
|
||||
"@/config": ["./src/config"],
|
||||
"@/errors": ["./src/errors"],
|
||||
"@/interfaces": ["./src/interfaces"],
|
||||
"@/logger": ["./src/logger"],
|
||||
"@/parser": ["./src/parser"],
|
||||
"@/utils": ["./src/utils"],
|
||||
"@/errors": ["./src/errors"]
|
||||
"@/providers": ["./src/providers"],
|
||||
"@/services": ["./src/services"],
|
||||
"@/storage": ["./src/storage"],
|
||||
"@/types": ["./src/types"],
|
||||
"@/utils": ["./src/utils"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { dotenvLoad } from 'dotenv-mono';
|
||||
dotenvLoad();
|
||||
|
||||
// Get all TM_PUBLIC_* env variables for build-time injection
|
||||
const getBuildTimeEnvs = () => {
|
||||
const envs: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith('TM_PUBLIC_')) {
|
||||
// Return the actual value, not JSON.stringify'd
|
||||
envs[key] = value || '';
|
||||
}
|
||||
}
|
||||
return envs;
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
'types/index': 'src/types/index.ts',
|
||||
'providers/index': 'src/providers/index.ts',
|
||||
'storage/index': 'src/storage/index.ts',
|
||||
'auth/index': 'src/auth/index.ts',
|
||||
'config/index': 'src/config/index.ts',
|
||||
'errors/index': 'src/errors/index.ts',
|
||||
'interfaces/index': 'src/interfaces/index.ts',
|
||||
'logger/index': 'src/logger/index.ts',
|
||||
'parser/index': 'src/parser/index.ts',
|
||||
'utils/index': 'src/utils/index.ts',
|
||||
'errors/index': 'src/errors/index.ts'
|
||||
'providers/index': 'src/providers/index.ts',
|
||||
'services/index': 'src/services/index.ts',
|
||||
'storage/index': 'src/storage/index.ts',
|
||||
'types/index': 'src/types/index.ts',
|
||||
'utils/index': 'src/utils/index.ts'
|
||||
},
|
||||
format: ['cjs', 'esm'],
|
||||
dts: true,
|
||||
@@ -20,7 +39,13 @@ export default defineConfig({
|
||||
target: 'es2022',
|
||||
tsconfig: './tsconfig.json',
|
||||
outDir: 'dist',
|
||||
external: ['zod'],
|
||||
// Replace process.env.TM_PUBLIC_* with actual values at build time
|
||||
env: getBuildTimeEnvs(),
|
||||
// Auto-external all dependencies from package.json
|
||||
external: [
|
||||
// External all node_modules - everything not starting with . or /
|
||||
/^[^./]/
|
||||
],
|
||||
esbuildOptions(options) {
|
||||
options.conditions = ['module'];
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import search from '@inquirer/search';
|
||||
import ora from 'ora'; // Import ora
|
||||
|
||||
import { log, readJSON } from './utils.js';
|
||||
// Import new ListTasksCommand from @tm/cli
|
||||
import { ListTasksCommand } from '@tm/cli';
|
||||
// Import new commands from @tm/cli
|
||||
import { ListTasksCommand, AuthCommand } from '@tm/cli';
|
||||
|
||||
import {
|
||||
parsePRD,
|
||||
@@ -1740,6 +1740,11 @@ function registerCommands(programInstance) {
|
||||
// NEW: Register the new list command from @tm/cli
|
||||
// This command handles all its own configuration and logic
|
||||
ListTasksCommand.registerOn(programInstance);
|
||||
|
||||
// Register the auth command from @tm/cli
|
||||
// Handles authentication with tryhamster.com
|
||||
AuthCommand.registerOn(programInstance);
|
||||
|
||||
// expand command
|
||||
programInstance
|
||||
.command('expand')
|
||||
|
||||
@@ -103,10 +103,14 @@ describe('Roo Files Inclusion in Package', () => {
|
||||
test('source Roo files exist in public/assets directory', () => {
|
||||
// Verify that the source files for Roo integration exist
|
||||
expect(
|
||||
fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo'))
|
||||
fs.existsSync(
|
||||
path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes'))
|
||||
fs.existsSync(
|
||||
path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,10 +89,14 @@ describe('Rules Files Inclusion in Package', () => {
|
||||
test('source Roo files exist in public/assets directory', () => {
|
||||
// Verify that the source files for Roo integration exist
|
||||
expect(
|
||||
fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo'))
|
||||
fs.existsSync(
|
||||
path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes'))
|
||||
fs.existsSync(
|
||||
path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,11 +62,11 @@ describe('PromptManager', () => {
|
||||
describe('loadPrompt', () => {
|
||||
it('should load and render a prompt from actual files', () => {
|
||||
// Test with an actual prompt that exists
|
||||
const result = promptManager.loadPrompt('research', {
|
||||
const result = promptManager.loadPrompt('research', {
|
||||
query: 'test query',
|
||||
projectContext: 'test context'
|
||||
});
|
||||
|
||||
|
||||
expect(result.systemPrompt).toBeDefined();
|
||||
expect(result.userPrompt).toBeDefined();
|
||||
expect(result.userPrompt).toContain('test query');
|
||||
@@ -87,7 +87,7 @@ describe('PromptManager', () => {
|
||||
});
|
||||
|
||||
const result = promptManager.loadPrompt('test-prompt', { name: 'John' });
|
||||
|
||||
|
||||
expect(result.userPrompt).toBe('Hello John, your age is ');
|
||||
});
|
||||
|
||||
@@ -100,13 +100,13 @@ describe('PromptManager', () => {
|
||||
it('should use cache for repeated calls', () => {
|
||||
// First call with a real prompt
|
||||
const result1 = promptManager.loadPrompt('research', { query: 'test' });
|
||||
|
||||
|
||||
// Mark the result to verify cache is used
|
||||
result1._cached = true;
|
||||
|
||||
|
||||
// Second call with same parameters should return cached result
|
||||
const result2 = promptManager.loadPrompt('research', { query: 'test' });
|
||||
|
||||
|
||||
expect(result2._cached).toBe(true);
|
||||
expect(result1).toBe(result2); // Same object reference
|
||||
});
|
||||
@@ -127,7 +127,7 @@ describe('PromptManager', () => {
|
||||
const result = promptManager.loadPrompt('array-prompt', {
|
||||
items: ['one', 'two', 'three']
|
||||
});
|
||||
|
||||
|
||||
// The actual implementation doesn't handle {{this}} properly, check what it does produce
|
||||
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');
|
||||
|
||||
const withoutData = promptManager.loadPrompt('conditional-prompt', { hasData: false });
|
||||
const withoutData = promptManager.loadPrompt('conditional-prompt', {
|
||||
hasData: false
|
||||
});
|
||||
expect(withoutData.userPrompt).toBe('No data');
|
||||
});
|
||||
});
|
||||
@@ -162,7 +166,7 @@ describe('PromptManager', () => {
|
||||
age: 30
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const result = promptManager.renderTemplate(template, variables);
|
||||
expect(result).toBe('User: John, Age: 30');
|
||||
});
|
||||
@@ -172,7 +176,7 @@ describe('PromptManager', () => {
|
||||
const variables = {
|
||||
special: '<>&"\''
|
||||
};
|
||||
|
||||
|
||||
const result = promptManager.renderTemplate(template, variables);
|
||||
expect(result).toBe('Special: <>&"\'');
|
||||
});
|
||||
@@ -183,8 +187,8 @@ describe('PromptManager', () => {
|
||||
const prompts = promptManager.listPrompts();
|
||||
expect(prompts).toBeInstanceOf(Array);
|
||||
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('expand-task');
|
||||
expect(ids).toContain('add-task');
|
||||
@@ -192,7 +196,6 @@ describe('PromptManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('validateTemplate', () => {
|
||||
it('should validate a correct template', () => {
|
||||
const result = promptManager.validateTemplate('research');
|
||||
@@ -202,7 +205,7 @@ describe('PromptManager', () => {
|
||||
it('should reject invalid template', () => {
|
||||
const result = promptManager.validateTemplate('non-existent');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("not found");
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { dotenvLoad } from 'dotenv-mono';
|
||||
|
||||
// Load .env from root level (monorepo support)
|
||||
dotenvLoad();
|
||||
|
||||
// Get all TM_PUBLIC_* env variables for build-time injection
|
||||
const getBuildTimeEnvs = () => {
|
||||
const envs: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith('TM_PUBLIC_')) {
|
||||
// Return the actual value, not JSON.stringify'd
|
||||
envs[key] = value || '';
|
||||
}
|
||||
}
|
||||
return envs;
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
@@ -18,6 +34,8 @@ export default defineConfig({
|
||||
'.js': 'jsx',
|
||||
'.ts': 'ts'
|
||||
},
|
||||
// Replace process.env.TM_PUBLIC_* with actual values at build time
|
||||
env: getBuildTimeEnvs(),
|
||||
esbuildOptions(options) {
|
||||
options.platform = 'node';
|
||||
// Allow importing TypeScript from JavaScript
|
||||
@@ -25,31 +43,9 @@ export default defineConfig({
|
||||
},
|
||||
// Bundle our monorepo packages but keep node_modules external
|
||||
noExternal: [/@tm\/.*/],
|
||||
external: [
|
||||
// Keep native node modules external
|
||||
'fs',
|
||||
'path',
|
||||
'child_process',
|
||||
'crypto',
|
||||
'os',
|
||||
'url',
|
||||
'util',
|
||||
'stream',
|
||||
'http',
|
||||
'https',
|
||||
'events',
|
||||
'assert',
|
||||
'buffer',
|
||||
'querystring',
|
||||
'readline',
|
||||
'zlib',
|
||||
'tty',
|
||||
'net',
|
||||
'dgram',
|
||||
'dns',
|
||||
'tls',
|
||||
'cluster',
|
||||
'process',
|
||||
'module'
|
||||
]
|
||||
// Don't bundle any other dependencies (auto-external all node_modules)
|
||||
// This regex matches anything that doesn't start with . or /
|
||||
external: [/^[^./]/],
|
||||
// Add success message for debugging
|
||||
onSuccess: 'echo "✅ Build completed successfully"'
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user