Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot]
24e480ce87 docs: auto-update documentation based on changes in next branch
This PR was automatically generated to update documentation based on recent changes.

  Original commit: feat: handle new command errors better (#1318)\n\n\n

  Co-authored-by: Claude <claude-assistant@anthropic.com>
2025-10-16 20:39:02 +00:00
Ralph Khreish
662e3865f3 feat: handle new command errors better (#1318) 2025-10-16 22:31:50 +02:00
Ralph Khreish
8649c8a347 chore: apply requested coderabbit changes 2025-10-16 19:24:29 +02:00
Ralph Khreish
f7cab246b0 fix: storage issue (show file or api more consistently) 2025-10-16 19:24:29 +02:00
Ralph Khreish
5aca107827 fix: runtime env variables working with new tm_ env variables 2025-10-16 19:24:29 +02:00
Ralph Khreish
fb68c9fe1f feat: improve auth login by adding context selection immediately after logging in 2025-10-16 19:24:29 +02:00
Ralph Khreish
ff3bd7add8 chore: CI format 2025-10-16 19:24:29 +02:00
Ralph Khreish
c8228e913b feat: show brief title when listing brief instead of uuid
- add search for brief selection
2025-10-16 19:24:29 +02:00
Ralph Khreish
218b68a31e feat: implement runtime and build time env variables for remote access 2025-10-16 19:24:29 +02:00
Ralph Khreish
6bc75c0ac6 fix: auth refresh (#1314) 2025-10-15 17:32:15 +02:00
42 changed files with 1223 additions and 585 deletions

View File

@@ -0,0 +1,5 @@
---
"task-master-ai": patch
---
Improve auth token refresh flow

View File

@@ -22,6 +22,7 @@
"test:ci": "vitest run --coverage --reporter=dot" "test:ci": "vitest run --coverage --reporter=dot"
}, },
"dependencies": { "dependencies": {
"@inquirer/search": "^3.2.0",
"@tm/core": "*", "@tm/core": "*",
"boxen": "^8.0.1", "boxen": "^8.0.1",
"chalk": "5.6.2", "chalk": "5.6.2",

View File

@@ -14,6 +14,8 @@ import {
type AuthCredentials type AuthCredentials
} from '@tm/core/auth'; } from '@tm/core/auth';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { ContextCommand } from './context.command.js';
import { displayError } from '../utils/error-handler.js';
/** /**
* Result type from auth command * Result type from auth command
@@ -116,8 +118,7 @@ export class AuthCommand extends Command {
process.exit(0); process.exit(0);
}, 100); }, 100);
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -133,8 +134,7 @@ export class AuthCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -143,11 +143,10 @@ export class AuthCommand extends Command {
*/ */
private async executeStatus(): Promise<void> { private async executeStatus(): Promise<void> {
try { try {
const result = await this.displayStatus(); const result = this.displayStatus();
this.setLastResult(result); this.setLastResult(result);
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -163,16 +162,15 @@ export class AuthCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
/** /**
* Display authentication status * Display authentication status
*/ */
private async displayStatus(): Promise<AuthResult> { private displayStatus(): AuthResult {
const credentials = await this.authManager.getCredentials(); const credentials = this.authManager.getCredentials();
console.log(chalk.cyan('\n🔐 Authentication Status\n')); console.log(chalk.cyan('\n🔐 Authentication Status\n'));
@@ -325,7 +323,7 @@ export class AuthCommand extends Command {
]); ]);
if (!continueAuth) { if (!continueAuth) {
const credentials = await this.authManager.getCredentials(); const credentials = this.authManager.getCredentials();
ui.displaySuccess('Using existing authentication'); ui.displaySuccess('Using existing authentication');
if (credentials) { if (credentials) {
@@ -351,6 +349,37 @@ export class AuthCommand extends Command {
chalk.gray(` Logged in as: ${credentials.email || credentials.userId}`) chalk.gray(` Logged in as: ${credentials.email || credentials.userId}`)
); );
// Post-auth: Set up workspace context
console.log(); // Add spacing
try {
const contextCommand = new ContextCommand();
const contextResult = await contextCommand.setupContextInteractive();
if (contextResult.success) {
if (contextResult.orgSelected && contextResult.briefSelected) {
console.log(
chalk.green('✓ Workspace context configured successfully')
);
} else if (contextResult.orgSelected) {
console.log(chalk.green('✓ Organization selected'));
}
} else {
console.log(
chalk.yellow('⚠ Context setup was skipped or encountered issues')
);
console.log(
chalk.gray(' You can set up context later with "tm context"')
);
}
} catch (contextError) {
console.log(chalk.yellow('⚠ Context setup encountered an error'));
console.log(
chalk.gray(' You can set up context later with "tm context"')
);
if (process.env.DEBUG) {
console.error(chalk.gray((contextError as Error).message));
}
}
return { return {
success: true, success: true,
action: 'login', action: 'login',
@@ -358,7 +387,7 @@ export class AuthCommand extends Command {
message: 'Authentication successful' message: 'Authentication successful'
}; };
} catch (error) { } catch (error) {
this.handleAuthError(error as AuthenticationError); displayError(error, { skipExit: true });
return { return {
success: false, success: false,
@@ -421,51 +450,6 @@ export class AuthCommand extends Command {
} }
} }
/**
* Handle authentication errors
*/
private handleAuthError(error: AuthenticationError): void {
console.error(chalk.red(`\n✗ ${error.message}`));
switch (error.code) {
case 'NETWORK_ERROR':
ui.displayWarning(
'Please check your internet connection and try again.'
);
break;
case 'INVALID_CREDENTIALS':
ui.displayWarning('Please check your credentials and try again.');
break;
case 'AUTH_EXPIRED':
ui.displayWarning(
'Your session has expired. Please authenticate again.'
);
break;
default:
if (process.env.DEBUG) {
console.error(chalk.gray(error.stack || ''));
}
}
}
/**
* Handle general errors
*/
private handleError(error: any): void {
if (error instanceof AuthenticationError) {
this.handleAuthError(error);
} else {
const msg = error?.getSanitizedDetails?.() ?? {
message: error?.message ?? String(error)
};
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
}
}
/** /**
* Set the last result for programmatic access * Set the last result for programmatic access
*/ */
@@ -490,7 +474,7 @@ export class AuthCommand extends Command {
/** /**
* Get current credentials (for programmatic usage) * Get current credentials (for programmatic usage)
*/ */
getCredentials(): Promise<AuthCredentials | null> { getCredentials(): AuthCredentials | null {
return this.authManager.getCredentials(); return this.authManager.getCredentials();
} }

View File

@@ -6,13 +6,11 @@
import { Command } from 'commander'; import { Command } from 'commander';
import chalk from 'chalk'; import chalk from 'chalk';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import search from '@inquirer/search';
import ora, { Ora } from 'ora'; import ora, { Ora } from 'ora';
import { import { AuthManager, type UserContext } from '@tm/core/auth';
AuthManager,
AuthenticationError,
type UserContext
} from '@tm/core/auth';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
/** /**
* Result type from context command * Result type from context command
@@ -115,18 +113,17 @@ export class ContextCommand extends Command {
*/ */
private async executeShow(): Promise<void> { private async executeShow(): Promise<void> {
try { try {
const result = await this.displayContext(); const result = this.displayContext();
this.setLastResult(result); this.setLastResult(result);
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
/** /**
* Display current context * Display current context
*/ */
private async displayContext(): Promise<ContextResult> { private displayContext(): ContextResult {
// Check authentication first // Check authentication first
if (!this.authManager.isAuthenticated()) { if (!this.authManager.isAuthenticated()) {
console.log(chalk.yellow('✗ Not authenticated')); console.log(chalk.yellow('✗ Not authenticated'));
@@ -139,7 +136,7 @@ export class ContextCommand extends Command {
}; };
} }
const context = await this.authManager.getContext(); const context = this.authManager.getContext();
console.log(chalk.cyan('\n🌍 Workspace Context\n')); console.log(chalk.cyan('\n🌍 Workspace Context\n'));
@@ -156,10 +153,14 @@ export class ContextCommand extends Command {
if (context.briefName || context.briefId) { if (context.briefName || context.briefId) {
console.log(chalk.green('\n✓ Brief')); console.log(chalk.green('\n✓ Brief'));
if (context.briefName) { if (context.briefName && context.briefId) {
const shortId = context.briefId.slice(0, 8);
console.log(
chalk.white(` ${context.briefName} `) + chalk.gray(`(${shortId})`)
);
} else if (context.briefName) {
console.log(chalk.white(` ${context.briefName}`)); console.log(chalk.white(` ${context.briefName}`));
} } else if (context.briefId) {
if (context.briefId) {
console.log(chalk.gray(` ID: ${context.briefId}`)); console.log(chalk.gray(` ID: ${context.briefId}`));
} }
} }
@@ -211,8 +212,7 @@ export class ContextCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -250,9 +250,10 @@ export class ContextCommand extends Command {
]); ]);
// Update context // Update context
await this.authManager.updateContext({ this.authManager.updateContext({
orgId: selectedOrg.id, orgId: selectedOrg.id,
orgName: selectedOrg.name, orgName: selectedOrg.name,
orgSlug: selectedOrg.slug,
// Clear brief when changing org // Clear brief when changing org
briefId: undefined, briefId: undefined,
briefName: undefined briefName: undefined
@@ -263,7 +264,7 @@ export class ContextCommand extends Command {
return { return {
success: true, success: true,
action: 'select-org', action: 'select-org',
context: (await this.authManager.getContext()) || undefined, context: this.authManager.getContext() || undefined,
message: `Selected organization: ${selectedOrg.name}` message: `Selected organization: ${selectedOrg.name}`
}; };
} catch (error) { } catch (error) {
@@ -284,7 +285,7 @@ export class ContextCommand extends Command {
} }
// Check if org is selected // Check if org is selected
const context = await this.authManager.getContext(); const context = this.authManager.getContext();
if (!context?.orgId) { if (!context?.orgId) {
ui.displayError( ui.displayError(
'No organization selected. Run "tm context org" first.' 'No organization selected. Run "tm context org" first.'
@@ -299,8 +300,7 @@ export class ContextCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -324,26 +324,54 @@ export class ContextCommand extends Command {
}; };
} }
// Prompt for selection // Prompt for selection with search
const { selectedBrief } = await inquirer.prompt([ const selectedBrief = await search<(typeof briefs)[0] | null>({
{ message: 'Search for a brief:',
type: 'list', source: async (input) => {
name: 'selectedBrief', const searchTerm = input?.toLowerCase() || '';
message: 'Select a brief:',
choices: [ // Static option for no brief
{ name: '(No brief - organization level)', value: null }, const noBriefOption = {
...briefs.map((brief) => ({ name: '(No brief - organization level)',
name: `Brief ${brief.id} (${new Date(brief.createdAt).toLocaleDateString()})`, value: null as any,
value: brief description: 'Clear brief selection'
})) };
]
// Filter and map brief options
const briefOptions = briefs
.filter((brief) => {
if (!searchTerm) return true;
const title = brief.document?.title || '';
const shortId = brief.id.slice(0, 8);
// Search by title first, then by UUID
return (
title.toLowerCase().includes(searchTerm) ||
brief.id.toLowerCase().includes(searchTerm) ||
shortId.toLowerCase().includes(searchTerm)
);
})
.map((brief) => {
const title =
brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
const shortId = brief.id.slice(0, 8);
return {
name: `${title} ${chalk.gray(`(${shortId})`)}`,
value: brief
};
});
return [noBriefOption, ...briefOptions];
} }
]); });
if (selectedBrief) { if (selectedBrief) {
// Update context with brief // Update context with brief
const briefName = `Brief ${selectedBrief.id.slice(0, 8)}`; const briefName =
await this.authManager.updateContext({ selectedBrief.document?.title ||
`Brief ${selectedBrief.id.slice(0, 8)}`;
this.authManager.updateContext({
briefId: selectedBrief.id, briefId: selectedBrief.id,
briefName: briefName briefName: briefName
}); });
@@ -353,12 +381,12 @@ export class ContextCommand extends Command {
return { return {
success: true, success: true,
action: 'select-brief', action: 'select-brief',
context: (await this.authManager.getContext()) || undefined, context: this.authManager.getContext() || undefined,
message: `Selected brief: ${selectedBrief.name}` message: `Selected brief: ${selectedBrief.document?.title}`
}; };
} else { } else {
// Clear brief selection // Clear brief selection
await this.authManager.updateContext({ this.authManager.updateContext({
briefId: undefined, briefId: undefined,
briefName: undefined briefName: undefined
}); });
@@ -368,7 +396,7 @@ export class ContextCommand extends Command {
return { return {
success: true, success: true,
action: 'select-brief', action: 'select-brief',
context: (await this.authManager.getContext()) || undefined, context: this.authManager.getContext() || undefined,
message: 'Cleared brief selection' message: 'Cleared brief selection'
}; };
} }
@@ -396,8 +424,7 @@ export class ContextCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -443,8 +470,7 @@ export class ContextCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -468,7 +494,7 @@ export class ContextCommand extends Command {
if (!briefId) { if (!briefId) {
spinner.fail('Could not extract a brief ID from the provided input'); spinner.fail('Could not extract a brief ID from the provided input');
ui.displayError( ui.displayError(
`Provide a valid brief ID or a Hamster brief URL, e.g. https://${process.env.TM_PUBLIC_BASE_DOMAIN}/home/hamster/briefs/<id>` `Provide a valid brief ID or a Hamster brief URL, e.g. https://${process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN}/home/hamster/briefs/<id>`
); );
process.exit(1); process.exit(1);
} }
@@ -480,20 +506,24 @@ export class ContextCommand extends Command {
process.exit(1); process.exit(1);
} }
// Fetch org to get a friendly name (optional) // Fetch org to get a friendly name and slug (optional)
let orgName: string | undefined; let orgName: string | undefined;
let orgSlug: string | undefined;
try { try {
const org = await this.authManager.getOrganization(brief.accountId); const org = await this.authManager.getOrganization(brief.accountId);
orgName = org?.name; orgName = org?.name;
orgSlug = org?.slug;
} catch { } catch {
// Non-fatal if org lookup fails // Non-fatal if org lookup fails
} }
// Update context: set org and brief // Update context: set org and brief
const briefName = `Brief ${brief.id.slice(0, 8)}`; const briefName =
await this.authManager.updateContext({ brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
this.authManager.updateContext({
orgId: brief.accountId, orgId: brief.accountId,
orgName, orgName,
orgSlug,
briefId: brief.id, briefId: brief.id,
briefName briefName
}); });
@@ -508,15 +538,14 @@ export class ContextCommand extends Command {
this.setLastResult({ this.setLastResult({
success: true, success: true,
action: 'set', action: 'set',
context: (await this.authManager.getContext()) || undefined, context: this.authManager.getContext() || undefined,
message: 'Context set from brief' message: 'Context set from brief'
}); });
} catch (error: any) { } catch (error: any) {
try { try {
if (spinner?.isSpinning) spinner.stop(); if (spinner?.isSpinning) spinner.stop();
} catch {} } catch {}
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -613,7 +642,7 @@ export class ContextCommand extends Command {
}; };
} }
await this.authManager.updateContext(context); this.authManager.updateContext(context);
ui.displaySuccess('Context updated'); ui.displaySuccess('Context updated');
// Display what was set // Display what was set
@@ -631,7 +660,7 @@ export class ContextCommand extends Command {
return { return {
success: true, success: true,
action: 'set', action: 'set',
context: (await this.authManager.getContext()) || undefined, context: this.authManager.getContext() || undefined,
message: 'Context updated' message: 'Context updated'
}; };
} catch (error) { } catch (error) {
@@ -645,26 +674,6 @@ export class ContextCommand extends Command {
} }
} }
/**
* Handle errors
*/
private handleError(error: any): void {
if (error instanceof AuthenticationError) {
console.error(chalk.red(`\n✗ ${error.message}`));
if (error.code === 'NOT_AUTHENTICATED') {
ui.displayWarning('Please authenticate first: tm auth login');
}
} else {
const msg = error?.message ?? String(error);
console.error(chalk.red(`Error: ${msg}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
}
}
/** /**
* Set the last result for programmatic access * Set the last result for programmatic access
*/ */
@@ -682,10 +691,57 @@ export class ContextCommand extends Command {
/** /**
* Get current context (for programmatic usage) * Get current context (for programmatic usage)
*/ */
getContext(): Promise<UserContext | null> { getContext(): UserContext | null {
return this.authManager.getContext(); return this.authManager.getContext();
} }
/**
* Interactive context setup (for post-auth flow)
* Prompts user to select org and brief
*/
async setupContextInteractive(): Promise<{
success: boolean;
orgSelected: boolean;
briefSelected: boolean;
}> {
try {
// Ask if user wants to set up workspace context
const { setupContext } = await inquirer.prompt([
{
type: 'confirm',
name: 'setupContext',
message: 'Would you like to set up your workspace context now?',
default: true
}
]);
if (!setupContext) {
return { success: true, orgSelected: false, briefSelected: false };
}
// Select organization
const orgResult = await this.selectOrganization();
if (!orgResult.success || !orgResult.context?.orgId) {
return { success: false, orgSelected: false, briefSelected: false };
}
// Select brief
const briefResult = await this.selectBrief(orgResult.context.orgId);
return {
success: true,
orgSelected: true,
briefSelected: briefResult.success
};
} catch (error) {
console.error(
chalk.yellow(
'\nContext setup skipped due to error. You can set it up later with "tm context"'
)
);
return { success: false, orgSelected: false, briefSelected: false };
}
}
/** /**
* Clean up resources * Clean up resources
*/ */

View File

@@ -7,13 +7,10 @@ import { Command } from 'commander';
import chalk from 'chalk'; import chalk from 'chalk';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import ora, { Ora } from 'ora'; import ora, { Ora } from 'ora';
import { import { AuthManager, type UserContext } from '@tm/core/auth';
AuthManager,
AuthenticationError,
type UserContext
} from '@tm/core/auth';
import { TaskMasterCore, type ExportResult } from '@tm/core'; import { TaskMasterCore, type ExportResult } from '@tm/core';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
/** /**
* Result type from export command * Result type from export command
@@ -197,8 +194,7 @@ export class ExportCommand extends Command {
}; };
} catch (error: any) { } catch (error: any) {
if (spinner?.isSpinning) spinner.fail('Export failed'); if (spinner?.isSpinning) spinner.fail('Export failed');
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -334,26 +330,6 @@ export class ExportCommand extends Command {
return confirmed; return confirmed;
} }
/**
* Handle errors
*/
private handleError(error: any): void {
if (error instanceof AuthenticationError) {
console.error(chalk.red(`\n✗ ${error.message}`));
if (error.code === 'NOT_AUTHENTICATED') {
ui.displayWarning('Please authenticate first: tm auth login');
}
} else {
const msg = error?.message ?? String(error);
console.error(chalk.red(`Error: ${msg}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
}
}
/** /**
* Get the last export result (useful for testing) * Get the last export result (useful for testing)
*/ */

View File

@@ -17,8 +17,9 @@ import {
} from '@tm/core'; } from '@tm/core';
import type { StorageType } from '@tm/core/types'; import type { StorageType } from '@tm/core/types';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
import { import {
displayHeader,
displayDashboards, displayDashboards,
calculateTaskStatistics, calculateTaskStatistics,
calculateSubtaskStatistics, calculateSubtaskStatistics,
@@ -106,14 +107,7 @@ export class ListTasksCommand extends Command {
this.displayResults(result, options); this.displayResults(result, options);
} }
} catch (error: any) { } catch (error: any) {
const msg = error?.getSanitizedDetails?.() ?? { displayError(error);
message: error?.message ?? String(error)
};
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
process.exit(1);
} }
} }
@@ -257,15 +251,12 @@ export class ListTasksCommand extends Command {
* Display in text format with tables * Display in text format with tables
*/ */
private displayText(data: ListTasksResult, withSubtasks?: boolean): void { private displayText(data: ListTasksResult, withSubtasks?: boolean): void {
const { tasks, tag } = data; const { tasks, tag, storageType } = data;
// Get file path for display // Display header using utility function
const filePath = this.tmCore ? `.taskmaster/tasks/tasks.json` : undefined; displayCommandHeader(this.tmCore, {
// Display header without banner (banner already shown by main CLI)
displayHeader({
tag: tag || 'master', tag: tag || 'master',
filePath: filePath storageType
}); });
// No tasks message // No tasks message

View File

@@ -9,8 +9,9 @@ import chalk from 'chalk';
import boxen from 'boxen'; import boxen from 'boxen';
import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core'; import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core';
import type { StorageType } from '@tm/core/types'; import type { StorageType } from '@tm/core/types';
import { displayError } from '../utils/error-handler.js';
import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import { displayHeader } from '../ui/index.js'; import { displayCommandHeader } from '../utils/display-helpers.js';
/** /**
* Options interface for the next command * Options interface for the next command
@@ -58,6 +59,7 @@ export class NextCommand extends Command {
* Execute the next command * Execute the next command
*/ */
private async executeCommand(options: NextCommandOptions): Promise<void> { private async executeCommand(options: NextCommandOptions): Promise<void> {
let hasError = false;
try { try {
// Validate options (throws on invalid options) // Validate options (throws on invalid options)
this.validateOptions(options); this.validateOptions(options);
@@ -76,16 +78,17 @@ export class NextCommand extends Command {
this.displayResults(result, options); this.displayResults(result, options);
} }
} catch (error: any) { } catch (error: any) {
const msg = error?.getSanitizedDetails?.() ?? { hasError = true;
message: error?.message ?? String(error) displayError(error, { skipExit: true });
};
// Allow error to propagate for library compatibility
throw new Error(msg.message || 'Unexpected error in next command');
} finally { } finally {
// Always clean up resources, even on error // Always clean up resources, even on error
await this.cleanup(); await this.cleanup();
} }
// Exit after cleanup completes
if (hasError) {
process.exit(1);
}
} }
/** /**
@@ -170,9 +173,10 @@ export class NextCommand extends Command {
* Display in text format * Display in text format
*/ */
private displayText(result: NextTaskResult): void { private displayText(result: NextTaskResult): void {
// Display header with tag (no file path for next command) // Display header with storage info
displayHeader({ displayCommandHeader(this.tmCore, {
tag: result.tag || 'master' tag: result.tag || 'master',
storageType: result.storageType
}); });
if (!result.found || !result.task) { if (!result.found || !result.task) {
@@ -191,7 +195,6 @@ export class NextCommand extends Command {
} }
) )
); );
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`);
console.log( console.log(
`\n${chalk.dim('Tip: Try')} ${chalk.cyan('task-master list --status pending')} ${chalk.dim('to see all pending tasks')}` `\n${chalk.dim('Tip: Try')} ${chalk.cyan('task-master list --status pending')} ${chalk.dim('to see all pending tasks')}`
); );
@@ -208,8 +211,6 @@ export class NextCommand extends Command {
headerColor: 'green', headerColor: 'green',
showSuggestedActions: true showSuggestedActions: true
}); });
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`);
} }
/** /**

View File

@@ -12,6 +12,7 @@ import {
type TaskStatus type TaskStatus
} from '@tm/core'; } from '@tm/core';
import type { StorageType } from '@tm/core/types'; import type { StorageType } from '@tm/core/types';
import { displayError } from '../utils/error-handler.js';
/** /**
* Valid task status values for validation * Valid task status values for validation
@@ -85,6 +86,7 @@ export class SetStatusCommand extends Command {
private async executeCommand( private async executeCommand(
options: SetStatusCommandOptions options: SetStatusCommandOptions
): Promise<void> { ): Promise<void> {
let hasError = false;
try { try {
// Validate required options // Validate required options
if (!options.id) { if (!options.id) {
@@ -135,16 +137,15 @@ export class SetStatusCommand extends Command {
oldStatus: result.oldStatus, oldStatus: result.oldStatus,
newStatus: result.newStatus newStatus: result.newStatus
}); });
} catch (error) { } catch (error: any) {
const errorMessage = hasError = true;
error instanceof Error ? error.message : String(error);
if (!options.silent) {
console.error(
chalk.red(`Failed to update task ${taskId}: ${errorMessage}`)
);
}
if (options.format === 'json') { if (options.format === 'json') {
const errorMessage = error?.getSanitizedDetails
? error.getSanitizedDetails().message
: error instanceof Error
? error.message
: String(error);
console.log( console.log(
JSON.stringify({ JSON.stringify({
success: false, success: false,
@@ -153,8 +154,13 @@ export class SetStatusCommand extends Command {
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}) })
); );
} else if (!options.silent) {
// Show which task failed with context
console.error(chalk.red(`\nFailed to update task ${taskId}:`));
displayError(error, { skipExit: true });
} }
process.exit(1); // Don't exit here - let finally block clean up first
break;
} }
} }
@@ -170,25 +176,26 @@ export class SetStatusCommand extends Command {
// Display results // Display results
this.displayResults(this.lastResult, options); this.displayResults(this.lastResult, options);
} catch (error) { } catch (error: any) {
const errorMessage = hasError = true;
error instanceof Error ? error.message : 'Unknown error occurred';
if (!options.silent) {
console.error(chalk.red(`Error: ${errorMessage}`));
}
if (options.format === 'json') { if (options.format === 'json') {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
console.log(JSON.stringify({ success: false, error: errorMessage })); console.log(JSON.stringify({ success: false, error: errorMessage }));
} else if (!options.silent) {
displayError(error, { skipExit: true });
} }
process.exit(1);
} finally { } finally {
// Clean up resources // Clean up resources
if (this.tmCore) { if (this.tmCore) {
await this.tmCore.close(); await this.tmCore.close();
} }
} }
// Exit after cleanup completes
if (hasError) {
process.exit(1);
}
} }
/** /**

View File

@@ -9,7 +9,9 @@ import boxen from 'boxen';
import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core'; import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core';
import type { StorageType } from '@tm/core/types'; import type { StorageType } from '@tm/core/types';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
/** /**
* Options interface for the show command * Options interface for the show command
@@ -112,14 +114,7 @@ export class ShowCommand extends Command {
this.displayResults(result, options); this.displayResults(result, options);
} }
} catch (error: any) { } catch (error: any) {
const msg = error?.getSanitizedDetails?.() ?? { displayError(error);
message: error?.message ?? String(error)
};
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
process.exit(1);
} }
} }
@@ -257,6 +252,15 @@ export class ShowCommand extends Command {
return; return;
} }
// Display header with storage info
const activeTag = this.tmCore?.getActiveTag() || 'master';
displayCommandHeader(this.tmCore, {
tag: activeTag,
storageType: result.storageType
});
console.log(); // Add spacing
// Use the global task details display function // Use the global task details display function
displayTaskDetails(result.task, { displayTaskDetails(result.task, {
statusFilter: options.status, statusFilter: options.status,
@@ -271,8 +275,12 @@ export class ShowCommand extends Command {
result: ShowMultipleTasksResult, result: ShowMultipleTasksResult,
_options: ShowCommandOptions _options: ShowCommandOptions
): void { ): void {
// Header // Display header with storage info
ui.displayBanner(`Tasks (${result.tasks.length} found)`); const activeTag = this.tmCore?.getActiveTag() || 'master';
displayCommandHeader(this.tmCore, {
tag: activeTag,
storageType: result.storageType
});
if (result.notFound.length > 0) { if (result.notFound.length > 0) {
console.log(chalk.yellow(`\n⚠ Not found: ${result.notFound.join(', ')}`)); console.log(chalk.yellow(`\n⚠ Not found: ${result.notFound.join(', ')}`));
@@ -291,8 +299,6 @@ export class ShowCommand extends Command {
showDependencies: true showDependencies: true
}) })
); );
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`);
} }
/** /**

View File

@@ -16,6 +16,7 @@ import {
} from '@tm/core'; } from '@tm/core';
import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
/** /**
* CLI-specific options interface for the start command * CLI-specific options interface for the start command
@@ -160,8 +161,7 @@ export class StartCommand extends Command {
if (spinner) { if (spinner) {
spinner.fail('Operation failed'); spinner.fail('Operation failed');
} }
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -452,22 +452,6 @@ export class StartCommand extends Command {
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`); console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`);
} }
/**
* Handle general errors
*/
private handleError(error: any): void {
const msg = error?.getSanitizedDetails?.() ?? {
message: error?.message ?? String(error)
};
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
// Show stack trace in development mode or when DEBUG is set
const isDevelopment = process.env.NODE_ENV !== 'production';
if ((isDevelopment || process.env.DEBUG) && error.stack) {
console.error(chalk.gray(error.stack));
}
}
/** /**
* Set the last result for programmatic access * Set the last result for programmatic access
*/ */

View File

@@ -24,6 +24,9 @@ export {
// UI utilities (for other commands to use) // UI utilities (for other commands to use)
export * as ui from './utils/ui.js'; export * as ui from './utils/ui.js';
// Error handling utilities
export { displayError, isDebugMode } from './utils/error-handler.js';
// Auto-update utilities // Auto-update utilities
export { export {
checkForUpdate, checkForUpdate,

View File

@@ -5,6 +5,16 @@
import chalk from 'chalk'; import chalk from 'chalk';
/**
* Brief information for API storage
*/
export interface BriefInfo {
briefId: string;
briefName: string;
orgSlug?: string;
webAppUrl?: string;
}
/** /**
* Header configuration options * Header configuration options
*/ */
@@ -12,16 +22,44 @@ export interface HeaderOptions {
title?: string; title?: string;
tag?: string; tag?: string;
filePath?: string; filePath?: string;
storageType?: 'api' | 'file';
briefInfo?: BriefInfo;
} }
/** /**
* Display the Task Master header with project info * Display the Task Master header with project info
*/ */
export function displayHeader(options: HeaderOptions = {}): void { export function displayHeader(options: HeaderOptions = {}): void {
const { filePath, tag } = options; const { filePath, tag, storageType, briefInfo } = options;
// Display tag and file path info // Display different header based on storage type
if (tag) { if (storageType === 'api' && briefInfo) {
// API storage: Show brief information
const briefDisplay = `🏷 Brief: ${chalk.cyan(briefInfo.briefName)} ${chalk.gray(`(${briefInfo.briefId})`)}`;
console.log(briefDisplay);
// Construct and display the brief URL or ID
if (briefInfo.webAppUrl && briefInfo.orgSlug) {
const briefUrl = `${briefInfo.webAppUrl}/home/${briefInfo.orgSlug}/briefs/${briefInfo.briefId}/plan`;
console.log(`Listing tasks from: ${chalk.dim(briefUrl)}`);
} else if (briefInfo.webAppUrl) {
// Show web app URL and brief ID if org slug is missing
console.log(
`Listing tasks from: ${chalk.dim(`${briefInfo.webAppUrl} (Brief: ${briefInfo.briefId})`)}`
);
console.log(
chalk.yellow(
`💡 Tip: Run ${chalk.cyan('tm context select')} to set your organization and see the full URL`
)
);
} else {
// Fallback: just show the brief ID if we can't get web app URL
console.log(
`Listing tasks from: ${chalk.dim(`API (Brief ID: ${briefInfo.briefId})`)}`
);
}
} else if (tag) {
// File storage: Show tag information
let tagInfo = ''; let tagInfo = '';
if (tag && tag !== 'master') { if (tag && tag !== 'master') {

View File

@@ -0,0 +1,75 @@
/**
* @fileoverview Display helper utilities for commands
* Provides DRY utilities for displaying headers and other command output
*/
import type { TaskMasterCore } from '@tm/core';
import type { StorageType } from '@tm/core/types';
import { displayHeader, type BriefInfo } from '../ui/index.js';
/**
* Get web app base URL from environment
*/
function getWebAppUrl(): string | undefined {
const baseDomain =
process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN;
if (!baseDomain) {
return undefined;
}
// If it already includes protocol, use as-is
if (baseDomain.startsWith('http://') || baseDomain.startsWith('https://')) {
return baseDomain;
}
// Otherwise, add protocol based on domain
if (baseDomain.includes('localhost') || baseDomain.includes('127.0.0.1')) {
return `http://${baseDomain}`;
}
return `https://${baseDomain}`;
}
/**
* Display the command header with appropriate storage information
* Handles both API and file storage displays
*/
export function displayCommandHeader(
tmCore: TaskMasterCore | undefined,
options: {
tag?: string;
storageType: Exclude<StorageType, 'auto'>;
}
): void {
const { tag, storageType } = options;
// Get brief info if using API storage
let briefInfo: BriefInfo | undefined;
if (storageType === 'api' && tmCore) {
const storageInfo = tmCore.getStorageDisplayInfo();
if (storageInfo) {
// Construct full brief info with web app URL
briefInfo = {
...storageInfo,
webAppUrl: getWebAppUrl()
};
}
}
// Get file path for display (only for file storage)
// Note: The file structure is fixed for file storage and won't change.
// This is a display-only relative path, not used for actual file operations.
const filePath =
storageType === 'file' && tmCore
? `.taskmaster/tasks/tasks.json`
: undefined;
// Display header
displayHeader({
tag: tag || 'master',
filePath: filePath,
storageType: storageType === 'api' ? 'api' : 'file',
briefInfo: briefInfo
});
}

View File

@@ -0,0 +1,60 @@
/**
* @fileoverview Centralized error handling utilities for CLI
* Provides consistent error formatting and debug mode detection
*/
import chalk from 'chalk';
/**
* Check if debug mode is enabled via environment variable
* Only returns true when DEBUG is explicitly set to 'true' or '1'
*
* @returns True if debug mode is enabled
*/
export function isDebugMode(): boolean {
return process.env.DEBUG === 'true' || process.env.DEBUG === '1';
}
/**
* Display an error to the user with optional stack trace in debug mode
* Handles both TaskMasterError instances and regular errors
*
* @param error - The error to display
* @param options - Display options
*/
export function displayError(
error: any,
options: {
/** Skip exit, useful when caller wants to handle exit */
skipExit?: boolean;
/** Force show stack trace regardless of debug mode */
forceStack?: boolean;
} = {}
): void {
// Check if it's a TaskMasterError with sanitized details
if (error?.getSanitizedDetails) {
const sanitized = error.getSanitizedDetails();
console.error(chalk.red(`\n${sanitized.message}`));
// Show stack trace in debug mode or if forced
if ((isDebugMode() || options.forceStack) && error.stack) {
console.error(chalk.gray('\nStack trace:'));
console.error(chalk.gray(error.stack));
}
} else {
// For other errors, show the message
const message = error?.message ?? String(error);
console.error(chalk.red(`\nError: ${message}`));
// Show stack trace in debug mode or if forced
if ((isDebugMode() || options.forceStack) && error?.stack) {
console.error(chalk.gray('\nStack trace:'));
console.error(chalk.gray(error.stack));
}
}
// Exit if not skipped
if (!options.skipExit) {
process.exit(1);
}
}

View File

@@ -3,6 +3,22 @@ title: CLI Commands
sidebarTitle: "CLI Commands" sidebarTitle: "CLI Commands"
--- ---
## Debug Mode
For troubleshooting issues, you can enable debug mode to get detailed error information:
```bash
# Enable debug mode for any command
DEBUG=true task-master <command>
# Example with next command
DEBUG=true task-master next
```
Debug mode shows full error messages, stack traces, and additional context. See the [Troubleshooting guide](/getting-started/troubleshooting) for more details.
## Commands
<AccordionGroup> <AccordionGroup>
<Accordion title="Parse PRD"> <Accordion title="Parse PRD">

View File

@@ -33,6 +33,7 @@
] ]
}, },
"getting-started/api-keys", "getting-started/api-keys",
"getting-started/troubleshooting",
"getting-started/faq", "getting-started/faq",
"getting-started/contribute" "getting-started/contribute"
] ]

View File

@@ -3,7 +3,32 @@ title: FAQ
sidebarTitle: "FAQ" sidebarTitle: "FAQ"
--- ---
Coming soon. ## Common Questions
### How do I get more detailed error information?
Enable debug mode by setting the `DEBUG` environment variable:
```bash
DEBUG=true task-master <command>
```
This shows full error messages, stack traces, and additional context. See our [Troubleshooting guide](/getting-started/troubleshooting) for complete details.
### Why are my commands failing silently?
Task Master uses sanitized error messages by default to provide a clean user experience. Enable debug mode to see technical details about what went wrong.
### How do I report a bug?
When reporting issues, please:
1. Enable debug mode and include the full error output
2. Provide your environment details (OS, Node.js version, Task Master version)
3. Include the command that failed and your project configuration
See our [Troubleshooting guide](/getting-started/troubleshooting) for complete bug reporting guidelines.
More frequently asked questions coming soon.
## 💬 Getting Help ## 💬 Getting Help

View File

@@ -0,0 +1,155 @@
---
title: Troubleshooting
sidebarTitle: "Troubleshooting"
---
This guide helps you troubleshoot common issues and get more detailed error information when using Task Master.
## Debug Mode
Task Master includes a debug mode that provides detailed error information including stack traces. This is especially useful when reporting issues or debugging problems.
### Enabling Debug Mode
Set the `DEBUG` environment variable to enable debug mode:
```bash
# Enable debug mode for a single command
DEBUG=true task-master next
# Or use '1' instead of 'true'
DEBUG=1 task-master list
# Enable for entire session (bash/zsh)
export DEBUG=true
task-master next # Now shows detailed errors
# Enable for entire session (fish shell)
set -x DEBUG true
task-master next
```
### What Debug Mode Shows
With debug mode enabled, errors will include:
- **Full error messages**: Complete technical details instead of sanitized user messages
- **Stack traces**: Detailed information about where errors occurred
- **Error context**: Additional metadata about the failed operation
- **Error chains**: If an error was caused by another error, both are shown
### Example Output
**Normal mode (default):**
```
Error: Unable to read tasks file
```
**Debug mode:**
```
TaskMasterError[FILE_READ_ERROR]: Unable to read tasks file
Stack trace:
at TaskService.loadTasks (/path/to/task-service.ts:123)
at NextCommand.execute (/path/to/next.command.ts:45)
...
```
## Common Issues
### File Permission Errors
**Problem**: Commands fail with file access errors
**Solution**:
1. Check file permissions in `.taskmaster/` directory
2. Ensure your user has write access to the project directory
3. Run with debug mode to see the exact file path causing issues
### API Connection Issues
**Problem**: Commands timeout or fail when using API storage
**Solutions**:
1. Check your internet connection
2. Verify API keys are correctly configured
3. Check if your organization has firewall restrictions
4. Use debug mode to see specific network errors
### Task File Corruption
**Problem**: Commands report invalid task data
**Solutions**:
1. Check if `.taskmaster/tasks/tasks.json` is valid JSON
2. Restore from backup if available
3. Run `task-master generate` to regenerate task files from main data
### Model Configuration Issues
**Problem**: AI-powered commands fail
**Solutions**:
1. Verify API keys are set: `task-master models`
2. Check if the configured model is available
3. Try switching to a different model
4. Use debug mode to see specific API error messages
## Getting Help
When reporting issues, please:
1. **Enable debug mode** and include the full error output
2. **Specify your environment**:
- Operating system and version
- Node.js version (`node --version`)
- Task Master version (`task-master --version`)
3. **Include relevant context**:
- Command that failed
- Project configuration
- Recent changes to your setup
### Support Channels
- **GitHub Issues**: [Report bugs and request features](https://github.com/eyaltoledano/claude-task-master/issues)
- **Discord**: [Join our community for help](https://discord.gg/taskmasterai)
- **GitHub Discussions**: [Ask questions and share ideas](https://github.com/eyaltoledano/claude-task-master/discussions)
## Advanced Debugging
### Inspecting Task Data
If you suspect task data corruption:
```bash
# View raw task data
cat .taskmaster/tasks/tasks.json | jq '.'
# Check specific task
task-master show <task-id> --format=json
```
### Configuration Debugging
Check your current configuration:
```bash
# View model configuration
task-master models
# Check if initialization is complete
ls -la .taskmaster/
```
### Network Debugging
For API storage issues:
```bash
# Test with curl (replace with your API endpoint)
curl -H "Authorization: Bearer $ANTHROPIC_API_KEY" \
-H "Content-Type: application/json" \
https://api.anthropic.com/v1/models
```
Remember to enable debug mode (`DEBUG=true`) when troubleshooting to get the most helpful error information.

62
output.txt Normal file

File diff suppressed because one or more lines are too long

86
package-lock.json generated
View File

@@ -104,6 +104,7 @@
"name": "@tm/cli", "name": "@tm/cli",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@inquirer/search": "^3.2.0",
"@tm/core": "*", "@tm/core": "*",
"boxen": "^8.0.1", "boxen": "^8.0.1",
"chalk": "5.6.2", "chalk": "5.6.2",
@@ -124,6 +125,91 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"apps/cli/node_modules/@inquirer/ansi": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz",
"integrity": "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"apps/cli/node_modules/@inquirer/figures": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.14.tgz",
"integrity": "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"apps/cli/node_modules/@inquirer/search": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.0.tgz",
"integrity": "sha512-a5SzB/qrXafDX1Z4AZW3CsVoiNxcIYCzYP7r9RzrfMpaLpB+yWi5U8BWagZyLmwR0pKbbL5umnGRd0RzGVI8bQ==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^10.3.0",
"@inquirer/figures": "^1.0.14",
"@inquirer/type": "^3.0.9",
"yoctocolors-cjs": "^2.1.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"apps/cli/node_modules/@inquirer/search/node_modules/@inquirer/core": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.0.tgz",
"integrity": "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^1.0.1",
"@inquirer/figures": "^1.0.14",
"@inquirer/type": "^3.0.9",
"cli-width": "^4.1.0",
"mute-stream": "^2.0.0",
"signal-exit": "^4.1.0",
"wrap-ansi": "^6.2.0",
"yoctocolors-cjs": "^2.1.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"apps/cli/node_modules/@inquirer/search/node_modules/@inquirer/type": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.9.tgz",
"integrity": "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"apps/docs": { "apps/docs": {
"version": "0.0.6", "version": "0.0.6",
"devDependencies": { "devDependencies": {

View File

@@ -35,7 +35,7 @@ vi.mock('./credential-store.js', () => {
} }
saveCredentials() {} saveCredentials() {}
clearCredentials() {} clearCredentials() {}
hasValidCredentials() { hasCredentials() {
return false; return false;
} }
} }

View File

@@ -29,8 +29,6 @@ export class AuthManager {
private oauthService: OAuthService; private oauthService: OAuthService;
private supabaseClient: SupabaseAuthClient; private supabaseClient: SupabaseAuthClient;
private organizationService?: OrganizationService; private organizationService?: OrganizationService;
private logger = getLogger('AuthManager');
private refreshPromise: Promise<AuthCredentials> | null = null;
private constructor(config?: Partial<AuthConfig>) { private constructor(config?: Partial<AuthConfig>) {
this.credentialStore = CredentialStore.getInstance(config); this.credentialStore = CredentialStore.getInstance(config);
@@ -83,60 +81,10 @@ export class AuthManager {
/** /**
* Get stored authentication credentials * Get stored authentication credentials
* Automatically refreshes the token if expired * Returns credentials as-is (even if expired). Refresh must be triggered explicitly
* via refreshToken() or will occur automatically when using the Supabase client for API calls.
*/ */
async getCredentials(): Promise<AuthCredentials | null> { getCredentials(): AuthCredentials | null {
const credentials = this.credentialStore.getCredentials({
allowExpired: true
});
if (!credentials) {
return null;
}
// Check if credentials are expired (with 30-second clock skew buffer)
const CLOCK_SKEW_MS = 30_000;
const isExpired = credentials.expiresAt
? new Date(credentials.expiresAt).getTime() <= Date.now() + CLOCK_SKEW_MS
: false;
// If expired and we have a refresh token, attempt refresh
if (isExpired && credentials.refreshToken) {
// Return existing refresh promise if one is in progress
if (this.refreshPromise) {
try {
return await this.refreshPromise;
} catch {
return null;
}
}
try {
this.logger.info('Token expired, attempting automatic refresh...');
this.refreshPromise = this.refreshToken();
const result = await this.refreshPromise;
return result;
} catch (error) {
this.logger.warn('Automatic token refresh failed:', error);
return null;
} finally {
this.refreshPromise = null;
}
}
// Return null if expired and no refresh token
if (isExpired) {
return null;
}
return credentials;
}
/**
* Get stored authentication credentials (synchronous version)
* Does not attempt automatic refresh
*/
getCredentialsSync(): AuthCredentials | null {
return this.credentialStore.getCredentials(); return this.credentialStore.getCredentials();
} }
@@ -219,25 +167,26 @@ export class AuthManager {
} }
/** /**
* Check if authenticated * Check if authenticated (credentials exist, regardless of expiration)
* @returns true if credentials are stored, including expired credentials
*/ */
isAuthenticated(): boolean { isAuthenticated(): boolean {
return this.credentialStore.hasValidCredentials(); return this.credentialStore.hasCredentials();
} }
/** /**
* Get the current user context (org/brief selection) * Get the current user context (org/brief selection)
*/ */
async getContext(): Promise<UserContext | null> { getContext(): UserContext | null {
const credentials = await this.getCredentials(); const credentials = this.getCredentials();
return credentials?.selectedContext || null; return credentials?.selectedContext || null;
} }
/** /**
* Update the user context (org/brief selection) * Update the user context (org/brief selection)
*/ */
async updateContext(context: Partial<UserContext>): Promise<void> { updateContext(context: Partial<UserContext>): void {
const credentials = await this.getCredentials(); const credentials = this.getCredentials();
if (!credentials) { if (!credentials) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED'); throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
} }
@@ -262,8 +211,8 @@ export class AuthManager {
/** /**
* Clear the user context * Clear the user context
*/ */
async clearContext(): Promise<void> { clearContext(): void {
const credentials = await this.getCredentials(); const credentials = this.getCredentials();
if (!credentials) { if (!credentials) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED'); throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
} }
@@ -280,7 +229,7 @@ export class AuthManager {
private async getOrganizationService(): Promise<OrganizationService> { private async getOrganizationService(): Promise<OrganizationService> {
if (!this.organizationService) { if (!this.organizationService) {
// First check if we have credentials with a token // First check if we have credentials with a token
const credentials = await this.getCredentials(); const credentials = this.getCredentials();
if (!credentials || !credentials.token) { if (!credentials || !credentials.token) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED'); throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
} }

View File

@@ -7,11 +7,13 @@ import path from 'path';
import { AuthConfig } from './types.js'; import { AuthConfig } from './types.js';
// Single base domain for all URLs // Single base domain for all URLs
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsup's env option // Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option
// Runtime: process.env.TM_BASE_DOMAIN can override for staging/development
// Default: https://tryhamster.com for production // Default: https://tryhamster.com for production
const BASE_DOMAIN = const BASE_DOMAIN =
process.env.TM_PUBLIC_BASE_DOMAIN || // This gets replaced at build time by tsup process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux)
'https://tryhamster.com'; process.env.TM_PUBLIC_BASE_DOMAIN; // Build-time (baked into compiled code)
/** /**
* Default authentication configuration * Default authentication configuration
@@ -19,7 +21,7 @@ const BASE_DOMAIN =
*/ */
export const DEFAULT_AUTH_CONFIG: AuthConfig = { export const DEFAULT_AUTH_CONFIG: AuthConfig = {
// Base domain for all services // Base domain for all services
baseUrl: BASE_DOMAIN, baseUrl: BASE_DOMAIN!,
// Configuration directory and file paths // Configuration directory and file paths
configDir: path.join(os.homedir(), '.taskmaster'), configDir: path.join(os.homedir(), '.taskmaster'),

View File

@@ -52,7 +52,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(expiredCredentials); credentialStore.saveCredentials(expiredCredentials);
const retrieved = credentialStore.getCredentials(); const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull(); expect(retrieved).toBeNull();
}); });
@@ -69,7 +69,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(validCredentials); credentialStore.saveCredentials(validCredentials);
const retrieved = credentialStore.getCredentials(); const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull(); expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('valid-token'); expect(retrieved?.token).toBe('valid-token');
@@ -92,6 +92,25 @@ describe('CredentialStore - Token Expiration', () => {
expect(retrieved).not.toBeNull(); expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('expired-token'); expect(retrieved?.token).toBe('expired-token');
}); });
it('should return expired token by default (allowExpired defaults to true)', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token-default',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
// Call without options - should default to allowExpired: true
const retrieved = credentialStore.getCredentials();
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('expired-token-default');
});
}); });
describe('Clock Skew Tolerance', () => { describe('Clock Skew Tolerance', () => {
@@ -108,7 +127,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(almostExpiredCredentials); credentialStore.saveCredentials(almostExpiredCredentials);
const retrieved = credentialStore.getCredentials(); const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull(); expect(retrieved).toBeNull();
}); });
@@ -126,7 +145,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(validCredentials); credentialStore.saveCredentials(validCredentials);
const retrieved = credentialStore.getCredentials(); const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull(); expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('valid-token'); expect(retrieved?.token).toBe('valid-token');
@@ -146,7 +165,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(credentials); credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials(); const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull(); expect(retrieved).not.toBeNull();
expect(typeof retrieved?.expiresAt).toBe('number'); // Normalized to number expect(typeof retrieved?.expiresAt).toBe('number'); // Normalized to number
@@ -164,7 +183,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(credentials); credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials(); const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull(); expect(retrieved).not.toBeNull();
expect(typeof retrieved?.expiresAt).toBe('number'); expect(typeof retrieved?.expiresAt).toBe('number');
@@ -185,7 +204,7 @@ describe('CredentialStore - Token Expiration', () => {
mode: 0o600 mode: 0o600
}); });
const retrieved = credentialStore.getCredentials(); const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull(); expect(retrieved).toBeNull();
}); });
@@ -203,7 +222,7 @@ describe('CredentialStore - Token Expiration', () => {
mode: 0o600 mode: 0o600
}); });
const retrieved = credentialStore.getCredentials(); const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull(); expect(retrieved).toBeNull();
}); });
@@ -244,15 +263,15 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(credentials); credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials(); const retrieved = credentialStore.getCredentials({ allowExpired: false });
// Should be normalized to number for runtime use // Should be normalized to number for runtime use
expect(typeof retrieved?.expiresAt).toBe('number'); expect(typeof retrieved?.expiresAt).toBe('number');
}); });
}); });
describe('hasValidCredentials', () => { describe('hasCredentials', () => {
it('should return false for expired credentials', () => { it('should return true for expired credentials', () => {
const expiredCredentials: AuthCredentials = { const expiredCredentials: AuthCredentials = {
token: 'expired-token', token: 'expired-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
@@ -264,7 +283,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(expiredCredentials); credentialStore.saveCredentials(expiredCredentials);
expect(credentialStore.hasValidCredentials()).toBe(false); expect(credentialStore.hasCredentials()).toBe(true);
}); });
it('should return true for valid credentials', () => { it('should return true for valid credentials', () => {
@@ -279,11 +298,11 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(validCredentials); credentialStore.saveCredentials(validCredentials);
expect(credentialStore.hasValidCredentials()).toBe(true); expect(credentialStore.hasCredentials()).toBe(true);
}); });
it('should return false when no credentials exist', () => { it('should return false when no credentials exist', () => {
expect(credentialStore.hasValidCredentials()).toBe(false); expect(credentialStore.hasCredentials()).toBe(false);
}); });
}); });
}); });

