From 04cefd84b4f3b7b5a8ad5e08c8e608ec43525136 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:54:18 +0200 Subject: [PATCH] chore: implement requested changes --- apps/cli/package.json | 1 + apps/cli/src/commands/auth.command.ts | 14 +- package-lock.json | 2 +- packages/tm-core/package.json | 54 ++++- packages/tm-core/src/auth/auth-manager.ts | 24 ++- packages/tm-core/src/auth/credential-store.ts | 32 +-- packages/tm-core/src/auth/index.ts | 2 - packages/tm-core/src/auth/oauth-service.ts | 194 +++++++++--------- packages/tm-core/src/auth/types.ts | 47 +++-- .../tm-core/src/clients/supabase-client.ts | 18 +- .../tm-core/src/config/config-manager.spec.ts | 62 +++++- packages/tm-core/src/config/config-manager.ts | 6 +- ...nvironment-config-provider.service.spec.ts | 5 + .../environment-config-provider.service.ts | 2 +- packages/tm-core/src/index.ts | 2 - .../src/interfaces/configuration.interface.ts | 4 +- packages/tm-core/src/services/index.ts | 6 + packages/tm-core/src/subpath-exports.test.ts | 99 +++++++++ packages/tm-core/src/utils/index.ts | 32 +-- packages/tm-core/tsconfig.json | 15 +- packages/tm-core/tsup.config.ts | 15 +- 21 files changed, 446 insertions(+), 190 deletions(-) create mode 100644 packages/tm-core/src/services/index.ts create mode 100644 packages/tm-core/src/subpath-exports.test.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 60c531e1..f78a69f7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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": { diff --git a/apps/cli/src/commands/auth.command.ts b/apps/cli/src/commands/auth.command.ts index 45ee515f..c79ff6bb 100644 --- a/apps/cli/src/commands/auth.command.ts +++ b/apps/cli/src/commands/auth.command.ts @@ -7,11 +7,12 @@ 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'; +} from '@tm/core/auth'; import * as ui from '../utils/ui.js'; /** @@ -125,7 +126,7 @@ export class AuthCommand extends Command { */ private async executeLogout(): Promise { try { - const result = this.performLogout(); + const result = await this.performLogout(); this.setLastResult(result); if (!result.success) { @@ -232,9 +233,9 @@ export class AuthCommand extends Command { /** * Perform logout */ - private performLogout(): AuthResult { + private async performLogout(): Promise { try { - this.authManager.logout(); + await this.authManager.logout(); ui.displaySuccess('Successfully logged out'); return { @@ -366,7 +367,10 @@ export class AuthCommand extends Command { try { // Use AuthManager's new unified OAuth flow method with callbacks const credentials = await this.authManager.authenticateWithOAuth({ - openBrowser: true, + // Callback to handle browser opening + openBrowser: async (authUrl) => { + await open(authUrl); + }, timeout: 5 * 60 * 1000, // 5 minutes // Callback when auth URL is ready diff --git a/package-lock.json b/package-lock.json index c90253df..03e69fb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,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": { @@ -26298,7 +26299,6 @@ "dependencies": { "@supabase/supabase-js": "^2.57.0", "chalk": "^5.3.0", - "open": "^10.2.0", "zod": "^3.22.4" }, "devDependencies": { diff --git a/packages/tm-core/package.json b/packages/tm-core/package.json index f1eb139c..b197b0c4 100644 --- a/packages/tm-core/package.json +++ b/packages/tm-core/package.json @@ -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", @@ -28,7 +79,6 @@ "dependencies": { "@supabase/supabase-js": "^2.57.0", "chalk": "^5.3.0", - "open": "^10.2.0", "zod": "^3.22.4" }, "devDependencies": { diff --git a/packages/tm-core/src/auth/auth-manager.ts b/packages/tm-core/src/auth/auth-manager.ts index 6a1c7ef4..a87705a0 100644 --- a/packages/tm-core/src/auth/auth-manager.ts +++ b/packages/tm-core/src/auth/auth-manager.ts @@ -65,10 +65,13 @@ export class AuthManager { * Note: This would require a custom implementation or Supabase RLS policies */ async authenticateWithApiKey(apiKey: string): Promise { - // TODO: Implement API key validation if needed - // For now, we can store the API key directly as a token + const token = apiKey.trim(); + if (!token || token.length < 10) { + throw new AuthenticationError('Invalid API key', 'INVALID_API_KEY'); + } + const authData: AuthCredentials = { - token: apiKey, + token, tokenType: 'api_key', userId: 'api-user', email: undefined, @@ -84,7 +87,9 @@ export class AuthManager { * Refresh authentication token */ async refreshToken(): Promise { - const authData = this.getCredentials(); + const authData = this.credentialStore.getCredentials({ + allowExpired: true + }); if (!authData || !authData.refreshToken) { throw new AuthenticationError( @@ -118,7 +123,16 @@ export class AuthManager { /** * Logout and clear credentials */ - logout(): void { + async logout(): Promise { + 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(); } diff --git a/packages/tm-core/src/auth/credential-store.ts b/packages/tm-core/src/auth/credential-store.ts index 23fa0c76..f55e0300 100644 --- a/packages/tm-core/src/auth/credential-store.ts +++ b/packages/tm-core/src/auth/credential-store.ts @@ -18,7 +18,7 @@ export class CredentialStore { /** * Get stored authentication credentials */ - getCredentials(): AuthCredentials | null { + getCredentials(options?: { allowExpired?: boolean }): AuthCredentials | null { try { if (!fs.existsSync(this.config.configFile)) { return null; @@ -29,7 +29,11 @@ export class CredentialStore { ) as AuthCredentials; // Check if token is expired - if (authData.expiresAt && new Date(authData.expiresAt) < new Date()) { + if ( + authData.expiresAt && + new Date(authData.expiresAt) < new Date() && + !options?.allowExpired + ) { this.logger.warn('Authentication token has expired'); return null; } @@ -50,24 +54,23 @@ export class CredentialStore { try { // Ensure directory exists if (!fs.existsSync(this.config.configDir)) { - fs.mkdirSync(this.config.configDir, { recursive: true }); + fs.mkdirSync(this.config.configDir, { recursive: true, mode: 0o700 }); } // Add timestamp authData.savedAt = new Date().toISOString(); - // Save credentials - fs.writeFileSync( - this.config.configFile, - JSON.stringify(authData, null, 2) - ); - - // Set file permissions to read/write for owner only - fs.chmodSync(this.config.configFile, 0o600); + // 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' + 'SAVE_FAILED', + error ); } } @@ -83,7 +86,8 @@ export class CredentialStore { } catch (error) { throw new AuthenticationError( `Failed to clear credentials: ${(error as Error).message}`, - 'CLEAR_FAILED' + 'CLEAR_FAILED', + error ); } } @@ -92,7 +96,7 @@ export class CredentialStore { * Check if credentials exist and are valid */ hasValidCredentials(): boolean { - const credentials = this.getCredentials(); + const credentials = this.getCredentials({ allowExpired: false }); return credentials !== null; } diff --git a/packages/tm-core/src/auth/index.ts b/packages/tm-core/src/auth/index.ts index a5dfb729..a2604941 100644 --- a/packages/tm-core/src/auth/index.ts +++ b/packages/tm-core/src/auth/index.ts @@ -8,8 +8,6 @@ export { OAuthService } from './oauth-service'; export type { AuthCredentials, - AuthOptions, - AuthResponse, OAuthFlowOptions, AuthConfig, CliData diff --git a/packages/tm-core/src/auth/oauth-service.ts b/packages/tm-core/src/auth/oauth-service.ts index ec9d67b4..33645361 100644 --- a/packages/tm-core/src/auth/oauth-service.ts +++ b/packages/tm-core/src/auth/oauth-service.ts @@ -6,7 +6,6 @@ import http from 'http'; import { URL } from 'url'; import crypto from 'crypto'; import os from 'os'; -import open from 'open'; import { AuthCredentials, AuthenticationError, @@ -27,6 +26,8 @@ export class OAuthService { private baseUrl: string; private authorizationUrl: string | null = null; private originalState: string | null = null; + private authorizationReady: Promise | null = null; + private resolveAuthorizationReady: (() => void) | null = null; constructor( credentialStore: CredentialStore, @@ -43,7 +44,7 @@ export class OAuthService { */ async authenticate(options: OAuthFlowOptions = {}): Promise { const { - openBrowser = true, + openBrowser, timeout = 300000, // 5 minutes default onAuthUrl, onWaitingForAuth, @@ -55,8 +56,10 @@ export class OAuthService { // Start the OAuth flow (starts local server) const authPromise = this.startFlow(timeout); - // Wait for server to start - await new Promise((resolve) => setTimeout(resolve, 100)); + // 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(); @@ -73,9 +76,15 @@ export class OAuthService { onAuthUrl(authUrl); } - // Open browser if requested + // Open browser if callback provided if (openBrowser) { - await this.openBrowser(authUrl); + 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 @@ -98,7 +107,8 @@ export class OAuthService { ? error : new AuthenticationError( `OAuth authentication failed: ${(error as Error).message}`, - 'OAUTH_FAILED' + 'OAUTH_FAILED', + error ); // Notify error @@ -115,50 +125,62 @@ export class OAuthService { */ private async startFlow(timeout: number = 300000): Promise { const state = this.generateState(); - const port = await this.getRandomPort(); - const callbackUrl = `http://localhost:${port}/callback`; // Store the original state for verification this.originalState = state; - // 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() - }; + // Create a promise that will resolve when the server is ready + this.authorizationReady = new Promise((resolve) => { + this.resolveAuthorizationReady = resolve; + }); return new Promise((resolve, reject) => { - let serverClosed = false; - + let timeoutId: NodeJS.Timeout; // Create local HTTP server for OAuth callback - const server = http.createServer(async (req, res) => { - const url = new URL(req.url!, `http://127.0.0.1:${port}`); + const server = http.createServer(); - if (url.pathname === '/callback') { - await this.handleCallback( - url, - res, - server, - serverClosed, - resolve, - reject - ); - serverClosed = true; - } else { - // Handle other paths (favicon, etc.) - res.writeHead(404); - res.end(); + // 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() + }; - // Start server on localhost only - server.listen(port, '127.0.0.1', () => { // Build authorization URL for web app sign-in page const authUrl = new URL(`${this.baseUrl}/auth/sign-in`); @@ -177,13 +199,23 @@ export class OAuthService { `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 - setTimeout(() => { - if (!serverClosed) { - serverClosed = true; + 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') ); @@ -199,9 +231,9 @@ export class OAuthService { url: URL, res: http.ServerResponse, server: http.Server, - serverClosed: boolean, resolve: (value: AuthCredentials) => void, - reject: (error: any) => void + reject: (error: any) => void, + timeoutId?: NodeJS.Timeout ): Promise { // Server now returns tokens directly instead of code const type = url.searchParams.get('type'); @@ -217,26 +249,26 @@ export class OAuthService { res.end(); if (error) { - if (!serverClosed) { + if (server.listening) { server.close(); - reject( - new AuthenticationError( - errorDescription || error || 'Authentication failed', - 'OAUTH_ERROR' - ) - ); } + reject( + new AuthenticationError( + errorDescription || error || 'Authentication failed', + 'OAUTH_ERROR' + ) + ); return; } // Verify state parameter for CSRF protection if (returnedState !== this.originalState) { - if (!serverClosed) { + if (server.listening) { server.close(); - reject( - new AuthenticationError('Invalid state parameter', 'INVALID_STATE') - ); } + reject( + new AuthenticationError('Invalid state parameter', 'INVALID_STATE') + ); return; } @@ -269,34 +301,25 @@ export class OAuthService { this.credentialStore.saveCredentials(authData); - if (!serverClosed) { + if (server.listening) { server.close(); - resolve(authData); } + // Clear timeout since authentication succeeded + if (timeoutId) { + clearTimeout(timeoutId); + } + resolve(authData); } catch (error) { - if (!serverClosed) { + if (server.listening) { server.close(); - reject(error); } + reject(error); } } else { - if (!serverClosed) { + if (server.listening) { server.close(); - reject(new AuthenticationError('No access token received', 'NO_TOKEN')); } - } - } - - /** - * Open browser with the given URL - */ - private async openBrowser(url: string): Promise { - try { - await open(url); - this.logger.debug('Browser opened successfully with URL:', url); - } 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); + reject(new AuthenticationError('No access token received', 'NO_TOKEN')); } } @@ -307,25 +330,6 @@ export class OAuthService { return crypto.randomBytes(32).toString('base64url'); } - /** - * Get a random available port - */ - private async getRandomPort(): Promise { - return new Promise((resolve, reject) => { - const server = http.createServer(); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - reject(new Error('Failed to get port')); - return; - } - const port = address.port; - server.close(() => resolve(port)); - }); - server.on('error', reject); - }); - } - /** * Get CLI version from package.json if available */ diff --git a/packages/tm-core/src/auth/types.ts b/packages/tm-core/src/auth/types.ts index 557460bb..a86250aa 100644 --- a/packages/tm-core/src/auth/types.ts +++ b/packages/tm-core/src/auth/types.ts @@ -12,23 +12,9 @@ export interface AuthCredentials { savedAt: string; } -export interface AuthOptions { - email?: string; - password?: string; - apiKey?: string; -} - -export interface AuthResponse { - token: string; - refreshToken?: string; - userId: string; - email?: string; - expiresAt?: string; -} - export interface OAuthFlowOptions { - /** Whether to automatically open the browser. Default: true */ - openBrowser?: boolean; + /** Callback to open the browser with the auth URL. If not provided, browser won't be opened */ + openBrowser?: (url: string) => Promise; /** Timeout for the OAuth flow in milliseconds. Default: 300000 (5 minutes) */ timeout?: number; /** Callback to be invoked with the authorization URL */ @@ -58,15 +44,42 @@ export interface CliData { 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: 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}`; + } } } diff --git a/packages/tm-core/src/clients/supabase-client.ts b/packages/tm-core/src/clients/supabase-client.ts index 2d6c8c6c..263b5c5d 100644 --- a/packages/tm-core/src/clients/supabase-client.ts +++ b/packages/tm-core/src/clients/supabase-client.ts @@ -15,15 +15,13 @@ export class SupabaseAuthClient { */ private getClient(): SupabaseClient { if (!this.client) { - // Get Supabase configuration from environment - const supabaseUrl = process.env.SUPABASE_URL || 'http://localhost:8080'; - const supabaseAnonKey = - process.env.SUPABASE_ANON_KEY || - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'; + // 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 SUPABASE_URL and SUPABASE_ANON_KEY environment variables.', + 'Supabase configuration missing. Please set TM_PUBLIC_SUPABASE_URL and TM_PUBLIC_SUPABASE_ANON_KEY environment variables.', 'CONFIG_MISSING' ); } @@ -136,13 +134,15 @@ export class SupabaseAuthClient { /** * 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(token: string): Promise { + async signOut(): Promise { try { const client = this.getClient(); - // Sign out using the token - const { error } = await client.auth.admin.signOut(token); + // 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); diff --git a/packages/tm-core/src/config/config-manager.spec.ts b/packages/tm-core/src/config/config-manager.spec.ts index 95ef17d6..ac068d31 100644 --- a/packages/tm-core/src/config/config-manager.spec.ts +++ b/packages/tm-core/src/config/config-manager.spec.ts @@ -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 }); }); diff --git a/packages/tm-core/src/config/config-manager.ts b/packages/tm-core/src/config/config-manager.ts index 6cfbe626..318c5a7f 100644 --- a/packages/tm-core/src/config/config-manager.ts +++ b/packages/tm-core/src/config/config-manager.ts @@ -138,6 +138,7 @@ export class ConfigManager { type: 'file' | 'api' | 'auto'; apiEndpoint?: string; apiAccessToken?: string; + apiConfigured: boolean; } { const storage = this.config.storage; @@ -148,11 +149,12 @@ export class ConfigManager { return { type: storageType, apiEndpoint: storage?.apiEndpoint, - apiAccessToken: storage?.apiAccessToken + apiAccessToken: storage?.apiAccessToken, + apiConfigured: Boolean(storage?.apiEndpoint || storage?.apiAccessToken) }; } - return { type: storageType }; + return { type: storageType, apiConfigured: false }; } /** diff --git a/packages/tm-core/src/config/services/environment-config-provider.service.spec.ts b/packages/tm-core/src/config/services/environment-config-provider.service.spec.ts index 36fa579c..9c4ef8b3 100644 --- a/packages/tm-core/src/config/services/environment-config-provider.service.spec.ts +++ b/packages/tm-core/src/config/services/environment-config-provider.service.spec.ts @@ -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', () => { diff --git a/packages/tm-core/src/config/services/environment-config-provider.service.ts b/packages/tm-core/src/config/services/environment-config-provider.service.ts index ec06d79e..75061519 100644 --- a/packages/tm-core/src/config/services/environment-config-provider.service.ts +++ b/packages/tm-core/src/config/services/environment-config-provider.service.ts @@ -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'] }, diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index 41efcfad..2eb86ad2 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -49,8 +49,6 @@ export { AuthManager, AuthenticationError, type AuthCredentials, - type AuthOptions, - type AuthResponse, type OAuthFlowOptions, type AuthConfig } from './auth'; diff --git a/packages/tm-core/src/interfaces/configuration.interface.ts b/packages/tm-core/src/interfaces/configuration.interface.ts index e0580ffc..ca215447 100644 --- a/packages/tm-core/src/interfaces/configuration.interface.ts +++ b/packages/tm-core/src/interfaces/configuration.interface.ts @@ -85,6 +85,8 @@ export interface StorageSettings { 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 }, diff --git a/packages/tm-core/src/services/index.ts b/packages/tm-core/src/services/index.ts new file mode 100644 index 00000000..c3f5ac48 --- /dev/null +++ b/packages/tm-core/src/services/index.ts @@ -0,0 +1,6 @@ +/** + * Services module exports + * Provides business logic and service layer functionality + */ + +export { TaskService } from './task-service'; diff --git a/packages/tm-core/src/subpath-exports.test.ts b/packages/tm-core/src/subpath-exports.test.ts new file mode 100644 index 00000000..71bc0bb2 --- /dev/null +++ b/packages/tm-core/src/subpath-exports.test.ts @@ -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 + */ diff --git a/packages/tm-core/src/utils/index.ts b/packages/tm-core/src/utils/index.ts index c9facaa6..527b2780 100644 --- a/packages/tm-core/src/utils/index.ts +++ b/packages/tm-core/src/utils/index.ts @@ -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 diff --git a/packages/tm-core/tsconfig.json b/packages/tm-core/tsconfig.json index f5a24553..47e52c40 100644 --- a/packages/tm-core/tsconfig.json +++ b/packages/tm-core/tsconfig.json @@ -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/**/*"], diff --git a/packages/tm-core/tsup.config.ts b/packages/tm-core/tsup.config.ts index ea2d68ec..c548e76b 100644 --- a/packages/tm-core/tsup.config.ts +++ b/packages/tm-core/tsup.config.ts @@ -17,12 +17,17 @@ const getBuildTimeEnvs = () => { 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,