chore: implement requested changes
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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<void> {
|
||||
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<AuthResult> {
|
||||
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
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -65,10 +65,13 @@ export class AuthManager {
|
||||
* Note: This would require a custom implementation or Supabase RLS policies
|
||||
*/
|
||||
async authenticateWithApiKey(apiKey: string): Promise<AuthCredentials> {
|
||||
// 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<AuthCredentials> {
|
||||
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<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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ export { OAuthService } from './oauth-service';
|
||||
|
||||
export type {
|
||||
AuthCredentials,
|
||||
AuthOptions,
|
||||
AuthResponse,
|
||||
OAuthFlowOptions,
|
||||
AuthConfig,
|
||||
CliData
|
||||
|
||||
@@ -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<void> | null = null;
|
||||
private resolveAuthorizationReady: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
credentialStore: CredentialStore,
|
||||
@@ -43,7 +44,7 @@ export class OAuthService {
|
||||
*/
|
||||
async authenticate(options: OAuthFlowOptions = {}): Promise<AuthCredentials> {
|
||||
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<AuthCredentials> {
|
||||
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<void>((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<void> {
|
||||
// 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<void> {
|
||||
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<number> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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<void>;
|
||||
/** 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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
async signOut(): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -49,8 +49,6 @@ export {
|
||||
AuthManager,
|
||||
AuthenticationError,
|
||||
type AuthCredentials,
|
||||
type AuthOptions,
|
||||
type AuthResponse,
|
||||
type OAuthFlowOptions,
|
||||
type AuthConfig
|
||||
} from './auth';
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
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';
|
||||
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
|
||||
*/
|
||||
@@ -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
|
||||
|
||||
@@ -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/**/*"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user