View File

@@ -197,7 +197,7 @@ describe('CredentialStore', () => {
JSON.stringify(mockCredentials) JSON.stringify(mockCredentials)
); );
const result = store.getCredentials(); const result = store.getCredentials({ allowExpired: false });
expect(result).toBeNull(); expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith( expect(mockLogger.warn).toHaveBeenCalledWith(
@@ -226,6 +226,31 @@ describe('CredentialStore', () => {
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result?.token).toBe('expired-token'); expect(result?.token).toBe('expired-token');
}); });
it('should return expired tokens by default (allowExpired defaults to true)', () => {
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
const mockCredentials = {
token: 'expired-token-default',
userId: 'user-expired',
expiresAt: expiredTimestamp,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
// Call without options - should default to allowExpired: true
const result = store.getCredentials();
expect(result).not.toBeNull();
expect(result?.token).toBe('expired-token-default');
expect(mockLogger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('Authentication token has expired')
);
});
}); });
describe('saveCredentials with timestamp normalization', () => { describe('saveCredentials with timestamp normalization', () => {
@@ -451,7 +476,7 @@ describe('CredentialStore', () => {
}); });
}); });
describe('hasValidCredentials', () => { describe('hasCredentials', () => {
it('should return true when valid unexpired credentials exist', () => { it('should return true when valid unexpired credentials exist', () => {
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
const credentials = { const credentials = {
@@ -465,10 +490,10 @@ describe('CredentialStore', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials)); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
expect(store.hasValidCredentials()).toBe(true); expect(store.hasCredentials()).toBe(true);
}); });
it('should return false when credentials are expired', () => { it('should return true when credentials are expired', () => {
const pastDate = new Date(Date.now() - 3600000); // 1 hour ago const pastDate = new Date(Date.now() - 3600000); // 1 hour ago
const credentials = { const credentials = {
token: 'expired-token', token: 'expired-token',
@@ -481,13 +506,13 @@ describe('CredentialStore', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials)); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
expect(store.hasValidCredentials()).toBe(false); expect(store.hasCredentials()).toBe(true);
}); });
it('should return false when no credentials exist', () => { it('should return false when no credentials exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.existsSync).mockReturnValue(false);
expect(store.hasValidCredentials()).toBe(false); expect(store.hasCredentials()).toBe(false);
}); });
it('should return false when file contains invalid JSON', () => { it('should return false when file contains invalid JSON', () => {
@@ -495,7 +520,7 @@ describe('CredentialStore', () => {
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {'); vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
vi.mocked(fs.renameSync).mockImplementation(() => undefined); vi.mocked(fs.renameSync).mockImplementation(() => undefined);
expect(store.hasValidCredentials()).toBe(false); expect(store.hasCredentials()).toBe(false);
}); });
it('should return false for credentials without expiry', () => { it('should return false for credentials without expiry', () => {
@@ -510,7 +535,7 @@ describe('CredentialStore', () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials)); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
// Credentials without expiry are considered invalid // Credentials without expiry are considered invalid
expect(store.hasValidCredentials()).toBe(false); expect(store.hasCredentials()).toBe(false);
// Should log warning about missing expiration // Should log warning about missing expiration
expect(mockLogger.warn).toHaveBeenCalledWith( expect(mockLogger.warn).toHaveBeenCalledWith(
@@ -518,14 +543,14 @@ describe('CredentialStore', () => {
); );
}); });
it('should use allowExpired=false by default', () => { it('should use allowExpired=true', () => {
// Spy on getCredentials to verify it's called with correct params // Spy on getCredentials to verify it's called with correct params
const getCredentialsSpy = vi.spyOn(store, 'getCredentials'); const getCredentialsSpy = vi.spyOn(store, 'getCredentials');
vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.existsSync).mockReturnValue(false);
store.hasValidCredentials(); store.hasCredentials();
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: false }); expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: true });
}); });
}); });

