feat: add oauth with remote server (#1178)

This commit is contained in:
Ralph Khreish
2025-09-04 20:45:41 +02:00
committed by GitHub
parent 19ec52181d
commit 6dd910fc52
37 changed files with 2340 additions and 7147 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -0,0 +1,163 @@
/**
* Authentication manager for Task Master CLI
*/
import {
AuthCredentials,
OAuthFlowOptions,
AuthenticationError,
AuthConfig
} from './types';
import { CredentialStore } from './credential-store';
import { OAuthService } from './oauth-service';
import { SupabaseAuthClient } from '../clients/supabase-client';
/**
* Authentication manager class
*/
export class AuthManager {
private static instance: AuthManager;
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);
}
return AuthManager.instance;
}
/**
* 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();
}
/**
* Authenticate with API key
* Note: This would require a custom implementation or Supabase RLS policies
*/
async authenticateWithApiKey(apiKey: string): Promise<AuthCredentials> {
const token = apiKey.trim();
if (!token || token.length < 10) {
throw new AuthenticationError('Invalid API key', 'INVALID_API_KEY');
}
const authData: AuthCredentials = {
token,
tokenType: 'api_key',
userId: 'api-user',
email: undefined,
expiresAt: undefined, // API keys don't expire
savedAt: new Date().toISOString()
};
this.credentialStore.saveCredentials(authData);
return authData;
}
/**
* 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
console.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();
}
/**
* Get authorization headers
*/
getAuthHeaders(): Record<string, string> {
const authData = this.getCredentials();
if (!authData) {
throw new AuthenticationError(
'Not authenticated. Please authenticate first.',
'NOT_AUTHENTICATED'
);
}
return {
Authorization: `Bearer ${authData.token}`
};
}
}

View File

@@ -0,0 +1,37 @@
/**
* Centralized authentication configuration
*/
import os from 'os';
import path from 'path';
import { AuthConfig } from './types';
// 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
};
}

View File

@@ -0,0 +1,109 @@
/**
* Credential storage and management
*/
import fs from 'fs';
import { AuthCredentials, AuthenticationError, AuthConfig } from './types';
import { getAuthConfig } from './config';
import { getLogger } from '../logger';
export class CredentialStore {
private logger = getLogger('CredentialStore');
private config: AuthConfig;
constructor(config?: Partial<AuthConfig>) {
this.config = getAuthConfig(config);
}
/**
* Get stored authentication credentials
*/
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;
// Check if token is expired
if (
authData.expiresAt &&
new Date(authData.expiresAt) < new Date() &&
!options?.allowExpired
) {
this.logger.warn('Authentication token has expired');
return null;
}
return authData;
} catch (error) {
this.logger.error(
`Failed to read auth credentials: ${(error as Error).message}`
);
return null;
}
}
/**
* Save authentication credentials
*/
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
authData.savedAt = new Date().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 };
}
}

View File

@@ -0,0 +1,21 @@
/**
* Authentication module exports
*/
export { AuthManager } from './auth-manager';
export { CredentialStore } from './credential-store';
export { OAuthService } from './oauth-service';
export type {
AuthCredentials,
OAuthFlowOptions,
AuthConfig,
CliData
} from './types';
export { AuthenticationError } from './types';
export {
DEFAULT_AUTH_CONFIG,
getAuthConfig
} from './config';

View 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';
import { CredentialStore } from './credential-store';
import { SupabaseAuthClient } from '../clients/supabase-client';
import { getAuthConfig } from './config';
import { getLogger } from '../logger';
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;
}
}

View File

@@ -0,0 +1,85 @@
/**
* Authentication types and interfaces
*/
export interface AuthCredentials {
token: string;
refreshToken?: string;
userId: string;
email?: string;
expiresAt?: string;
tokenType?: 'standard' | 'api_key';
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_API_KEY'
| 'INVALID_CREDENTIALS'
| 'NO_REFRESH_TOKEN'
| 'NOT_AUTHENTICATED'
| 'NETWORK_ERROR'
| 'CONFIG_MISSING'
| 'SAVE_FAILED'
| 'CLEAR_FAILED'
| 'STORAGE_ERROR';
/**
* 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}`;
}
}
}

View File

@@ -0,0 +1,5 @@
/**
* Client exports
*/
export { SupabaseAuthClient } from './supabase-client';

View File

@@ -0,0 +1,154 @@
/**
* Supabase client for authentication
*/
import { createClient, SupabaseClient, User } from '@supabase/supabase-js';
import { AuthenticationError } from '../auth/types';
import { getLogger } from '../logger';
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);
}
}
}