View File

@@ -24,6 +24,8 @@ export class CredentialStore {
private config: AuthConfig; private config: AuthConfig;
// Clock skew tolerance for expiry checks (30 seconds) // Clock skew tolerance for expiry checks (30 seconds)
private readonly CLOCK_SKEW_MS = 30_000; private readonly CLOCK_SKEW_MS = 30_000;
// Track if we've already warned about missing expiration to avoid spam
private hasWarnedAboutMissingExpiration = false;
private constructor(config?: Partial<AuthConfig>) { private constructor(config?: Partial<AuthConfig>) {
this.config = getAuthConfig(config); this.config = getAuthConfig(config);
@@ -54,9 +56,12 @@ export class CredentialStore {
/** /**
* Get stored authentication credentials * Get stored authentication credentials
* @param options.allowExpired - Whether to return expired credentials (default: true)
* @returns AuthCredentials with expiresAt as number (milliseconds) for runtime use * @returns AuthCredentials with expiresAt as number (milliseconds) for runtime use
*/ */
getCredentials(options?: { allowExpired?: boolean }): AuthCredentials | null { getCredentials({
allowExpired = true
}: { allowExpired?: boolean } = {}): AuthCredentials | null {
try { try {
if (!fs.existsSync(this.config.configFile)) { if (!fs.existsSync(this.config.configFile)) {
return null; return null;
@@ -81,7 +86,11 @@ export class CredentialStore {
// Validate expiration time for tokens // Validate expiration time for tokens
if (expiresAtMs === undefined) { if (expiresAtMs === undefined) {
this.logger.warn('No valid expiration time provided for token'); // Only log this warning once to avoid spam during auth flows
if (!this.hasWarnedAboutMissingExpiration) {
this.logger.warn('No valid expiration time provided for token');
this.hasWarnedAboutMissingExpiration = true;
}
return null; return null;
} }
@@ -90,7 +99,6 @@ export class CredentialStore {
// Check if the token has expired (with clock skew tolerance) // Check if the token has expired (with clock skew tolerance)
const now = Date.now(); const now = Date.now();
const allowExpired = options?.allowExpired ?? false;
if (now >= expiresAtMs - this.CLOCK_SKEW_MS && !allowExpired) { if (now >= expiresAtMs - this.CLOCK_SKEW_MS && !allowExpired) {
this.logger.warn( this.logger.warn(
'Authentication token has expired or is about to expire', 'Authentication token has expired or is about to expire',
@@ -103,7 +111,7 @@ export class CredentialStore {
return null; return null;
} }
// Return valid token // Return credentials (even if expired) to enable refresh flows
return authData; return authData;
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
@@ -172,6 +180,9 @@ export class CredentialStore {
mode: 0o600 mode: 0o600
}); });
fs.renameSync(tempFile, this.config.configFile); fs.renameSync(tempFile, this.config.configFile);
// Reset the warning flag so it can be shown again for future invalid tokens
this.hasWarnedAboutMissingExpiration = false;
} catch (error) { } catch (error) {
throw new AuthenticationError( throw new AuthenticationError(
`Failed to save auth credentials: ${(error as Error).message}`, `Failed to save auth credentials: ${(error as Error).message}`,
@@ -199,10 +210,11 @@ export class CredentialStore {
} }
/** /**
* Check if credentials exist and are valid * Check if credentials exist (regardless of expiration status)
* @returns true if credentials are stored, including expired credentials
*/ */
hasValidCredentials(): boolean { hasCredentials(): boolean {
const credentials = this.getCredentials({ allowExpired: false }); const credentials = this.getCredentials({ allowExpired: true });
return credentials !== null; return credentials !== null;
} }

View File

@@ -281,15 +281,26 @@ export class OAuthService {
// Exchange code for session using PKCE // Exchange code for session using PKCE
const session = await this.supabaseClient.exchangeCodeForSession(code); const session = await this.supabaseClient.exchangeCodeForSession(code);
// Calculate expiration - can be overridden with TM_TOKEN_EXPIRY_MINUTES
let expiresAt: string | undefined;
const tokenExpiryMinutes = process.env.TM_TOKEN_EXPIRY_MINUTES;
if (tokenExpiryMinutes) {
const minutes = parseInt(tokenExpiryMinutes);
expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
this.logger.warn(`Token expiry overridden to ${minutes} minute(s)`);
} else {
expiresAt = session.expires_at
? new Date(session.expires_at * 1000).toISOString()
: undefined;
}
// Save authentication data // Save authentication data
const authData: AuthCredentials = { const authData: AuthCredentials = {
token: session.access_token, token: session.access_token,
refreshToken: session.refresh_token, refreshToken: session.refresh_token,
userId: session.user.id, userId: session.user.id,
email: session.user.email, email: session.user.email,
expiresAt: session.expires_at expiresAt,
? new Date(session.expires_at * 1000).toISOString()
: undefined,
tokenType: 'standard', tokenType: 'standard',
savedAt: new Date().toISOString() savedAt: new Date().toISOString()
}; };
@@ -340,10 +351,18 @@ export class OAuthService {
// Get user info from the session // Get user info from the session
const user = await this.supabaseClient.getUser(); const user = await this.supabaseClient.getUser();
// Calculate expiration time // Calculate expiration time - can be overridden with TM_TOKEN_EXPIRY_MINUTES
const expiresAt = expiresIn let expiresAt: string | undefined;
? new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString() const tokenExpiryMinutes = process.env.TM_TOKEN_EXPIRY_MINUTES;
: undefined; if (tokenExpiryMinutes) {
const minutes = parseInt(tokenExpiryMinutes);
expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
this.logger.warn(`Token expiry overridden to ${minutes} minute(s)`);
} else {
expiresAt = expiresIn
? new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString()
: undefined;
}
// Save authentication data // Save authentication data
const authData: AuthCredentials = { const authData: AuthCredentials = {
@@ -351,7 +370,7 @@ export class OAuthService {
refreshToken: refreshToken || undefined, refreshToken: refreshToken || undefined,
userId: user?.id || 'unknown', userId: user?.id || 'unknown',
email: user?.email, email: user?.email,
expiresAt: expiresAt, expiresAt,
tokenType: 'standard', tokenType: 'standard',
savedAt: new Date().toISOString() savedAt: new Date().toISOString()
}; };

View File

@@ -98,11 +98,11 @@ export class SupabaseSessionStorage implements SupportedStorage {
// Only handle Supabase session keys // Only handle Supabase session keys
if (key === STORAGE_KEY || key.includes('auth-token')) { if (key === STORAGE_KEY || key.includes('auth-token')) {
try { try {
this.logger.info('Supabase called setItem - storing refreshed session');
// Parse the session and update our credentials // Parse the session and update our credentials
const sessionUpdates = this.parseSessionToCredentials(value); const sessionUpdates = this.parseSessionToCredentials(value);
const existingCredentials = this.store.getCredentials({ const existingCredentials = this.store.getCredentials();
allowExpired: true
});
if (sessionUpdates.token) { if (sessionUpdates.token) {
const updatedCredentials: AuthCredentials = { const updatedCredentials: AuthCredentials = {
@@ -113,6 +113,9 @@ export class SupabaseSessionStorage implements SupportedStorage {
} as AuthCredentials; } as AuthCredentials;
this.store.saveCredentials(updatedCredentials); this.store.saveCredentials(updatedCredentials);
this.logger.info(
'Successfully saved refreshed credentials from Supabase'
);
} }
} catch (error) { } catch (error) {
this.logger.error('Error setting session:', error); this.logger.error('Error setting session:', error);

View File

@@ -16,6 +16,7 @@ export interface AuthCredentials {
export interface UserContext { export interface UserContext {
orgId?: string; orgId?: string;
orgName?: string; orgName?: string;
orgSlug?: string;
briefId?: string; briefId?: string;
briefName?: string; briefName?: string;
updatedAt: string; updatedAt: string;

View File

@@ -17,10 +17,11 @@ export class SupabaseAuthClient {
private client: SupabaseJSClient | null = null; private client: SupabaseJSClient | null = null;
private sessionStorage: SupabaseSessionStorage; private sessionStorage: SupabaseSessionStorage;
private logger = getLogger('SupabaseAuthClient'); private logger = getLogger('SupabaseAuthClient');
private credentialStore: CredentialStore;
constructor() { constructor() {
const credentialStore = CredentialStore.getInstance(); this.credentialStore = CredentialStore.getInstance();
this.sessionStorage = new SupabaseSessionStorage(credentialStore); this.sessionStorage = new SupabaseSessionStorage(this.credentialStore);
} }
/** /**
@@ -28,13 +29,17 @@ export class SupabaseAuthClient {
*/ */
getClient(): SupabaseJSClient { getClient(): SupabaseJSClient {
if (!this.client) { if (!this.client) {
// Get Supabase configuration from environment - using TM_PUBLIC prefix // Get Supabase configuration from environment
const supabaseUrl = process.env.TM_PUBLIC_SUPABASE_URL; // Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
const supabaseAnonKey = process.env.TM_PUBLIC_SUPABASE_ANON_KEY; const supabaseUrl =
process.env.TM_SUPABASE_URL || process.env.TM_PUBLIC_SUPABASE_URL;
const supabaseAnonKey =
process.env.TM_SUPABASE_ANON_KEY ||
process.env.TM_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) { if (!supabaseUrl || !supabaseAnonKey) {
throw new AuthenticationError( throw new AuthenticationError(
'Supabase configuration missing. Please set TM_PUBLIC_SUPABASE_URL and TM_PUBLIC_SUPABASE_ANON_KEY environment variables.', 'Supabase configuration missing. Please set TM_SUPABASE_URL and TM_SUPABASE_ANON_KEY (runtime) or TM_PUBLIC_SUPABASE_URL and TM_PUBLIC_SUPABASE_ANON_KEY (build-time) environment variables.',
'CONFIG_MISSING' 'CONFIG_MISSING'
); );
} }

View File

@@ -52,7 +52,10 @@ export const ERROR_CODES = {
INVALID_INPUT: 'INVALID_INPUT', INVALID_INPUT: 'INVALID_INPUT',
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED', NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
UNKNOWN_ERROR: 'UNKNOWN_ERROR', UNKNOWN_ERROR: 'UNKNOWN_ERROR',
NOT_FOUND: 'NOT_FOUND' NOT_FOUND: 'NOT_FOUND',
// Context errors
NO_BRIEF_SELECTED: 'NO_BRIEF_SELECTED'
} as const; } as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];

View File

@@ -358,11 +358,12 @@ export class ExportService {
tasks: any[] tasks: any[]
): Promise<void> { ): Promise<void> {
// Check if we should use the API endpoint or direct Supabase // Check if we should use the API endpoint or direct Supabase
const useAPIEndpoint = process.env.TM_PUBLIC_BASE_DOMAIN; const apiEndpoint =
process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN;
if (useAPIEndpoint) { if (apiEndpoint) {
// Use the new bulk import API endpoint // Use the new bulk import API endpoint
const apiUrl = `${process.env.TM_PUBLIC_BASE_DOMAIN}/ai/api/v1/briefs/${briefId}/tasks`; const apiUrl = `${apiEndpoint}/ai/api/v1/briefs/${briefId}/tasks`;
// Transform tasks to flat structure for API // Transform tasks to flat structure for API
const flatTasks = this.transformTasksForBulkImport(tasks); const flatTasks = this.transformTasksForBulkImport(tasks);

View File

@@ -27,6 +27,12 @@ export interface Brief {
status: string; status: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
document?: {
id: string;
title: string;
document_name: string;
description?: string;
};
} }
/** /**
@@ -171,7 +177,12 @@ export class OrganizationService {
document_id, document_id,
status, status,
created_at, created_at,
updated_at updated_at,
document:document_id (
id,
document_name,
title
)
`) `)
.eq('account_id', orgId); .eq('account_id', orgId);
@@ -196,7 +207,14 @@ export class OrganizationService {
documentId: brief.document_id, documentId: brief.document_id,
status: brief.status, status: brief.status,
createdAt: brief.created_at, createdAt: brief.created_at,
updatedAt: brief.updated_at updatedAt: brief.updated_at,
document: brief.document
? {
id: brief.document.id,
document_name: brief.document.document_name,
title: brief.document.title
}
: undefined
})); }));
} catch (error) { } catch (error) {
if (error instanceof TaskMasterError) { if (error instanceof TaskMasterError) {
@@ -224,7 +242,13 @@ export class OrganizationService {
document_id, document_id,
status, status,
created_at, created_at,
updated_at updated_at,
document:document_id (
id,
document_name,
title,
description
)
`) `)
.eq('id', briefId) .eq('id', briefId)
.single(); .single();
@@ -253,7 +277,15 @@ export class OrganizationService {
documentId: briefData.document_id, documentId: briefData.document_id,
status: briefData.status, status: briefData.status,
createdAt: briefData.created_at, createdAt: briefData.created_at,
updatedAt: briefData.updated_at updatedAt: briefData.updated_at,
document: briefData.document
? {
id: briefData.document.id,
document_name: briefData.document.document_name,
title: briefData.document.title,
description: briefData.document.description
}
: undefined
}; };
} catch (error) { } catch (error) {
if (error instanceof TaskMasterError) { if (error instanceof TaskMasterError) {

View File

@@ -161,6 +161,16 @@ export class TaskService {
storageType: this.getStorageType() storageType: this.getStorageType()
}; };
} catch (error) { } catch (error) {
// If it's a user-facing error (like NO_BRIEF_SELECTED), don't log it as an internal error
if (
error instanceof TaskMasterError &&
error.is(ERROR_CODES.NO_BRIEF_SELECTED)
) {
// Just re-throw user-facing errors without wrapping
throw error;
}
// Log internal errors
this.logger.error('Failed to get task list', error); this.logger.error('Failed to get task list', error);
throw new TaskMasterError( throw new TaskMasterError(
'Failed to get task list', 'Failed to get task list',
@@ -186,6 +196,14 @@ export class TaskService {
// Delegate to storage layer which handles the specific logic for tasks vs subtasks // Delegate to storage layer which handles the specific logic for tasks vs subtasks
return await this.storage.loadTask(String(taskId), activeTag); return await this.storage.loadTask(String(taskId), activeTag);
} catch (error) { } catch (error) {
// If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it
if (
error instanceof TaskMasterError &&
error.is(ERROR_CODES.NO_BRIEF_SELECTED)
) {
throw error;
}
throw new TaskMasterError( throw new TaskMasterError(
`Failed to get task ${taskId}`, `Failed to get task ${taskId}`,
ERROR_CODES.STORAGE_ERROR, ERROR_CODES.STORAGE_ERROR,
@@ -522,6 +540,14 @@ export class TaskService {
activeTag activeTag
); );
} catch (error) { } catch (error) {
// If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it
if (
error instanceof TaskMasterError &&
error.is(ERROR_CODES.NO_BRIEF_SELECTED)
) {
throw error;
}
throw new TaskMasterError( throw new TaskMasterError(
`Failed to update task status for ${taskIdStr}`, `Failed to update task status for ${taskIdStr}`,
ERROR_CODES.STORAGE_ERROR, ERROR_CODES.STORAGE_ERROR,

View File

@@ -37,6 +37,13 @@ export interface ApiStorageConfig {
maxRetries?: number; maxRetries?: number;
} }
/**
* Auth context with a guaranteed briefId
*/
type ContextWithBrief = NonNullable<
ReturnType<typeof AuthManager.prototype.getContext>
> & { briefId: string };
/** /**
* ApiStorage implementation using repository pattern * ApiStorage implementation using repository pattern
* Provides flexibility to swap between different backend implementations * Provides flexibility to swap between different backend implementations
@@ -112,6 +119,13 @@ export class ApiStorage implements IStorage {
} }
} }
/**
* Get the storage type
*/
getType(): 'api' {
return 'api';
}
/** /**
* Load tags into cache * Load tags into cache
* In our API-based system, "tags" represent briefs * In our API-based system, "tags" represent briefs
@@ -119,7 +133,7 @@ export class ApiStorage implements IStorage {
private async loadTagsIntoCache(): Promise<void> { private async loadTagsIntoCache(): Promise<void> {
try { try {
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
const context = await authManager.getContext(); const context = authManager.getContext();
// If we have a selected brief, create a virtual "tag" for it // If we have a selected brief, create a virtual "tag" for it
if (context?.briefId) { if (context?.briefId) {
@@ -151,15 +165,7 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized(); await this.ensureInitialized();
try { try {
const authManager = AuthManager.getInstance(); const context = this.ensureBriefSelected('loadTasks');
const context = await authManager.getContext();
// If no brief is selected in context, throw an error
if (!context?.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief <brief-id>'
);
}
// Load tasks from the current brief context with filters pushed to repository // Load tasks from the current brief context with filters pushed to repository
const tasks = await this.retryOperation(() => const tasks = await this.retryOperation(() =>
@@ -174,12 +180,11 @@ export class ApiStorage implements IStorage {
return tasks; return tasks;
} catch (error) { } catch (error) {
throw new TaskMasterError( this.wrapError(error, 'Failed to load tasks from API', {
'Failed to load tasks from API', operation: 'loadTasks',
ERROR_CODES.STORAGE_ERROR, tag,
{ operation: 'loadTasks', tag, context: 'brief-based loading' }, context: 'brief-based loading'
error as Error });
);
} }
} }
@@ -230,16 +235,17 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized(); await this.ensureInitialized();
try { try {
this.ensureBriefSelected('loadTask');
return await this.retryOperation(() => return await this.retryOperation(() =>
this.repository.getTask(this.projectId, taskId) this.repository.getTask(this.projectId, taskId)
); );
} catch (error) { } catch (error) {
throw new TaskMasterError( this.wrapError(error, 'Failed to load task from API', {
'Failed to load task from API', operation: 'loadTask',
ERROR_CODES.STORAGE_ERROR, taskId,
{ operation: 'loadTask', taskId, tag }, tag
error as Error });
);
} }
} }
@@ -318,7 +324,7 @@ export class ApiStorage implements IStorage {
try { try {
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
const context = await authManager.getContext(); const context = authManager.getContext();
// In our API-based system, we only have one "tag" at a time - the current brief // In our API-based system, we only have one "tag" at a time - the current brief
if (context?.briefId) { if (context?.briefId) {
@@ -503,6 +509,8 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized(); await this.ensureInitialized();
try { try {
this.ensureBriefSelected('updateTaskStatus');
const existingTask = await this.retryOperation(() => const existingTask = await this.retryOperation(() =>
this.repository.getTask(this.projectId, taskId) this.repository.getTask(this.projectId, taskId)
); );
@@ -539,12 +547,12 @@ export class ApiStorage implements IStorage {
taskId taskId
}; };
} catch (error) { } catch (error) {
throw new TaskMasterError( this.wrapError(error, 'Failed to update task status via API', {
'Failed to update task status via API', operation: 'updateTaskStatus',
ERROR_CODES.STORAGE_ERROR, taskId,
{ operation: 'updateTaskStatus', taskId, newStatus, tag }, newStatus,
error as Error tag
); });
} }
} }
@@ -762,6 +770,29 @@ export class ApiStorage implements IStorage {
} }
} }
/**
* Ensure a brief is selected in the current context
* @returns The current auth context with a valid briefId
*/
private ensureBriefSelected(operation: string): ContextWithBrief {
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
if (!context?.briefId) {
throw new TaskMasterError(
'No brief selected',
ERROR_CODES.NO_BRIEF_SELECTED,
{
operation,
userMessage:
'No brief selected. Please select a brief first using: tm context brief <brief-id> or tm context brief <brief-url>'
}
);
}
return context as ContextWithBrief;
}
/** /**
* Retry an operation with exponential backoff * Retry an operation with exponential backoff
*/ */
@@ -780,4 +811,28 @@ export class ApiStorage implements IStorage {
throw error; throw error;
} }
} }
/**
* Wrap an error unless it's already a NO_BRIEF_SELECTED error
*/
private wrapError(
error: unknown,
message: string,
context: Record<string, unknown>
): never {
// If it's already a NO_BRIEF_SELECTED error, don't wrap it
if (
error instanceof TaskMasterError &&
error.is(ERROR_CODES.NO_BRIEF_SELECTED)
) {
throw error;
}
throw new TaskMasterError(
message,
ERROR_CODES.STORAGE_ERROR,
context,
error as Error
);
}
} }

View File

@@ -44,6 +44,13 @@ export class FileStorage implements IStorage {
await this.fileOps.cleanup(); await this.fileOps.cleanup();
} }
/**
* Get the storage type
*/
getType(): 'file' {
return 'file';
}
/** /**
* Get statistics about the storage * Get statistics about the storage
*/ */

View File

@@ -73,7 +73,7 @@ export class StorageFactory {
); );
} }
// Use auth token from AuthManager (synchronous - no auto-refresh here) // Use auth token from AuthManager (synchronous - no auto-refresh here)
const credentials = authManager.getCredentialsSync(); const credentials = authManager.getCredentials();
if (credentials) { if (credentials) {
// Merge with existing storage config, ensuring required fields // Merge with existing storage config, ensuring required fields
const nextStorage: StorageSettings = { const nextStorage: StorageSettings = {
@@ -82,8 +82,8 @@ export class StorageFactory {
apiAccessToken: credentials.token, apiAccessToken: credentials.token,
apiEndpoint: apiEndpoint:
config.storage?.apiEndpoint || config.storage?.apiEndpoint ||
process.env.TM_PUBLIC_BASE_DOMAIN || process.env.TM_BASE_DOMAIN ||
'https://tryhamster.com/api' process.env.TM_PUBLIC_BASE_DOMAIN
}; };
config.storage = nextStorage; config.storage = nextStorage;
} }
@@ -103,7 +103,7 @@ export class StorageFactory {
// Then check if authenticated via AuthManager // Then check if authenticated via AuthManager
if (authManager.isAuthenticated()) { if (authManager.isAuthenticated()) {
const credentials = authManager.getCredentialsSync(); const credentials = authManager.getCredentials();
if (credentials) { if (credentials) {
// Configure API storage with auth credentials // Configure API storage with auth credentials
const nextStorage: StorageSettings = { const nextStorage: StorageSettings = {
@@ -112,6 +112,7 @@ export class StorageFactory {
apiAccessToken: credentials.token, apiAccessToken: credentials.token,
apiEndpoint: apiEndpoint:
config.storage?.apiEndpoint || config.storage?.apiEndpoint ||
process.env.TM_BASE_DOMAIN ||
process.env.TM_PUBLIC_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN ||
'https://tryhamster.com/api' 'https://tryhamster.com/api'
}; };

View File

@@ -201,6 +201,44 @@ export class TaskMasterCore {
return this.taskService.getStorageType(); return this.taskService.getStorageType();
} }
/**
* Get storage configuration
*/
getStorageConfig() {
return this.configManager.getStorageConfig();
}
/**
* Get storage display information for headers
* Returns context info for API storage, null for file storage
*/
getStorageDisplayInfo(): {
briefId: string;
briefName: string;
orgSlug?: string;
} | null {
// Only return info if using API storage
const storageType = this.getStorageType();
if (storageType !== 'api') {
return null;
}
// Get credentials from auth manager
const authManager = AuthManager.getInstance();
const credentials = authManager.getCredentials();
const selectedContext = credentials?.selectedContext;
if (!selectedContext?.briefId || !selectedContext?.briefName) {
return null;
}
return {
briefId: selectedContext.briefId,
briefName: selectedContext.briefName,
orgSlug: selectedContext.orgSlug
};
}
/** /**
* Get current active tag * Get current active tag
*/ */

View File

@@ -50,7 +50,7 @@ describe('AuthManager Token Refresh', () => {
} }
}); });
it('should not make concurrent refresh requests', async () => { it('should return expired credentials to enable refresh flows', () => {
// Set up expired credentials with refresh token // Set up expired credentials with refresh token
const expiredCredentials: AuthCredentials = { const expiredCredentials: AuthCredentials = {
token: 'expired_access_token', token: 'expired_access_token',
@@ -63,50 +63,16 @@ describe('AuthManager Token Refresh', () => {
credentialStore.saveCredentials(expiredCredentials); credentialStore.saveCredentials(expiredCredentials);
// Mock the refreshToken method to track calls // Get credentials should return them even if expired
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken'); // Refresh will be handled by explicit calls or client operations
const mockSession: Session = { const credentials = authManager.getCredentials();
access_token: 'new_access_token',
refresh_token: 'new_refresh_token',
expires_at: Math.floor(Date.now() / 1000) + 3600,
user: {
id: 'test-user-id',
email: 'test@example.com',
app_metadata: {},
user_metadata: {},
aud: 'authenticated',
created_at: new Date().toISOString()
}
};
refreshTokenSpy.mockResolvedValue({ expect(credentials).not.toBeNull();
token: mockSession.access_token, expect(credentials?.token).toBe('expired_access_token');
refreshToken: mockSession.refresh_token, expect(credentials?.refreshToken).toBe('valid_refresh_token');
userId: mockSession.user.id,
email: mockSession.user.email,
expiresAt: new Date(mockSession.expires_at! * 1000).toISOString(),
savedAt: new Date().toISOString()
});
// Make multiple concurrent calls to getCredentials
const promises = [
authManager.getCredentials(),
authManager.getCredentials(),
authManager.getCredentials()
];
const results = await Promise.all(promises);
// Verify all calls returned the same new credentials
expect(results[0]?.token).toBe('new_access_token');
expect(results[1]?.token).toBe('new_access_token');
expect(results[2]?.token).toBe('new_access_token');
// Verify refreshToken was only called once, not three times
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
}); });
it('should return valid credentials without attempting refresh', async () => { it('should return valid credentials', () => {
// Set up valid (non-expired) credentials // Set up valid (non-expired) credentials
const validCredentials: AuthCredentials = { const validCredentials: AuthCredentials = {
token: 'valid_access_token', token: 'valid_access_token',
@@ -119,17 +85,14 @@ describe('AuthManager Token Refresh', () => {
credentialStore.saveCredentials(validCredentials); credentialStore.saveCredentials(validCredentials);
// Spy on refreshToken to ensure it's not called const credentials = authManager.getCredentials();
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
const credentials = await authManager.getCredentials();
expect(credentials?.token).toBe('valid_access_token'); expect(credentials?.token).toBe('valid_access_token');
expect(refreshTokenSpy).not.toHaveBeenCalled();
}); });
it('should return null if credentials are expired with no refresh token', async () => { it('should return expired credentials even without refresh token', () => {
// Set up expired credentials WITHOUT refresh token // Set up expired credentials WITHOUT refresh token
// We still return them - it's up to the caller to handle
const expiredCredentials: AuthCredentials = { const expiredCredentials: AuthCredentials = {
token: 'expired_access_token', token: 'expired_access_token',
refreshToken: undefined, refreshToken: undefined,
@@ -141,17 +104,19 @@ describe('AuthManager Token Refresh', () => {
credentialStore.saveCredentials(expiredCredentials); credentialStore.saveCredentials(expiredCredentials);
const credentials = await authManager.getCredentials(); const credentials = authManager.getCredentials();
// Returns credentials even if expired
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('expired_access_token');
});
it('should return null if no credentials exist', () => {
const credentials = authManager.getCredentials();
expect(credentials).toBeNull(); expect(credentials).toBeNull();
}); });
it('should return null if no credentials exist', async () => { it('should return credentials regardless of refresh token validity', () => {
const credentials = await authManager.getCredentials();
expect(credentials).toBeNull();
});
it('should handle refresh failures gracefully', async () => {
// Set up expired credentials with refresh token // Set up expired credentials with refresh token
const expiredCredentials: AuthCredentials = { const expiredCredentials: AuthCredentials = {
token: 'expired_access_token', token: 'expired_access_token',
@@ -164,13 +129,11 @@ describe('AuthManager Token Refresh', () => {
credentialStore.saveCredentials(expiredCredentials); credentialStore.saveCredentials(expiredCredentials);
// Mock refreshToken to throw an error const credentials = authManager.getCredentials();
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
refreshTokenSpy.mockRejectedValue(new Error('Refresh failed'));
const credentials = await authManager.getCredentials(); // Returns credentials - refresh will be attempted by the client which will handle failure
expect(credentials).not.toBeNull();
expect(credentials).toBeNull(); expect(credentials?.token).toBe('expired_access_token');
expect(refreshTokenSpy).toHaveBeenCalledTimes(1); expect(credentials?.refreshToken).toBe('invalid_refresh_token');
}); });
}); });

View File

@@ -76,7 +76,7 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
}); });
describe('Expired Token Detection', () => { describe('Expired Token Detection', () => {
it('should detect expired token', async () => { it('should return expired token for Supabase to refresh', () => {
// Set up expired credentials // Set up expired credentials
const expiredCredentials: AuthCredentials = { const expiredCredentials: AuthCredentials = {
token: 'expired-token', token: 'expired-token',
@@ -91,24 +91,15 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance(); authManager = AuthManager.getInstance();
// Mock the Supabase refreshSession to return new tokens // Get credentials returns them even if expired
const mockRefreshSession = vi const credentials = authManager.getCredentials();
.fn()
.mockResolvedValue(mockRefreshedSession);
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
// Get credentials should trigger refresh
const credentials = await authManager.getCredentials();
expect(mockRefreshSession).toHaveBeenCalledTimes(1);
expect(credentials).not.toBeNull(); expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('new-access-token-xyz'); expect(credentials?.token).toBe('expired-token');
expect(credentials?.refreshToken).toBe('valid-refresh-token');
}); });
it('should not refresh valid token', async () => { it('should return valid token', () => {
// Set up valid credentials // Set up valid credentials
const validCredentials: AuthCredentials = { const validCredentials: AuthCredentials = {
token: 'valid-token', token: 'valid-token',
@@ -123,22 +114,14 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance(); authManager = AuthManager.getInstance();
// Mock refresh to ensure it's not called const credentials = authManager.getCredentials();
const mockRefreshSession = vi.fn();
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
const credentials = await authManager.getCredentials();
expect(mockRefreshSession).not.toHaveBeenCalled();
expect(credentials?.token).toBe('valid-token'); expect(credentials?.token).toBe('valid-token');
}); });
}); });
describe('Token Refresh Flow', () => { describe('Token Refresh Flow', () => {
it('should refresh expired token and save new credentials', async () => { it('should manually refresh expired token and save new credentials', async () => {
const expiredCredentials: AuthCredentials = { const expiredCredentials: AuthCredentials = {
token: 'old-token', token: 'old-token',
refreshToken: 'old-refresh-token', refreshToken: 'old-refresh-token',
@@ -162,23 +145,24 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
'refreshSession' 'refreshSession'
).mockResolvedValue(mockRefreshedSession); ).mockResolvedValue(mockRefreshedSession);
const refreshedCredentials = await authManager.getCredentials(); // Explicitly call refreshToken() method
const refreshedCredentials = await authManager.refreshToken();
expect(refreshedCredentials).not.toBeNull(); expect(refreshedCredentials).not.toBeNull();
expect(refreshedCredentials?.token).toBe('new-access-token-xyz'); expect(refreshedCredentials.token).toBe('new-access-token-xyz');
expect(refreshedCredentials?.refreshToken).toBe('new-refresh-token-xyz'); expect(refreshedCredentials.refreshToken).toBe('new-refresh-token-xyz');
// Verify context was preserved // Verify context was preserved
expect(refreshedCredentials?.selectedContext?.orgId).toBe('test-org'); expect(refreshedCredentials.selectedContext?.orgId).toBe('test-org');
expect(refreshedCredentials?.selectedContext?.briefId).toBe('test-brief'); expect(refreshedCredentials.selectedContext?.briefId).toBe('test-brief');
// Verify new expiration is in the future // Verify new expiration is in the future
const newExpiry = new Date(refreshedCredentials!.expiresAt!).getTime(); const newExpiry = new Date(refreshedCredentials.expiresAt!).getTime();
const now = Date.now(); const now = Date.now();
expect(newExpiry).toBeGreaterThan(now); expect(newExpiry).toBeGreaterThan(now);
}); });
it('should return null if refresh fails', async () => { it('should throw error if manual refresh fails', async () => {
const expiredCredentials: AuthCredentials = { const expiredCredentials: AuthCredentials = {
token: 'expired-token', token: 'expired-token',
refreshToken: 'invalid-refresh-token', refreshToken: 'invalid-refresh-token',
@@ -198,12 +182,11 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
'refreshSession' 'refreshSession'
).mockRejectedValue(new Error('Refresh token expired')); ).mockRejectedValue(new Error('Refresh token expired'));
const credentials = await authManager.getCredentials(); // Explicit refreshToken() call should throw
await expect(authManager.refreshToken()).rejects.toThrow();
expect(credentials).toBeNull();
}); });
it('should return null if no refresh token available', async () => { it('should return expired credentials even without refresh token', () => {
const expiredCredentials: AuthCredentials = { const expiredCredentials: AuthCredentials = {
token: 'expired-token', token: 'expired-token',
// No refresh token // No refresh token
@@ -217,18 +200,21 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance(); authManager = AuthManager.getInstance();
const credentials = await authManager.getCredentials(); const credentials = authManager.getCredentials();
expect(credentials).toBeNull(); // Credentials are returned even without refresh token
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('expired-token');
expect(credentials?.refreshToken).toBeUndefined();
}); });
it('should return null if credentials missing expiresAt', async () => { it('should return null if credentials missing expiresAt', () => {
const credentialsWithoutExpiry: AuthCredentials = { const credentialsWithoutExpiry: AuthCredentials = {
token: 'test-token', token: 'test-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
userId: 'test-user-id', userId: 'test-user-id',
email: 'test@example.com', email: 'test@example.com',
// Missing expiresAt // Missing expiresAt - invalid token
savedAt: new Date().toISOString() savedAt: new Date().toISOString()
} as any; } as any;
@@ -236,16 +222,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance(); authManager = AuthManager.getInstance();
const credentials = await authManager.getCredentials(); const credentials = authManager.getCredentials();
// Should return null because no valid expiration // Tokens without valid expiration are considered invalid
expect(credentials).toBeNull(); expect(credentials).toBeNull();
}); });
}); });
describe('Clock Skew Tolerance', () => { describe('Clock Skew Tolerance', () => {
it('should refresh token within 30-second expiry window', async () => { it('should return credentials within 30-second expiry window', () => {
// Token expires in 15 seconds (within 30-second buffer) // Token expires in 15 seconds (within 30-second buffer)
// Supabase will handle refresh automatically
const almostExpiredCredentials: AuthCredentials = { const almostExpiredCredentials: AuthCredentials = {
token: 'almost-expired-token', token: 'almost-expired-token',
refreshToken: 'valid-refresh-token', refreshToken: 'valid-refresh-token',
@@ -259,23 +246,16 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance(); authManager = AuthManager.getInstance();
const mockRefreshSession = vi const credentials = authManager.getCredentials();
.fn()
.mockResolvedValue(mockRefreshedSession);
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
const credentials = await authManager.getCredentials(); // Credentials are returned (Supabase handles auto-refresh in background)
expect(credentials).not.toBeNull();
// Should trigger refresh due to 30-second buffer expect(credentials?.token).toBe('almost-expired-token');
expect(mockRefreshSession).toHaveBeenCalledTimes(1); expect(credentials?.refreshToken).toBe('valid-refresh-token');
expect(credentials?.token).toBe('new-access-token-xyz');
}); });
it('should not refresh token well before expiry', async () => { it('should return valid token well before expiry', () => {
// Token expires in 5 minutes (well outside 30-second buffer) // Token expires in 5 minutes
const validCredentials: AuthCredentials = { const validCredentials: AuthCredentials = {
token: 'valid-token', token: 'valid-token',
refreshToken: 'valid-refresh-token', refreshToken: 'valid-refresh-token',
@@ -289,21 +269,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance(); authManager = AuthManager.getInstance();
const mockRefreshSession = vi.fn(); const credentials = authManager.getCredentials();
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
const credentials = await authManager.getCredentials(); // Valid credentials are returned as-is
expect(credentials).not.toBeNull();
expect(mockRefreshSession).not.toHaveBeenCalled();
expect(credentials?.token).toBe('valid-token'); expect(credentials?.token).toBe('valid-token');
expect(credentials?.refreshToken).toBe('valid-refresh-token');
}); });
}); });
describe('Synchronous vs Async Methods', () => { describe('Synchronous vs Async Methods', () => {
it('getCredentialsSync should not trigger refresh', () => { it('getCredentials should return expired credentials', () => {
const expiredCredentials: AuthCredentials = { const expiredCredentials: AuthCredentials = {
token: 'expired-token', token: 'expired-token',
refreshToken: 'valid-refresh-token', refreshToken: 'valid-refresh-token',
@@ -317,40 +293,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance(); authManager = AuthManager.getInstance();
// Synchronous call should return null without refresh // Returns credentials even if expired - Supabase will handle refresh
const credentials = authManager.getCredentialsSync(); const credentials = authManager.getCredentials();
expect(credentials).toBeNull();
});
it('getCredentials async should trigger refresh', async () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'valid-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
authManager = AuthManager.getInstance();
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockResolvedValue(mockRefreshedSession);
const credentials = await authManager.getCredentials();
expect(credentials).not.toBeNull(); expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('new-access-token-xyz'); expect(credentials?.token).toBe('expired-token');
expect(credentials?.refreshToken).toBe('valid-refresh-token');
}); });
}); });
describe('Multiple Concurrent Calls', () => { describe('Multiple Concurrent Calls', () => {
it('should handle concurrent getCredentials calls gracefully', async () => { it('should handle concurrent getCredentials calls gracefully', () => {
const expiredCredentials: AuthCredentials = { const expiredCredentials: AuthCredentials = {
token: 'expired-token', token: 'expired-token',
refreshToken: 'valid-refresh-token', refreshToken: 'valid-refresh-token',
@@ -364,29 +317,20 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance(); authManager = AuthManager.getInstance();
const mockRefreshSession = vi // Make multiple concurrent calls (synchronous now)
.fn() const creds1 = authManager.getCredentials();
.mockResolvedValue(mockRefreshedSession); const creds2 = authManager.getCredentials();
vi.spyOn( const creds3 = authManager.getCredentials();
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
// Make multiple concurrent calls // All should get the same credentials (even if expired)
const [creds1, creds2, creds3] = await Promise.all([ expect(creds1?.token).toBe('expired-token');
authManager.getCredentials(), expect(creds2?.token).toBe('expired-token');
authManager.getCredentials(), expect(creds3?.token).toBe('expired-token');
authManager.getCredentials()
]);
// All should get the refreshed token // All include refresh token for Supabase to use
expect(creds1?.token).toBe('new-access-token-xyz'); expect(creds1?.refreshToken).toBe('valid-refresh-token');
expect(creds2?.token).toBe('new-access-token-xyz'); expect(creds2?.refreshToken).toBe('valid-refresh-token');
expect(creds3?.token).toBe('new-access-token-xyz'); expect(creds3?.refreshToken).toBe('valid-refresh-token');
// Refresh might be called multiple times, but that's okay
// (ideally we'd debounce, but this is acceptable behavior)
expect(mockRefreshSession).toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -9,6 +9,8 @@
*/ */
import dotenv from 'dotenv'; import dotenv from 'dotenv';
// Load .env BEFORE any other imports to ensure env vars are available
dotenv.config(); dotenv.config();
// Add at the very beginning of the file // Add at the very beginning of the file
@@ -16,7 +18,8 @@ if (process.env.DEBUG === '1') {
console.error('DEBUG - dev.js received args:', process.argv.slice(2)); console.error('DEBUG - dev.js received args:', process.argv.slice(2));
} }
import { runCLI } from './modules/commands.js'; // Use dynamic import to ensure dotenv.config() runs before module-level code executes
const { runCLI } = await import('./modules/commands.js');
// Run the CLI with the process arguments // Run the CLI with the process arguments
runCLI(process.argv); runCLI(process.argv);

View File

@@ -19,7 +19,8 @@ import {
registerAllCommands, registerAllCommands,
checkForUpdate, checkForUpdate,
performAutoUpdate, performAutoUpdate,
displayUpgradeNotification displayUpgradeNotification,
displayError
} from '@tm/cli'; } from '@tm/cli';
import { import {
@@ -5156,10 +5157,7 @@ async function runCLI(argv = process.argv) {
); );
} else { } else {
// Generic error handling for other errors // Generic error handling for other errors
console.error(chalk.red(`Error: ${error.message}`)); displayError(error);
if (getDebugFlag()) {
console.error(error);
}
} }
process.exit(1); process.exit(1);