View File

@@ -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
});
});

View File

@@ -135,25 +135,26 @@ export class ConfigManager {
* Get storage configuration
*/
getStorageConfig(): {
type: 'file' | 'api';
type: 'file' | 'api' | 'auto';
apiEndpoint?: string;
apiAccessToken?: string;
apiConfigured: boolean;
} {
const storage = this.config.storage;
if (
storage?.type === 'api' &&
storage.apiEndpoint &&
storage.apiAccessToken
) {
// Return the configured type (including 'auto')
const storageType = storage?.type || 'auto';
if (storageType === 'api' || storageType === 'auto') {
return {
type: 'api',
apiEndpoint: storage.apiEndpoint,
apiAccessToken: storage.apiAccessToken
type: storageType,
apiEndpoint: storage?.apiEndpoint,
apiAccessToken: storage?.apiAccessToken,
apiConfigured: Boolean(storage?.apiEndpoint || storage?.apiAccessToken)
};
}
return { type: 'file' };
return { type: storageType, apiConfigured: false };
}
/**
@@ -269,12 +270,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
}
}

View File

@@ -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', () => {

View File

@@ -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'] },

View File

@@ -43,3 +43,15 @@ export * from './errors';
// Re-export entities
export { TaskEntity } from './entities/task.entity';
// Re-export authentication
export {
AuthManager,
AuthenticationError,
type AuthCredentials,
type OAuthFlowOptions,
type AuthConfig
} from './auth';
// Re-export logger
export { getLogger, createLogger, setGlobalLogger } from './logger';

View File

@@ -77,14 +77,16 @@ export interface TagSettings {
* Storage and persistence settings
*/
export interface StorageSettings {
/** Storage backend type */
type: 'file' | 'api';
/** Storage backend type - 'auto' detects based on auth status */
type: 'file' | 'api' | 'auto';
/** Base path for file storage */
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) */
apiConfigured?: boolean;
/** Enable automatic backups */
enableBackup: boolean;
/** Maximum number of backups to retain */
@@ -388,7 +390,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
},

View File

@@ -0,0 +1,59 @@
/**
* @fileoverview Logger factory and singleton management
*/
import { Logger, 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();
}

View 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';

View 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
});
}
}

View File

@@ -0,0 +1,6 @@
/**
* Services module exports
* Provides business logic and service layer functionality
*/
export { TaskService } from './task-service';

View File

@@ -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';
}
/**
@@ -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;
}

View File

@@ -7,6 +7,8 @@ import type { IConfiguration } from '../interfaces/configuration.interface.js';
import { FileStorage } from './file-storage';
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
@@ -22,15 +24,77 @@ 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)) {
// Check if authenticated via AuthManager
const authManager = AuthManager.getInstance();
if (!authManager.isAuthenticated()) {
throw new TaskMasterError(
'API storage configured but not authenticated. Run: tm auth login',
ERROR_CODES.MISSING_CONFIGURATION,
{ storageType: 'api' }
);
}
// Use auth token from AuthManager
const credentials = authManager.getCredentials();
if (credentials) {
// Merge with existing storage config, ensuring required fields
config.storage = {
...config.storage,
type: 'api' as const,
apiAccessToken: credentials.token,
apiEndpoint:
config.storage?.apiEndpoint ||
process.env.HAMSTER_API_URL ||
'https://tryhamster.com/api'
} as any; // Cast to any to bypass strict type checking for partial config
}
}
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
config.storage = {
...config.storage,
type: 'api' as const,
apiAccessToken: credentials.token,
apiEndpoint:
config.storage?.apiEndpoint ||
process.env.HAMSTER_API_URL ||
'https://tryhamster.com/api'
} as any; // Cast to any to bypass strict type checking for partial config
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}`,
@@ -157,7 +221,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
);

View 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
*/

View File

@@ -152,7 +152,7 @@ export class TaskMasterCore {
/**
* Get current storage type
*/
getStorageType(): 'file' | 'api' {
getStorageType(): 'file' | 'api' | 'auto' {
return this.taskService.getStorageType();
}

View File

@@ -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';
// 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

View File

@@ -28,12 +28,17 @@
"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/**/*"],

View File

@@ -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'];
}