Compare commits
14 Commits
ralph/fix/
...
ralph/batc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d4ba9b28 | ||
|
|
b8830d9508 | ||
|
|
548beb4344 | ||
|
|
555da2b5b9 | ||
|
|
662e3865f3 | ||
|
|
8649c8a347 | ||
|
|
f7cab246b0 | ||
|
|
5aca107827 | ||
|
|
fb68c9fe1f | ||
|
|
ff3bd7add8 | ||
|
|
c8228e913b | ||
|
|
218b68a31e | ||
|
|
6bc75c0ac6 | ||
|
|
d7fca1844f |
5
.changeset/dirty-hairs-know.md
Normal file
5
.changeset/dirty-hairs-know.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"task-master-ai": patch
|
||||
---
|
||||
|
||||
Improve auth token refresh flow
|
||||
5
.changeset/metal-rocks-help.md
Normal file
5
.changeset/metal-rocks-help.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"task-master-ai": minor
|
||||
---
|
||||
|
||||
Improve next command to work with remote
|
||||
5
.changeset/open-tips-notice.md
Normal file
5
.changeset/open-tips-notice.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"task-master-ai": minor
|
||||
---
|
||||
|
||||
Add 4.5 haiku and sonnet to supported models for claude-code and anthropic ai providers
|
||||
@@ -22,6 +22,7 @@
|
||||
"test:ci": "vitest run --coverage --reporter=dot"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/search": "^3.2.0",
|
||||
"@tm/core": "*",
|
||||
"boxen": "^8.0.1",
|
||||
"chalk": "5.6.2",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Command } from 'commander';
|
||||
// Import all commands
|
||||
import { ListTasksCommand } from './commands/list.command.js';
|
||||
import { ShowCommand } from './commands/show.command.js';
|
||||
import { NextCommand } from './commands/next.command.js';
|
||||
import { AuthCommand } from './commands/auth.command.js';
|
||||
import { ContextCommand } from './commands/context.command.js';
|
||||
import { StartCommand } from './commands/start.command.js';
|
||||
@@ -45,6 +46,12 @@ export class CommandRegistry {
|
||||
commandClass: ShowCommand as any,
|
||||
category: 'task'
|
||||
},
|
||||
{
|
||||
name: 'next',
|
||||
description: 'Find the next available task to work on',
|
||||
commandClass: NextCommand as any,
|
||||
category: 'task'
|
||||
},
|
||||
{
|
||||
name: 'start',
|
||||
description: 'Start working on a task with claude-code',
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type AuthCredentials
|
||||
} from '@tm/core/auth';
|
||||
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
|
||||
@@ -116,8 +118,7 @@ export class AuthCommand extends Command {
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
} catch (error: any) {
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +134,7 @@ export class AuthCommand extends Command {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +143,10 @@ export class AuthCommand extends Command {
|
||||
*/
|
||||
private async executeStatus(): Promise<void> {
|
||||
try {
|
||||
const result = await this.displayStatus();
|
||||
const result = this.displayStatus();
|
||||
this.setLastResult(result);
|
||||
} catch (error: any) {
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,16 +162,15 @@ export class AuthCommand extends Command {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display authentication status
|
||||
*/
|
||||
private async displayStatus(): Promise<AuthResult> {
|
||||
const credentials = await this.authManager.getCredentials();
|
||||
private displayStatus(): AuthResult {
|
||||
const credentials = this.authManager.getCredentials();
|
||||
|
||||
console.log(chalk.cyan('\n🔐 Authentication Status\n'));
|
||||
|
||||
@@ -325,7 +323,7 @@ export class AuthCommand extends Command {
|
||||
]);
|
||||
|
||||
if (!continueAuth) {
|
||||
const credentials = await this.authManager.getCredentials();
|
||||
const credentials = this.authManager.getCredentials();
|
||||
ui.displaySuccess('Using existing authentication');
|
||||
|
||||
if (credentials) {
|
||||
@@ -351,6 +349,37 @@ export class AuthCommand extends Command {
|
||||
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 {
|
||||
success: true,
|
||||
action: 'login',
|
||||
@@ -358,7 +387,7 @@ export class AuthCommand extends Command {
|
||||
message: 'Authentication successful'
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleAuthError(error as AuthenticationError);
|
||||
displayError(error, { skipExit: true });
|
||||
|
||||
return {
|
||||
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
|
||||
*/
|
||||
@@ -490,7 +474,7 @@ export class AuthCommand extends Command {
|
||||
/**
|
||||
* Get current credentials (for programmatic usage)
|
||||
*/
|
||||
getCredentials(): Promise<AuthCredentials | null> {
|
||||
getCredentials(): AuthCredentials | null {
|
||||
return this.authManager.getCredentials();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,11 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import search from '@inquirer/search';
|
||||
import ora, { Ora } from 'ora';
|
||||
import {
|
||||
AuthManager,
|
||||
AuthenticationError,
|
||||
type UserContext
|
||||
} from '@tm/core/auth';
|
||||
import { AuthManager, type UserContext } from '@tm/core/auth';
|
||||
import * as ui from '../utils/ui.js';
|
||||
import { displayError } from '../utils/error-handler.js';
|
||||
|
||||
/**
|
||||
* Result type from context command
|
||||
@@ -115,18 +113,17 @@ export class ContextCommand extends Command {
|
||||
*/
|
||||
private async executeShow(): Promise<void> {
|
||||
try {
|
||||
const result = await this.displayContext();
|
||||
const result = this.displayContext();
|
||||
this.setLastResult(result);
|
||||
} catch (error: any) {
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display current context
|
||||
*/
|
||||
private async displayContext(): Promise<ContextResult> {
|
||||
private displayContext(): ContextResult {
|
||||
// Check authentication first
|
||||
if (!this.authManager.isAuthenticated()) {
|
||||
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'));
|
||||
|
||||
@@ -156,10 +153,14 @@ export class ContextCommand extends Command {
|
||||
|
||||
if (context.briefName || context.briefId) {
|
||||
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}`));
|
||||
}
|
||||
if (context.briefId) {
|
||||
} else if (context.briefId) {
|
||||
console.log(chalk.gray(` ID: ${context.briefId}`));
|
||||
}
|
||||
}
|
||||
@@ -211,8 +212,7 @@ export class ContextCommand extends Command {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,9 +250,10 @@ export class ContextCommand extends Command {
|
||||
]);
|
||||
|
||||
// Update context
|
||||
await this.authManager.updateContext({
|
||||
this.authManager.updateContext({
|
||||
orgId: selectedOrg.id,
|
||||
orgName: selectedOrg.name,
|
||||
orgSlug: selectedOrg.slug,
|
||||
// Clear brief when changing org
|
||||
briefId: undefined,
|
||||
briefName: undefined
|
||||
@@ -263,7 +264,7 @@ export class ContextCommand extends Command {
|
||||
return {
|
||||
success: true,
|
||||
action: 'select-org',
|
||||
context: (await this.authManager.getContext()) || undefined,
|
||||
context: this.authManager.getContext() || undefined,
|
||||
message: `Selected organization: ${selectedOrg.name}`
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -284,7 +285,7 @@ export class ContextCommand extends Command {
|
||||
}
|
||||
|
||||
// Check if org is selected
|
||||
const context = await this.authManager.getContext();
|
||||
const context = this.authManager.getContext();
|
||||
if (!context?.orgId) {
|
||||
ui.displayError(
|
||||
'No organization selected. Run "tm context org" first.'
|
||||
@@ -299,8 +300,7 @@ export class ContextCommand extends Command {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,26 +324,54 @@ export class ContextCommand extends Command {
|
||||
};
|
||||
}
|
||||
|
||||
// Prompt for selection
|
||||
const { selectedBrief } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'selectedBrief',
|
||||
message: 'Select a brief:',
|
||||
choices: [
|
||||
{ name: '(No brief - organization level)', value: null },
|
||||
...briefs.map((brief) => ({
|
||||
name: `Brief ${brief.id} (${new Date(brief.createdAt).toLocaleDateString()})`,
|
||||
value: brief
|
||||
}))
|
||||
]
|
||||
// Prompt for selection with search
|
||||
const selectedBrief = await search<(typeof briefs)[0] | null>({
|
||||
message: 'Search for a brief:',
|
||||
source: async (input) => {
|
||||
const searchTerm = input?.toLowerCase() || '';
|
||||
|
||||
// Static option for no brief
|
||||
const noBriefOption = {
|
||||
name: '(No brief - organization level)',
|
||||
value: null as any,
|
||||
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) {
|
||||
// Update context with brief
|
||||
const briefName = `Brief ${selectedBrief.id.slice(0, 8)}`;
|
||||
await this.authManager.updateContext({
|
||||
const briefName =
|
||||
selectedBrief.document?.title ||
|
||||
`Brief ${selectedBrief.id.slice(0, 8)}`;
|
||||
this.authManager.updateContext({
|
||||
briefId: selectedBrief.id,
|
||||
briefName: briefName
|
||||
});
|
||||
@@ -353,12 +381,12 @@ export class ContextCommand extends Command {
|
||||
return {
|
||||
success: true,
|
||||
action: 'select-brief',
|
||||
context: (await this.authManager.getContext()) || undefined,
|
||||
message: `Selected brief: ${selectedBrief.name}`
|
||||
context: this.authManager.getContext() || undefined,
|
||||
message: `Selected brief: ${selectedBrief.document?.title}`
|
||||
};
|
||||
} else {
|
||||
// Clear brief selection
|
||||
await this.authManager.updateContext({
|
||||
this.authManager.updateContext({
|
||||
briefId: undefined,
|
||||
briefName: undefined
|
||||
});
|
||||
@@ -368,7 +396,7 @@ export class ContextCommand extends Command {
|
||||
return {
|
||||
success: true,
|
||||
action: 'select-brief',
|
||||
context: (await this.authManager.getContext()) || undefined,
|
||||
context: this.authManager.getContext() || undefined,
|
||||
message: 'Cleared brief selection'
|
||||
};
|
||||
}
|
||||
@@ -396,8 +424,7 @@ export class ContextCommand extends Command {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,8 +470,7 @@ export class ContextCommand extends Command {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +494,7 @@ export class ContextCommand extends Command {
|
||||
if (!briefId) {
|
||||
spinner.fail('Could not extract a brief ID from the provided input');
|
||||
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);
|
||||
}
|
||||
@@ -480,20 +506,24 @@ export class ContextCommand extends Command {
|
||||
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 orgSlug: string | undefined;
|
||||
try {
|
||||
const org = await this.authManager.getOrganization(brief.accountId);
|
||||
orgName = org?.name;
|
||||
orgSlug = org?.slug;
|
||||
} catch {
|
||||
// Non-fatal if org lookup fails
|
||||
}
|
||||
|
||||
// Update context: set org and brief
|
||||
const briefName = `Brief ${brief.id.slice(0, 8)}`;
|
||||
await this.authManager.updateContext({
|
||||
const briefName =
|
||||
brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
|
||||
this.authManager.updateContext({
|
||||
orgId: brief.accountId,
|
||||
orgName,
|
||||
orgSlug,
|
||||
briefId: brief.id,
|
||||
briefName
|
||||
});
|
||||
@@ -508,15 +538,14 @@ export class ContextCommand extends Command {
|
||||
this.setLastResult({
|
||||
success: true,
|
||||
action: 'set',
|
||||
context: (await this.authManager.getContext()) || undefined,
|
||||
context: this.authManager.getContext() || undefined,
|
||||
message: 'Context set from brief'
|
||||
});
|
||||
} catch (error: any) {
|
||||
try {
|
||||
if (spinner?.isSpinning) spinner.stop();
|
||||
} catch {}
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,7 +642,7 @@ export class ContextCommand extends Command {
|
||||
};
|
||||
}
|
||||
|
||||
await this.authManager.updateContext(context);
|
||||
this.authManager.updateContext(context);
|
||||
ui.displaySuccess('Context updated');
|
||||
|
||||
// Display what was set
|
||||
@@ -631,7 +660,7 @@ export class ContextCommand extends Command {
|
||||
return {
|
||||
success: true,
|
||||
action: 'set',
|
||||
context: (await this.authManager.getContext()) || undefined,
|
||||
context: this.authManager.getContext() || undefined,
|
||||
message: 'Context updated'
|
||||
};
|
||||
} 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
|
||||
*/
|
||||
@@ -682,10 +691,57 @@ export class ContextCommand extends Command {
|
||||
/**
|
||||
* Get current context (for programmatic usage)
|
||||
*/
|
||||
getContext(): Promise<UserContext | null> {
|
||||
getContext(): UserContext | null {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -7,13 +7,10 @@ import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import ora, { Ora } from 'ora';
|
||||
import {
|
||||
AuthManager,
|
||||
AuthenticationError,
|
||||
type UserContext
|
||||
} from '@tm/core/auth';
|
||||
import { AuthManager, type UserContext } from '@tm/core/auth';
|
||||
import { TaskMasterCore, type ExportResult } from '@tm/core';
|
||||
import * as ui from '../utils/ui.js';
|
||||
import { displayError } from '../utils/error-handler.js';
|
||||
|
||||
/**
|
||||
* Result type from export command
|
||||
@@ -197,8 +194,7 @@ export class ExportCommand extends Command {
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (spinner?.isSpinning) spinner.fail('Export failed');
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,26 +330,6 @@ export class ExportCommand extends Command {
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
} from '@tm/core';
|
||||
import type { StorageType } from '@tm/core/types';
|
||||
import * as ui from '../utils/ui.js';
|
||||
import { displayError } from '../utils/error-handler.js';
|
||||
import { displayCommandHeader } from '../utils/display-helpers.js';
|
||||
import {
|
||||
displayHeader,
|
||||
displayDashboards,
|
||||
calculateTaskStatistics,
|
||||
calculateSubtaskStatistics,
|
||||
@@ -106,14 +107,7 @@ export class ListTasksCommand extends Command {
|
||||
this.displayResults(result, options);
|
||||
}
|
||||
} catch (error: any) {
|
||||
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));
|
||||
}
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,15 +251,12 @@ export class ListTasksCommand extends Command {
|
||||
* Display in text format with tables
|
||||
*/
|
||||
private displayText(data: ListTasksResult, withSubtasks?: boolean): void {
|
||||
const { tasks, tag } = data;
|
||||
const { tasks, tag, storageType } = data;
|
||||
|
||||
// Get file path for display
|
||||
const filePath = this.tmCore ? `.taskmaster/tasks/tasks.json` : undefined;
|
||||
|
||||
// Display header without banner (banner already shown by main CLI)
|
||||
displayHeader({
|
||||
// Display header using utility function
|
||||
displayCommandHeader(this.tmCore, {
|
||||
tag: tag || 'master',
|
||||
filePath: filePath
|
||||
storageType
|
||||
});
|
||||
|
||||
// No tasks message
|
||||
|
||||
248
apps/cli/src/commands/next.command.ts
Normal file
248
apps/cli/src/commands/next.command.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @fileoverview NextCommand using Commander's native class pattern
|
||||
* Extends Commander.Command for better integration with the framework
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import boxen from 'boxen';
|
||||
import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core';
|
||||
import type { StorageType } from '@tm/core/types';
|
||||
import { displayError } from '../utils/error-handler.js';
|
||||
import { displayTaskDetails } from '../ui/components/task-detail.component.js';
|
||||
import { displayCommandHeader } from '../utils/display-helpers.js';
|
||||
|
||||
/**
|
||||
* Options interface for the next command
|
||||
*/
|
||||
export interface NextCommandOptions {
|
||||
tag?: string;
|
||||
format?: 'text' | 'json';
|
||||
silent?: boolean;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type from next command
|
||||
*/
|
||||
export interface NextTaskResult {
|
||||
task: Task | null;
|
||||
found: boolean;
|
||||
tag: string;
|
||||
storageType: Exclude<StorageType, 'auto'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* NextCommand extending Commander's Command class
|
||||
* This is a thin presentation layer over @tm/core
|
||||
*/
|
||||
export class NextCommand extends Command {
|
||||
private tmCore?: TaskMasterCore;
|
||||
private lastResult?: NextTaskResult;
|
||||
|
||||
constructor(name?: string) {
|
||||
super(name || 'next');
|
||||
|
||||
// Configure the command
|
||||
this.description('Find the next available task to work on')
|
||||
.option('-t, --tag <tag>', 'Filter by tag')
|
||||
.option('-f, --format <format>', 'Output format (text, json)', 'text')
|
||||
.option('--silent', 'Suppress output (useful for programmatic usage)')
|
||||
.option('-p, --project <path>', 'Project root directory', process.cwd())
|
||||
.action(async (options: NextCommandOptions) => {
|
||||
await this.executeCommand(options);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the next command
|
||||
*/
|
||||
private async executeCommand(options: NextCommandOptions): Promise<void> {
|
||||
let hasError = false;
|
||||
try {
|
||||
// Validate options (throws on invalid options)
|
||||
this.validateOptions(options);
|
||||
|
||||
// Initialize tm-core
|
||||
await this.initializeCore(options.project || process.cwd());
|
||||
|
||||
// Get next task from core
|
||||
const result = await this.getNextTask(options);
|
||||
|
||||
// Store result for programmatic access
|
||||
this.setLastResult(result);
|
||||
|
||||
// Display results
|
||||
if (!options.silent) {
|
||||
this.displayResults(result, options);
|
||||
}
|
||||
} catch (error: any) {
|
||||
hasError = true;
|
||||
displayError(error, { skipExit: true });
|
||||
} finally {
|
||||
// Always clean up resources, even on error
|
||||
await this.cleanup();
|
||||
}
|
||||
|
||||
// Exit after cleanup completes
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate command options
|
||||
*/
|
||||
private validateOptions(options: NextCommandOptions): void {
|
||||
// Validate format
|
||||
if (options.format && !['text', 'json'].includes(options.format)) {
|
||||
throw new Error(
|
||||
`Invalid format: ${options.format}. Valid formats are: text, json`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize TaskMasterCore
|
||||
*/
|
||||
private async initializeCore(projectRoot: string): Promise<void> {
|
||||
if (!this.tmCore) {
|
||||
const resolved = path.resolve(projectRoot);
|
||||
this.tmCore = await createTaskMasterCore({ projectPath: resolved });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next task from tm-core
|
||||
*/
|
||||
private async getNextTask(
|
||||
options: NextCommandOptions
|
||||
): Promise<NextTaskResult> {
|
||||
if (!this.tmCore) {
|
||||
throw new Error('TaskMasterCore not initialized');
|
||||
}
|
||||
|
||||
// Call tm-core to get next task
|
||||
const task = await this.tmCore.getNextTask(options.tag);
|
||||
|
||||
// Get storage type and active tag
|
||||
const storageType = this.tmCore.getStorageType();
|
||||
if (storageType === 'auto') {
|
||||
throw new Error('Storage type must be resolved before use');
|
||||
}
|
||||
const activeTag = options.tag || this.tmCore.getActiveTag();
|
||||
|
||||
return {
|
||||
task,
|
||||
found: task !== null,
|
||||
tag: activeTag,
|
||||
storageType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Display results based on format
|
||||
*/
|
||||
private displayResults(
|
||||
result: NextTaskResult,
|
||||
options: NextCommandOptions
|
||||
): void {
|
||||
const format = options.format || 'text';
|
||||
|
||||
switch (format) {
|
||||
case 'json':
|
||||
this.displayJson(result);
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
this.displayText(result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display in JSON format
|
||||
*/
|
||||
private displayJson(result: NextTaskResult): void {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display in text format
|
||||
*/
|
||||
private displayText(result: NextTaskResult): void {
|
||||
// Display header with storage info
|
||||
displayCommandHeader(this.tmCore, {
|
||||
tag: result.tag || 'master',
|
||||
storageType: result.storageType
|
||||
});
|
||||
|
||||
if (!result.found || !result.task) {
|
||||
// No next task available
|
||||
console.log(
|
||||
boxen(
|
||||
chalk.yellow(
|
||||
'No tasks available to work on. All tasks are either completed, blocked by dependencies, or in progress.'
|
||||
),
|
||||
{
|
||||
padding: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'yellow',
|
||||
title: '⚠ NO TASKS AVAILABLE ⚠',
|
||||
titleAlignment: 'center'
|
||||
}
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
`\n${chalk.dim('Tip: Try')} ${chalk.cyan('task-master list --status pending')} ${chalk.dim('to see all pending tasks')}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const task = result.task;
|
||||
|
||||
// Display the task details using the same component as 'show' command
|
||||
// with a custom header indicating this is the next task
|
||||
const customHeader = `Next Task: #${task.id} - ${task.title}`;
|
||||
displayTaskDetails(task, {
|
||||
customHeader,
|
||||
headerColor: 'green',
|
||||
showSuggestedActions: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the last result for programmatic access
|
||||
*/
|
||||
private setLastResult(result: NextTaskResult): void {
|
||||
this.lastResult = result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last result (for programmatic usage)
|
||||
*/
|
||||
getLastResult(): NextTaskResult | undefined {
|
||||
return this.lastResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.tmCore) {
|
||||
await this.tmCore.close();
|
||||
this.tmCore = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this command on an existing program
|
||||
*/
|
||||
static register(program: Command, name?: string): NextCommand {
|
||||
const nextCommand = new NextCommand(name);
|
||||
program.addCommand(nextCommand);
|
||||
return nextCommand;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type TaskStatus
|
||||
} from '@tm/core';
|
||||
import type { StorageType } from '@tm/core/types';
|
||||
import { displayError } from '../utils/error-handler.js';
|
||||
|
||||
/**
|
||||
* Valid task status values for validation
|
||||
@@ -85,6 +86,7 @@ export class SetStatusCommand extends Command {
|
||||
private async executeCommand(
|
||||
options: SetStatusCommandOptions
|
||||
): Promise<void> {
|
||||
let hasError = false;
|
||||
try {
|
||||
// Validate required options
|
||||
if (!options.id) {
|
||||
@@ -135,16 +137,15 @@ export class SetStatusCommand extends Command {
|
||||
oldStatus: result.oldStatus,
|
||||
newStatus: result.newStatus
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (!options.silent) {
|
||||
console.error(
|
||||
chalk.red(`Failed to update task ${taskId}: ${errorMessage}`)
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
hasError = true;
|
||||
if (options.format === 'json') {
|
||||
const errorMessage = error?.getSanitizedDetails
|
||||
? error.getSanitizedDetails().message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
@@ -153,8 +154,13 @@ export class SetStatusCommand extends Command {
|
||||
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
|
||||
this.displayResults(this.lastResult, options);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
if (!options.silent) {
|
||||
console.error(chalk.red(`Error: ${errorMessage}`));
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
hasError = true;
|
||||
if (options.format === 'json') {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
console.log(JSON.stringify({ success: false, error: errorMessage }));
|
||||
} else if (!options.silent) {
|
||||
displayError(error, { skipExit: true });
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Clean up resources
|
||||
if (this.tmCore) {
|
||||
await this.tmCore.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Exit after cleanup completes
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,9 @@ import boxen from 'boxen';
|
||||
import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core';
|
||||
import type { StorageType } from '@tm/core/types';
|
||||
import * as ui from '../utils/ui.js';
|
||||
import { displayError } from '../utils/error-handler.js';
|
||||
import { displayTaskDetails } from '../ui/components/task-detail.component.js';
|
||||
import { displayCommandHeader } from '../utils/display-helpers.js';
|
||||
|
||||
/**
|
||||
* Options interface for the show command
|
||||
@@ -112,14 +114,7 @@ export class ShowCommand extends Command {
|
||||
this.displayResults(result, options);
|
||||
}
|
||||
} catch (error: any) {
|
||||
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));
|
||||
}
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +252,15 @@ export class ShowCommand extends Command {
|
||||
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
|
||||
displayTaskDetails(result.task, {
|
||||
statusFilter: options.status,
|
||||
@@ -271,8 +275,12 @@ export class ShowCommand extends Command {
|
||||
result: ShowMultipleTasksResult,
|
||||
_options: ShowCommandOptions
|
||||
): void {
|
||||
// Header
|
||||
ui.displayBanner(`Tasks (${result.tasks.length} found)`);
|
||||
// Display header with storage info
|
||||
const activeTag = this.tmCore?.getActiveTag() || 'master';
|
||||
displayCommandHeader(this.tmCore, {
|
||||
tag: activeTag,
|
||||
storageType: result.storageType
|
||||
});
|
||||
|
||||
if (result.notFound.length > 0) {
|
||||
console.log(chalk.yellow(`\n⚠ Not found: ${result.notFound.join(', ')}`));
|
||||
@@ -291,8 +299,6 @@ export class ShowCommand extends Command {
|
||||
showDependencies: true
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@tm/core';
|
||||
import { displayTaskDetails } from '../ui/components/task-detail.component.js';
|
||||
import * as ui from '../utils/ui.js';
|
||||
import { displayError } from '../utils/error-handler.js';
|
||||
|
||||
/**
|
||||
* CLI-specific options interface for the start command
|
||||
@@ -160,8 +161,7 @@ export class StartCommand extends Command {
|
||||
if (spinner) {
|
||||
spinner.fail('Operation failed');
|
||||
}
|
||||
this.handleError(error);
|
||||
process.exit(1);
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,22 +452,6 @@ export class StartCommand extends Command {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// Commands
|
||||
export { ListTasksCommand } from './commands/list.command.js';
|
||||
export { ShowCommand } from './commands/show.command.js';
|
||||
export { NextCommand } from './commands/next.command.js';
|
||||
export { AuthCommand } from './commands/auth.command.js';
|
||||
export { ContextCommand } from './commands/context.command.js';
|
||||
export { StartCommand } from './commands/start.command.js';
|
||||
@@ -23,6 +24,9 @@ export {
|
||||
// UI utilities (for other commands to use)
|
||||
export * as ui from './utils/ui.js';
|
||||
|
||||
// Error handling utilities
|
||||
export { displayError, isDebugMode } from './utils/error-handler.js';
|
||||
|
||||
// Auto-update utilities
|
||||
export {
|
||||
checkForUpdate,
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
/**
|
||||
* Brief information for API storage
|
||||
*/
|
||||
export interface BriefInfo {
|
||||
briefId: string;
|
||||
briefName: string;
|
||||
orgSlug?: string;
|
||||
webAppUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header configuration options
|
||||
*/
|
||||
@@ -12,22 +22,50 @@ export interface HeaderOptions {
|
||||
title?: string;
|
||||
tag?: string;
|
||||
filePath?: string;
|
||||
storageType?: 'api' | 'file';
|
||||
briefInfo?: BriefInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the Task Master header with project info
|
||||
*/
|
||||
export function displayHeader(options: HeaderOptions = {}): void {
|
||||
const { filePath, tag } = options;
|
||||
const { filePath, tag, storageType, briefInfo } = options;
|
||||
|
||||
// Display tag and file path info
|
||||
if (tag) {
|
||||
// Display different header based on storage type
|
||||
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 = '';
|
||||
|
||||
if (tag && tag !== 'master') {
|
||||
tagInfo = `🏷 tag: ${chalk.cyan(tag)}`;
|
||||
tagInfo = `🏷 tag: ${chalk.cyan(tag)}`;
|
||||
} else {
|
||||
tagInfo = `🏷 tag: ${chalk.cyan('master')}`;
|
||||
tagInfo = `🏷 tag: ${chalk.cyan('master')}`;
|
||||
}
|
||||
|
||||
console.log(tagInfo);
|
||||
@@ -39,7 +77,5 @@ export function displayHeader(options: HeaderOptions = {}): void {
|
||||
: `${process.cwd()}/${filePath}`;
|
||||
console.log(`Listing tasks from: ${chalk.dim(absolutePath)}`);
|
||||
}
|
||||
|
||||
console.log(); // Empty line for spacing
|
||||
}
|
||||
}
|
||||
|
||||
75
apps/cli/src/utils/display-helpers.ts
Normal file
75
apps/cli/src/utils/display-helpers.ts
Normal 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
|
||||
});
|
||||
}
|
||||
60
apps/cli/src/utils/error-handler.ts
Normal file
60
apps/cli/src/utils/error-handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Available Models as of October 5, 2025
|
||||
# Available Models as of October 18, 2025
|
||||
|
||||
## Main Models
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
| anthropic | claude-opus-4-20250514 | 0.725 | 15 | 75 |
|
||||
| anthropic | claude-3-7-sonnet-20250219 | 0.623 | 3 | 15 |
|
||||
| anthropic | claude-3-5-sonnet-20241022 | 0.49 | 3 | 15 |
|
||||
| anthropic | claude-sonnet-4-5-20250929 | 0.73 | 3 | 15 |
|
||||
| anthropic | claude-haiku-4-5-20251001 | 0.45 | 1 | 5 |
|
||||
| claude-code | opus | 0.725 | 0 | 0 |
|
||||
| claude-code | sonnet | 0.727 | 0 | 0 |
|
||||
| claude-code | haiku | 0.45 | 0 | 0 |
|
||||
| codex-cli | gpt-5 | 0.749 | 0 | 0 |
|
||||
| codex-cli | gpt-5-codex | 0.749 | 0 | 0 |
|
||||
| mcp | mcp-sampling | — | 0 | 0 |
|
||||
@@ -102,6 +105,7 @@
|
||||
| ----------- | -------------------------------------------- | --------- | ---------- | ----------- |
|
||||
| claude-code | opus | 0.725 | 0 | 0 |
|
||||
| claude-code | sonnet | 0.727 | 0 | 0 |
|
||||
| claude-code | haiku | 0.45 | 0 | 0 |
|
||||
| codex-cli | gpt-5 | 0.749 | 0 | 0 |
|
||||
| codex-cli | gpt-5-codex | 0.749 | 0 | 0 |
|
||||
| mcp | mcp-sampling | — | 0 | 0 |
|
||||
@@ -142,8 +146,11 @@
|
||||
| anthropic | claude-opus-4-20250514 | 0.725 | 15 | 75 |
|
||||
| anthropic | claude-3-7-sonnet-20250219 | 0.623 | 3 | 15 |
|
||||
| anthropic | claude-3-5-sonnet-20241022 | 0.49 | 3 | 15 |
|
||||
| anthropic | claude-sonnet-4-5-20250929 | 0.73 | 3 | 15 |
|
||||
| anthropic | claude-haiku-4-5-20251001 | 0.45 | 1 | 5 |
|
||||
| claude-code | opus | 0.725 | 0 | 0 |
|
||||
| claude-code | sonnet | 0.727 | 0 | 0 |
|
||||
| claude-code | haiku | 0.45 | 0 | 0 |
|
||||
| codex-cli | gpt-5 | 0.749 | 0 | 0 |
|
||||
| codex-cli | gpt-5-codex | 0.749 | 0 | 0 |
|
||||
| mcp | mcp-sampling | — | 0 | 0 |
|
||||
|
||||
@@ -78,7 +78,7 @@ function log(level, ...args) {
|
||||
// is responsible for directing logs correctly (e.g., to stderr)
|
||||
// during tool execution without upsetting the client connection.
|
||||
// Logs outside of tool execution (like startup) will go to stdout.
|
||||
console.log(prefix, ...coloredArgs);
|
||||
console.error(prefix, ...coloredArgs);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
86
package-lock.json
generated
86
package-lock.json
generated
@@ -104,6 +104,7 @@
|
||||
"name": "@tm/cli",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/search": "^3.2.0",
|
||||
"@tm/core": "*",
|
||||
"boxen": "^8.0.1",
|
||||
"chalk": "5.6.2",
|
||||
@@ -124,6 +125,91 @@
|
||||
"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": {
|
||||
"version": "0.0.6",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock('./credential-store.js', () => {
|
||||
}
|
||||
saveCredentials() {}
|
||||
clearCredentials() {}
|
||||
hasValidCredentials() {
|
||||
hasCredentials() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,6 @@ export class AuthManager {
|
||||
private oauthService: OAuthService;
|
||||
private supabaseClient: SupabaseAuthClient;
|
||||
private organizationService?: OrganizationService;
|
||||
private logger = getLogger('AuthManager');
|
||||
private refreshPromise: Promise<AuthCredentials> | null = null;
|
||||
|
||||
private constructor(config?: Partial<AuthConfig>) {
|
||||
this.credentialStore = CredentialStore.getInstance(config);
|
||||
@@ -83,60 +81,10 @@ export class AuthManager {
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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 {
|
||||
getCredentials(): AuthCredentials | null {
|
||||
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 {
|
||||
return this.credentialStore.hasValidCredentials();
|
||||
return this.credentialStore.hasCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user context (org/brief selection)
|
||||
*/
|
||||
async getContext(): Promise<UserContext | null> {
|
||||
const credentials = await this.getCredentials();
|
||||
getContext(): UserContext | null {
|
||||
const credentials = this.getCredentials();
|
||||
return credentials?.selectedContext || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user context (org/brief selection)
|
||||
*/
|
||||
async updateContext(context: Partial<UserContext>): Promise<void> {
|
||||
const credentials = await this.getCredentials();
|
||||
updateContext(context: Partial<UserContext>): void {
|
||||
const credentials = this.getCredentials();
|
||||
if (!credentials) {
|
||||
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
|
||||
}
|
||||
@@ -262,8 +211,8 @@ export class AuthManager {
|
||||
/**
|
||||
* Clear the user context
|
||||
*/
|
||||
async clearContext(): Promise<void> {
|
||||
const credentials = await this.getCredentials();
|
||||
clearContext(): void {
|
||||
const credentials = this.getCredentials();
|
||||
if (!credentials) {
|
||||
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
|
||||
}
|
||||
@@ -280,7 +229,7 @@ export class AuthManager {
|
||||
private async getOrganizationService(): Promise<OrganizationService> {
|
||||
if (!this.organizationService) {
|
||||
// First check if we have credentials with a token
|
||||
const credentials = await this.getCredentials();
|
||||
const credentials = this.getCredentials();
|
||||
if (!credentials || !credentials.token) {
|
||||
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ import path from 'path';
|
||||
import { AuthConfig } from './types.js';
|
||||
|
||||
// Single base domain for all URLs
|
||||
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsup's env option
|
||||
// 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
|
||||
const BASE_DOMAIN =
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN || // This gets replaced at build time by tsup
|
||||
'https://tryhamster.com';
|
||||
process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux)
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN; // Build-time (baked into compiled code)
|
||||
|
||||
/**
|
||||
* Default authentication configuration
|
||||
@@ -19,7 +21,7 @@ const BASE_DOMAIN =
|
||||
*/
|
||||
export const DEFAULT_AUTH_CONFIG: AuthConfig = {
|
||||
// Base domain for all services
|
||||
baseUrl: BASE_DOMAIN,
|
||||
baseUrl: BASE_DOMAIN!,
|
||||
|
||||
// Configuration directory and file paths
|
||||
configDir: path.join(os.homedir(), '.taskmaster'),
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
|
||||
credentialStore.saveCredentials(expiredCredentials);
|
||||
|
||||
const retrieved = credentialStore.getCredentials();
|
||||
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
@@ -69,7 +69,7 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
|
||||
credentialStore.saveCredentials(validCredentials);
|
||||
|
||||
const retrieved = credentialStore.getCredentials();
|
||||
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.token).toBe('valid-token');
|
||||
@@ -92,6 +92,25 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
expect(retrieved).not.toBeNull();
|
||||
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', () => {
|
||||
@@ -108,7 +127,7 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
|
||||
credentialStore.saveCredentials(almostExpiredCredentials);
|
||||
|
||||
const retrieved = credentialStore.getCredentials();
|
||||
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
@@ -126,7 +145,7 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
|
||||
credentialStore.saveCredentials(validCredentials);
|
||||
|
||||
const retrieved = credentialStore.getCredentials();
|
||||
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.token).toBe('valid-token');
|
||||
@@ -146,7 +165,7 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
|
||||
credentialStore.saveCredentials(credentials);
|
||||
|
||||
const retrieved = credentialStore.getCredentials();
|
||||
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(typeof retrieved?.expiresAt).toBe('number'); // Normalized to number
|
||||
@@ -164,7 +183,7 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
|
||||
credentialStore.saveCredentials(credentials);
|
||||
|
||||
const retrieved = credentialStore.getCredentials();
|
||||
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(typeof retrieved?.expiresAt).toBe('number');
|
||||
@@ -185,7 +204,7 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
mode: 0o600
|
||||
});
|
||||
|
||||
const retrieved = credentialStore.getCredentials();
|
||||
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
@@ -203,7 +222,7 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
mode: 0o600
|
||||
});
|
||||
|
||||
const retrieved = credentialStore.getCredentials();
|
||||
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
@@ -244,15 +263,15 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
|
||||
credentialStore.saveCredentials(credentials);
|
||||
|
||||
const retrieved = credentialStore.getCredentials();
|
||||
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||
|
||||
// Should be normalized to number for runtime use
|
||||
expect(typeof retrieved?.expiresAt).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasValidCredentials', () => {
|
||||
it('should return false for expired credentials', () => {
|
||||
describe('hasCredentials', () => {
|
||||
it('should return true for expired credentials', () => {
|
||||
const expiredCredentials: AuthCredentials = {
|
||||
token: 'expired-token',
|
||||
refreshToken: 'refresh-token',
|
||||
@@ -264,7 +283,7 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
|
||||
credentialStore.saveCredentials(expiredCredentials);
|
||||
|
||||
expect(credentialStore.hasValidCredentials()).toBe(false);
|
||||
expect(credentialStore.hasCredentials()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid credentials', () => {
|
||||
@@ -279,11 +298,11 @@ describe('CredentialStore - Token Expiration', () => {
|
||||
|
||||
credentialStore.saveCredentials(validCredentials);
|
||||
|
||||
expect(credentialStore.hasValidCredentials()).toBe(true);
|
||||
expect(credentialStore.hasCredentials()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no credentials exist', () => {
|
||||
expect(credentialStore.hasValidCredentials()).toBe(false);
|
||||
expect(credentialStore.hasCredentials()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('CredentialStore', () => {
|
||||
JSON.stringify(mockCredentials)
|
||||
);
|
||||
|
||||
const result = store.getCredentials();
|
||||
const result = store.getCredentials({ allowExpired: false });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
@@ -226,6 +226,31 @@ describe('CredentialStore', () => {
|
||||
expect(result).not.toBeNull();
|
||||
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', () => {
|
||||
@@ -451,7 +476,7 @@ describe('CredentialStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasValidCredentials', () => {
|
||||
describe('hasCredentials', () => {
|
||||
it('should return true when valid unexpired credentials exist', () => {
|
||||
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
|
||||
const credentials = {
|
||||
@@ -465,10 +490,10 @@ describe('CredentialStore', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
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 credentials = {
|
||||
token: 'expired-token',
|
||||
@@ -481,13 +506,13 @@ describe('CredentialStore', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
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', () => {
|
||||
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', () => {
|
||||
@@ -495,7 +520,7 @@ describe('CredentialStore', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
||||
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
||||
|
||||
expect(store.hasValidCredentials()).toBe(false);
|
||||
expect(store.hasCredentials()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for credentials without expiry', () => {
|
||||
@@ -510,7 +535,7 @@ describe('CredentialStore', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
||||
|
||||
// Credentials without expiry are considered invalid
|
||||
expect(store.hasValidCredentials()).toBe(false);
|
||||
expect(store.hasCredentials()).toBe(false);
|
||||
|
||||
// Should log warning about missing expiration
|
||||
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
|
||||
const getCredentialsSpy = vi.spyOn(store, 'getCredentials');
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
store.hasValidCredentials();
|
||||
store.hasCredentials();
|
||||
|
||||
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: false });
|
||||
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ export class CredentialStore {
|
||||
private config: AuthConfig;
|
||||
// Clock skew tolerance for expiry checks (30 seconds)
|
||||
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>) {
|
||||
this.config = getAuthConfig(config);
|
||||
@@ -54,9 +56,12 @@ export class CredentialStore {
|
||||
|
||||
/**
|
||||
* Get stored authentication credentials
|
||||
* @param options.allowExpired - Whether to return expired credentials (default: true)
|
||||
* @returns AuthCredentials with expiresAt as number (milliseconds) for runtime use
|
||||
*/
|
||||
getCredentials(options?: { allowExpired?: boolean }): AuthCredentials | null {
|
||||
getCredentials({
|
||||
allowExpired = true
|
||||
}: { allowExpired?: boolean } = {}): AuthCredentials | null {
|
||||
try {
|
||||
if (!fs.existsSync(this.config.configFile)) {
|
||||
return null;
|
||||
@@ -81,7 +86,11 @@ export class CredentialStore {
|
||||
|
||||
// Validate expiration time for tokens
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -90,7 +99,6 @@ export class CredentialStore {
|
||||
|
||||
// Check if the token has expired (with clock skew tolerance)
|
||||
const now = Date.now();
|
||||
const allowExpired = options?.allowExpired ?? false;
|
||||
if (now >= expiresAtMs - this.CLOCK_SKEW_MS && !allowExpired) {
|
||||
this.logger.warn(
|
||||
'Authentication token has expired or is about to expire',
|
||||
@@ -103,7 +111,7 @@ export class CredentialStore {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return valid token
|
||||
// Return credentials (even if expired) to enable refresh flows
|
||||
return authData;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
@@ -172,6 +180,9 @@ export class CredentialStore {
|
||||
mode: 0o600
|
||||
});
|
||||
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) {
|
||||
throw new AuthenticationError(
|
||||
`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 {
|
||||
const credentials = this.getCredentials({ allowExpired: false });
|
||||
hasCredentials(): boolean {
|
||||
const credentials = this.getCredentials({ allowExpired: true });
|
||||
return credentials !== null;
|
||||
}
|
||||
|
||||
|
||||
@@ -281,15 +281,26 @@ export class OAuthService {
|
||||
// Exchange code for session using PKCE
|
||||
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
|
||||
const authData: AuthCredentials = {
|
||||
token: session.access_token,
|
||||
refreshToken: session.refresh_token,
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
expiresAt: session.expires_at
|
||||
? new Date(session.expires_at * 1000).toISOString()
|
||||
: undefined,
|
||||
expiresAt,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
@@ -340,10 +351,18 @@ export class OAuthService {
|
||||
// Get user info from the session
|
||||
const user = await this.supabaseClient.getUser();
|
||||
|
||||
// Calculate expiration time
|
||||
const expiresAt = expiresIn
|
||||
? new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString()
|
||||
: undefined;
|
||||
// Calculate expiration time - 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 = expiresIn
|
||||
? new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Save authentication data
|
||||
const authData: AuthCredentials = {
|
||||
@@ -351,7 +370,7 @@ export class OAuthService {
|
||||
refreshToken: refreshToken || undefined,
|
||||
userId: user?.id || 'unknown',
|
||||
email: user?.email,
|
||||
expiresAt: expiresAt,
|
||||
expiresAt,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
@@ -98,11 +98,11 @@ export class SupabaseSessionStorage implements SupportedStorage {
|
||||
// Only handle Supabase session keys
|
||||
if (key === STORAGE_KEY || key.includes('auth-token')) {
|
||||
try {
|
||||
this.logger.info('Supabase called setItem - storing refreshed session');
|
||||
|
||||
// Parse the session and update our credentials
|
||||
const sessionUpdates = this.parseSessionToCredentials(value);
|
||||
const existingCredentials = this.store.getCredentials({
|
||||
allowExpired: true
|
||||
});
|
||||
const existingCredentials = this.store.getCredentials();
|
||||
|
||||
if (sessionUpdates.token) {
|
||||
const updatedCredentials: AuthCredentials = {
|
||||
@@ -113,6 +113,9 @@ export class SupabaseSessionStorage implements SupportedStorage {
|
||||
} as AuthCredentials;
|
||||
|
||||
this.store.saveCredentials(updatedCredentials);
|
||||
this.logger.info(
|
||||
'Successfully saved refreshed credentials from Supabase'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Error setting session:', error);
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AuthCredentials {
|
||||
export interface UserContext {
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
orgSlug?: string;
|
||||
briefId?: string;
|
||||
briefName?: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -17,10 +17,11 @@ export class SupabaseAuthClient {
|
||||
private client: SupabaseJSClient | null = null;
|
||||
private sessionStorage: SupabaseSessionStorage;
|
||||
private logger = getLogger('SupabaseAuthClient');
|
||||
private credentialStore: CredentialStore;
|
||||
|
||||
constructor() {
|
||||
const credentialStore = CredentialStore.getInstance();
|
||||
this.sessionStorage = new SupabaseSessionStorage(credentialStore);
|
||||
this.credentialStore = CredentialStore.getInstance();
|
||||
this.sessionStorage = new SupabaseSessionStorage(this.credentialStore);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,13 +29,17 @@ export class SupabaseAuthClient {
|
||||
*/
|
||||
getClient(): SupabaseJSClient {
|
||||
if (!this.client) {
|
||||
// Get Supabase configuration from environment - using TM_PUBLIC prefix
|
||||
const supabaseUrl = process.env.TM_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.TM_PUBLIC_SUPABASE_ANON_KEY;
|
||||
// Get Supabase configuration from environment
|
||||
// Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
|
||||
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) {
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,10 @@ export const ERROR_CODES = {
|
||||
INVALID_INPUT: 'INVALID_INPUT',
|
||||
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
|
||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
||||
NOT_FOUND: 'NOT_FOUND'
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
|
||||
// Context errors
|
||||
NO_BRIEF_SELECTED: 'NO_BRIEF_SELECTED'
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface LoggerConfig {
|
||||
export class Logger {
|
||||
private config: Required<LoggerConfig>;
|
||||
private static readonly DEFAULT_CONFIG: Required<LoggerConfig> = {
|
||||
level: LogLevel.WARN,
|
||||
level: LogLevel.SILENT,
|
||||
silent: false,
|
||||
prefix: '',
|
||||
timestamp: false,
|
||||
|
||||
@@ -358,11 +358,12 @@ export class ExportService {
|
||||
tasks: any[]
|
||||
): Promise<void> {
|
||||
// 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
|
||||
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
|
||||
const flatTasks = this.transformTasksForBulkImport(tasks);
|
||||
|
||||
@@ -27,6 +27,12 @@ export interface Brief {
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
document?: {
|
||||
id: string;
|
||||
title: string;
|
||||
document_name: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,7 +177,12 @@ export class OrganizationService {
|
||||
document_id,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
updated_at,
|
||||
document:document_id (
|
||||
id,
|
||||
document_name,
|
||||
title
|
||||
)
|
||||
`)
|
||||
.eq('account_id', orgId);
|
||||
|
||||
@@ -196,7 +207,14 @@ export class OrganizationService {
|
||||
documentId: brief.document_id,
|
||||
status: brief.status,
|
||||
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) {
|
||||
if (error instanceof TaskMasterError) {
|
||||
@@ -224,7 +242,13 @@ export class OrganizationService {
|
||||
document_id,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
updated_at,
|
||||
document:document_id (
|
||||
id,
|
||||
document_name,
|
||||
title,
|
||||
description
|
||||
)
|
||||
`)
|
||||
.eq('id', briefId)
|
||||
.single();
|
||||
@@ -253,7 +277,15 @@ export class OrganizationService {
|
||||
documentId: briefData.document_id,
|
||||
status: briefData.status,
|
||||
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) {
|
||||
if (error instanceof TaskMasterError) {
|
||||
|
||||
@@ -161,6 +161,16 @@ export class TaskService {
|
||||
storageType: this.getStorageType()
|
||||
};
|
||||
} 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);
|
||||
throw new TaskMasterError(
|
||||
'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
|
||||
return await this.storage.loadTask(String(taskId), activeTag);
|
||||
} 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(
|
||||
`Failed to get task ${taskId}`,
|
||||
ERROR_CODES.STORAGE_ERROR,
|
||||
@@ -522,6 +540,14 @@ export class TaskService {
|
||||
activeTag
|
||||
);
|
||||
} 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(
|
||||
`Failed to update task status for ${taskIdStr}`,
|
||||
ERROR_CODES.STORAGE_ERROR,
|
||||
|
||||
@@ -37,6 +37,13 @@ export interface ApiStorageConfig {
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth context with a guaranteed briefId
|
||||
*/
|
||||
type ContextWithBrief = NonNullable<
|
||||
ReturnType<typeof AuthManager.prototype.getContext>
|
||||
> & { briefId: string };
|
||||
|
||||
/**
|
||||
* ApiStorage implementation using repository pattern
|
||||
* 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
|
||||
* In our API-based system, "tags" represent briefs
|
||||
@@ -119,7 +133,7 @@ export class ApiStorage implements IStorage {
|
||||
private async loadTagsIntoCache(): Promise<void> {
|
||||
try {
|
||||
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 (context?.briefId) {
|
||||
@@ -151,15 +165,7 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const authManager = AuthManager.getInstance();
|
||||
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>'
|
||||
);
|
||||
}
|
||||
const context = this.ensureBriefSelected('loadTasks');
|
||||
|
||||
// Load tasks from the current brief context with filters pushed to repository
|
||||
const tasks = await this.retryOperation(() =>
|
||||
@@ -174,12 +180,11 @@ export class ApiStorage implements IStorage {
|
||||
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to load tasks from API',
|
||||
ERROR_CODES.STORAGE_ERROR,
|
||||
{ operation: 'loadTasks', tag, context: 'brief-based loading' },
|
||||
error as Error
|
||||
);
|
||||
this.wrapError(error, 'Failed to load tasks from API', {
|
||||
operation: 'loadTasks',
|
||||
tag,
|
||||
context: 'brief-based loading'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,16 +235,17 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
this.ensureBriefSelected('loadTask');
|
||||
|
||||
return await this.retryOperation(() =>
|
||||
this.repository.getTask(this.projectId, taskId)
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to load task from API',
|
||||
ERROR_CODES.STORAGE_ERROR,
|
||||
{ operation: 'loadTask', taskId, tag },
|
||||
error as Error
|
||||
);
|
||||
this.wrapError(error, 'Failed to load task from API', {
|
||||
operation: 'loadTask',
|
||||
taskId,
|
||||
tag
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +324,7 @@ export class ApiStorage implements IStorage {
|
||||
|
||||
try {
|
||||
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
|
||||
if (context?.briefId) {
|
||||
@@ -503,6 +509,8 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
this.ensureBriefSelected('updateTaskStatus');
|
||||
|
||||
const existingTask = await this.retryOperation(() =>
|
||||
this.repository.getTask(this.projectId, taskId)
|
||||
);
|
||||
@@ -539,12 +547,12 @@ export class ApiStorage implements IStorage {
|
||||
taskId
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to update task status via API',
|
||||
ERROR_CODES.STORAGE_ERROR,
|
||||
{ operation: 'updateTaskStatus', taskId, newStatus, tag },
|
||||
error as Error
|
||||
);
|
||||
this.wrapError(error, 'Failed to update task status via API', {
|
||||
operation: 'updateTaskStatus',
|
||||
taskId,
|
||||
newStatus,
|
||||
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
|
||||
*/
|
||||
@@ -780,4 +811,28 @@ export class ApiStorage implements IStorage {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ export class FileStorage implements IStorage {
|
||||
await this.fileOps.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage type
|
||||
*/
|
||||
getType(): 'file' {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about the storage
|
||||
*/
|
||||
|
||||
@@ -73,7 +73,7 @@ export class StorageFactory {
|
||||
);
|
||||
}
|
||||
// Use auth token from AuthManager (synchronous - no auto-refresh here)
|
||||
const credentials = authManager.getCredentialsSync();
|
||||
const credentials = authManager.getCredentials();
|
||||
if (credentials) {
|
||||
// Merge with existing storage config, ensuring required fields
|
||||
const nextStorage: StorageSettings = {
|
||||
@@ -82,8 +82,8 @@ export class StorageFactory {
|
||||
apiAccessToken: credentials.token,
|
||||
apiEndpoint:
|
||||
config.storage?.apiEndpoint ||
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN ||
|
||||
'https://tryhamster.com/api'
|
||||
process.env.TM_BASE_DOMAIN ||
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN
|
||||
};
|
||||
config.storage = nextStorage;
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export class StorageFactory {
|
||||
|
||||
// Then check if authenticated via AuthManager
|
||||
if (authManager.isAuthenticated()) {
|
||||
const credentials = authManager.getCredentialsSync();
|
||||
const credentials = authManager.getCredentials();
|
||||
if (credentials) {
|
||||
// Configure API storage with auth credentials
|
||||
const nextStorage: StorageSettings = {
|
||||
@@ -112,6 +112,7 @@ export class StorageFactory {
|
||||
apiAccessToken: credentials.token,
|
||||
apiEndpoint:
|
||||
config.storage?.apiEndpoint ||
|
||||
process.env.TM_BASE_DOMAIN ||
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN ||
|
||||
'https://tryhamster.com/api'
|
||||
};
|
||||
|
||||
@@ -201,6 +201,44 @@ export class TaskMasterCore {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
const expiredCredentials: AuthCredentials = {
|
||||
token: 'expired_access_token',
|
||||
@@ -63,50 +63,16 @@ describe('AuthManager Token Refresh', () => {
|
||||
|
||||
credentialStore.saveCredentials(expiredCredentials);
|
||||
|
||||
// Mock the refreshToken method to track calls
|
||||
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
|
||||
const mockSession: Session = {
|
||||
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()
|
||||
}
|
||||
};
|
||||
// Get credentials should return them even if expired
|
||||
// Refresh will be handled by explicit calls or client operations
|
||||
const credentials = authManager.getCredentials();
|
||||
|
||||
refreshTokenSpy.mockResolvedValue({
|
||||
token: mockSession.access_token,
|
||||
refreshToken: mockSession.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);
|
||||
expect(credentials).not.toBeNull();
|
||||
expect(credentials?.token).toBe('expired_access_token');
|
||||
expect(credentials?.refreshToken).toBe('valid_refresh_token');
|
||||
});
|
||||
|
||||
it('should return valid credentials without attempting refresh', async () => {
|
||||
it('should return valid credentials', () => {
|
||||
// Set up valid (non-expired) credentials
|
||||
const validCredentials: AuthCredentials = {
|
||||
token: 'valid_access_token',
|
||||
@@ -119,17 +85,14 @@ describe('AuthManager Token Refresh', () => {
|
||||
|
||||
credentialStore.saveCredentials(validCredentials);
|
||||
|
||||
// Spy on refreshToken to ensure it's not called
|
||||
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
|
||||
|
||||
const credentials = await authManager.getCredentials();
|
||||
const credentials = authManager.getCredentials();
|
||||
|
||||
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
|
||||
// We still return them - it's up to the caller to handle
|
||||
const expiredCredentials: AuthCredentials = {
|
||||
token: 'expired_access_token',
|
||||
refreshToken: undefined,
|
||||
@@ -141,17 +104,19 @@ describe('AuthManager Token Refresh', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('should return null if no credentials exist', async () => {
|
||||
const credentials = await authManager.getCredentials();
|
||||
expect(credentials).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle refresh failures gracefully', async () => {
|
||||
it('should return credentials regardless of refresh token validity', () => {
|
||||
// Set up expired credentials with refresh token
|
||||
const expiredCredentials: AuthCredentials = {
|
||||
token: 'expired_access_token',
|
||||
@@ -164,13 +129,11 @@ describe('AuthManager Token Refresh', () => {
|
||||
|
||||
credentialStore.saveCredentials(expiredCredentials);
|
||||
|
||||
// Mock refreshToken to throw an error
|
||||
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
|
||||
refreshTokenSpy.mockRejectedValue(new Error('Refresh failed'));
|
||||
const credentials = authManager.getCredentials();
|
||||
|
||||
const credentials = await authManager.getCredentials();
|
||||
|
||||
expect(credentials).toBeNull();
|
||||
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
|
||||
// Returns credentials - refresh will be attempted by the client which will handle failure
|
||||
expect(credentials).not.toBeNull();
|
||||
expect(credentials?.token).toBe('expired_access_token');
|
||||
expect(credentials?.refreshToken).toBe('invalid_refresh_token');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
});
|
||||
|
||||
describe('Expired Token Detection', () => {
|
||||
it('should detect expired token', async () => {
|
||||
it('should return expired token for Supabase to refresh', () => {
|
||||
// Set up expired credentials
|
||||
const expiredCredentials: AuthCredentials = {
|
||||
token: 'expired-token',
|
||||
@@ -91,24 +91,15 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
|
||||
authManager = AuthManager.getInstance();
|
||||
|
||||
// Mock the Supabase refreshSession to return new tokens
|
||||
const mockRefreshSession = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mockRefreshedSession);
|
||||
vi.spyOn(
|
||||
authManager['supabaseClient'],
|
||||
'refreshSession'
|
||||
).mockImplementation(mockRefreshSession);
|
||||
// Get credentials returns them even if expired
|
||||
const credentials = authManager.getCredentials();
|
||||
|
||||
// Get credentials should trigger refresh
|
||||
const credentials = await authManager.getCredentials();
|
||||
|
||||
expect(mockRefreshSession).toHaveBeenCalledTimes(1);
|
||||
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
|
||||
const validCredentials: AuthCredentials = {
|
||||
token: 'valid-token',
|
||||
@@ -123,22 +114,14 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
|
||||
authManager = AuthManager.getInstance();
|
||||
|
||||
// Mock refresh to ensure it's not called
|
||||
const mockRefreshSession = vi.fn();
|
||||
vi.spyOn(
|
||||
authManager['supabaseClient'],
|
||||
'refreshSession'
|
||||
).mockImplementation(mockRefreshSession);
|
||||
const credentials = authManager.getCredentials();
|
||||
|
||||
const credentials = await authManager.getCredentials();
|
||||
|
||||
expect(mockRefreshSession).not.toHaveBeenCalled();
|
||||
expect(credentials?.token).toBe('valid-token');
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
token: 'old-token',
|
||||
refreshToken: 'old-refresh-token',
|
||||
@@ -162,23 +145,24 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
'refreshSession'
|
||||
).mockResolvedValue(mockRefreshedSession);
|
||||
|
||||
const refreshedCredentials = await authManager.getCredentials();
|
||||
// Explicitly call refreshToken() method
|
||||
const refreshedCredentials = await authManager.refreshToken();
|
||||
|
||||
expect(refreshedCredentials).not.toBeNull();
|
||||
expect(refreshedCredentials?.token).toBe('new-access-token-xyz');
|
||||
expect(refreshedCredentials?.refreshToken).toBe('new-refresh-token-xyz');
|
||||
expect(refreshedCredentials.token).toBe('new-access-token-xyz');
|
||||
expect(refreshedCredentials.refreshToken).toBe('new-refresh-token-xyz');
|
||||
|
||||
// Verify context was preserved
|
||||
expect(refreshedCredentials?.selectedContext?.orgId).toBe('test-org');
|
||||
expect(refreshedCredentials?.selectedContext?.briefId).toBe('test-brief');
|
||||
expect(refreshedCredentials.selectedContext?.orgId).toBe('test-org');
|
||||
expect(refreshedCredentials.selectedContext?.briefId).toBe('test-brief');
|
||||
|
||||
// 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();
|
||||
expect(newExpiry).toBeGreaterThan(now);
|
||||
});
|
||||
|
||||
it('should return null if refresh fails', async () => {
|
||||
it('should throw error if manual refresh fails', async () => {
|
||||
const expiredCredentials: AuthCredentials = {
|
||||
token: 'expired-token',
|
||||
refreshToken: 'invalid-refresh-token',
|
||||
@@ -198,12 +182,11 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
'refreshSession'
|
||||
).mockRejectedValue(new Error('Refresh token expired'));
|
||||
|
||||
const credentials = await authManager.getCredentials();
|
||||
|
||||
expect(credentials).toBeNull();
|
||||
// Explicit refreshToken() call should throw
|
||||
await expect(authManager.refreshToken()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should return null if no refresh token available', async () => {
|
||||
it('should return expired credentials even without refresh token', () => {
|
||||
const expiredCredentials: AuthCredentials = {
|
||||
token: 'expired-token',
|
||||
// No refresh token
|
||||
@@ -217,18 +200,21 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
|
||||
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 = {
|
||||
token: 'test-token',
|
||||
refreshToken: 'refresh-token',
|
||||
userId: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
// Missing expiresAt
|
||||
// Missing expiresAt - invalid token
|
||||
savedAt: new Date().toISOString()
|
||||
} as any;
|
||||
|
||||
@@ -236,16 +222,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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)
|
||||
// Supabase will handle refresh automatically
|
||||
const almostExpiredCredentials: AuthCredentials = {
|
||||
token: 'almost-expired-token',
|
||||
refreshToken: 'valid-refresh-token',
|
||||
@@ -259,23 +246,16 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
|
||||
authManager = AuthManager.getInstance();
|
||||
|
||||
const mockRefreshSession = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mockRefreshedSession);
|
||||
vi.spyOn(
|
||||
authManager['supabaseClient'],
|
||||
'refreshSession'
|
||||
).mockImplementation(mockRefreshSession);
|
||||
const credentials = authManager.getCredentials();
|
||||
|
||||
const credentials = await authManager.getCredentials();
|
||||
|
||||
// Should trigger refresh due to 30-second buffer
|
||||
expect(mockRefreshSession).toHaveBeenCalledTimes(1);
|
||||
expect(credentials?.token).toBe('new-access-token-xyz');
|
||||
// Credentials are returned (Supabase handles auto-refresh in background)
|
||||
expect(credentials).not.toBeNull();
|
||||
expect(credentials?.token).toBe('almost-expired-token');
|
||||
expect(credentials?.refreshToken).toBe('valid-refresh-token');
|
||||
});
|
||||
|
||||
it('should not refresh token well before expiry', async () => {
|
||||
// Token expires in 5 minutes (well outside 30-second buffer)
|
||||
it('should return valid token well before expiry', () => {
|
||||
// Token expires in 5 minutes
|
||||
const validCredentials: AuthCredentials = {
|
||||
token: 'valid-token',
|
||||
refreshToken: 'valid-refresh-token',
|
||||
@@ -289,21 +269,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
|
||||
authManager = AuthManager.getInstance();
|
||||
|
||||
const mockRefreshSession = vi.fn();
|
||||
vi.spyOn(
|
||||
authManager['supabaseClient'],
|
||||
'refreshSession'
|
||||
).mockImplementation(mockRefreshSession);
|
||||
const credentials = authManager.getCredentials();
|
||||
|
||||
const credentials = await authManager.getCredentials();
|
||||
|
||||
expect(mockRefreshSession).not.toHaveBeenCalled();
|
||||
// Valid credentials are returned as-is
|
||||
expect(credentials).not.toBeNull();
|
||||
expect(credentials?.token).toBe('valid-token');
|
||||
expect(credentials?.refreshToken).toBe('valid-refresh-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Synchronous vs Async Methods', () => {
|
||||
it('getCredentialsSync should not trigger refresh', () => {
|
||||
it('getCredentials should return expired credentials', () => {
|
||||
const expiredCredentials: AuthCredentials = {
|
||||
token: 'expired-token',
|
||||
refreshToken: 'valid-refresh-token',
|
||||
@@ -317,40 +293,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
|
||||
authManager = AuthManager.getInstance();
|
||||
|
||||
// Synchronous call should return null without refresh
|
||||
const credentials = authManager.getCredentialsSync();
|
||||
|
||||
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();
|
||||
// Returns credentials even if expired - Supabase will handle refresh
|
||||
const credentials = authManager.getCredentials();
|
||||
|
||||
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', () => {
|
||||
it('should handle concurrent getCredentials calls gracefully', async () => {
|
||||
it('should handle concurrent getCredentials calls gracefully', () => {
|
||||
const expiredCredentials: AuthCredentials = {
|
||||
token: 'expired-token',
|
||||
refreshToken: 'valid-refresh-token',
|
||||
@@ -364,29 +317,20 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
||||
|
||||
authManager = AuthManager.getInstance();
|
||||
|
||||
const mockRefreshSession = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mockRefreshedSession);
|
||||
vi.spyOn(
|
||||
authManager['supabaseClient'],
|
||||
'refreshSession'
|
||||
).mockImplementation(mockRefreshSession);
|
||||
// Make multiple concurrent calls (synchronous now)
|
||||
const creds1 = authManager.getCredentials();
|
||||
const creds2 = authManager.getCredentials();
|
||||
const creds3 = authManager.getCredentials();
|
||||
|
||||
// Make multiple concurrent calls
|
||||
const [creds1, creds2, creds3] = await Promise.all([
|
||||
authManager.getCredentials(),
|
||||
authManager.getCredentials(),
|
||||
authManager.getCredentials()
|
||||
]);
|
||||
// All should get the same credentials (even if expired)
|
||||
expect(creds1?.token).toBe('expired-token');
|
||||
expect(creds2?.token).toBe('expired-token');
|
||||
expect(creds3?.token).toBe('expired-token');
|
||||
|
||||
// All should get the refreshed token
|
||||
expect(creds1?.token).toBe('new-access-token-xyz');
|
||||
expect(creds2?.token).toBe('new-access-token-xyz');
|
||||
expect(creds3?.token).toBe('new-access-token-xyz');
|
||||
|
||||
// Refresh might be called multiple times, but that's okay
|
||||
// (ideally we'd debounce, but this is acceptable behavior)
|
||||
expect(mockRefreshSession).toHaveBeenCalled();
|
||||
// All include refresh token for Supabase to use
|
||||
expect(creds1?.refreshToken).toBe('valid-refresh-token');
|
||||
expect(creds2?.refreshToken).toBe('valid-refresh-token');
|
||||
expect(creds3?.refreshToken).toBe('valid-refresh-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
*/
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load .env BEFORE any other imports to ensure env vars are available
|
||||
dotenv.config();
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
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
|
||||
runCLI(process.argv);
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
registerAllCommands,
|
||||
checkForUpdate,
|
||||
performAutoUpdate,
|
||||
displayUpgradeNotification
|
||||
displayUpgradeNotification,
|
||||
displayError
|
||||
} from '@tm/cli';
|
||||
|
||||
import {
|
||||
@@ -2441,57 +2442,6 @@ ${result.result}
|
||||
}
|
||||
});
|
||||
|
||||
// next command
|
||||
programInstance
|
||||
.command('next')
|
||||
.description(
|
||||
`Show the next task to work on based on dependencies and status${chalk.reset('')}`
|
||||
)
|
||||
.option(
|
||||
'-f, --file <file>',
|
||||
'Path to the tasks file',
|
||||
TASKMASTER_TASKS_FILE
|
||||
)
|
||||
.option(
|
||||
'-r, --report <report>',
|
||||
'Path to the complexity report file',
|
||||
COMPLEXITY_REPORT_FILE
|
||||
)
|
||||
.option('--tag <tag>', 'Specify tag context for task operations')
|
||||
.action(async (options) => {
|
||||
const initOptions = {
|
||||
tasksPath: options.file || true,
|
||||
tag: options.tag
|
||||
};
|
||||
|
||||
if (options.report && options.report !== COMPLEXITY_REPORT_FILE) {
|
||||
initOptions.complexityReportPath = options.report;
|
||||
}
|
||||
|
||||
// Initialize TaskMaster
|
||||
const taskMaster = initTaskMaster({
|
||||
tasksPath: options.file || true,
|
||||
tag: options.tag,
|
||||
complexityReportPath: options.report || false
|
||||
});
|
||||
|
||||
const tag = taskMaster.getCurrentTag();
|
||||
|
||||
const context = {
|
||||
projectRoot: taskMaster.getProjectRoot(),
|
||||
tag
|
||||
};
|
||||
|
||||
// Show current tag context
|
||||
displayCurrentTagIndicator(tag);
|
||||
|
||||
await displayNextTask(
|
||||
taskMaster.getTasksPath(),
|
||||
taskMaster.getComplexityReportPath(),
|
||||
context
|
||||
);
|
||||
});
|
||||
|
||||
// add-dependency command
|
||||
programInstance
|
||||
.command('add-dependency')
|
||||
@@ -5207,10 +5157,7 @@ async function runCLI(argv = process.argv) {
|
||||
);
|
||||
} else {
|
||||
// Generic error handling for other errors
|
||||
console.error(chalk.red(`Error: ${error.message}`));
|
||||
if (getDebugFlag()) {
|
||||
console.error(error);
|
||||
}
|
||||
displayError(error);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
|
||||
@@ -307,6 +307,20 @@ function validateProviderModelCombination(providerName, modelId) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of supported model IDs for a given provider from supported-models.json
|
||||
* @param {string} providerName - The name of the provider (e.g., 'claude-code', 'anthropic')
|
||||
* @returns {string[]} Array of supported model IDs, or empty array if provider not found
|
||||
*/
|
||||
export function getSupportedModelsForProvider(providerName) {
|
||||
if (!MODEL_MAP[providerName]) {
|
||||
return [];
|
||||
}
|
||||
return MODEL_MAP[providerName]
|
||||
.filter((model) => model.supported !== false)
|
||||
.map((model) => model.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Claude Code AI provider custom settings
|
||||
* @param {object} settings The settings to validate
|
||||
|
||||
@@ -43,6 +43,28 @@
|
||||
"allowed_roles": ["main", "fallback"],
|
||||
"max_tokens": 8192,
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4-5-20250929",
|
||||
"swe_score": 0.73,
|
||||
"cost_per_1m_tokens": {
|
||||
"input": 3.0,
|
||||
"output": 15.0
|
||||
},
|
||||
"allowed_roles": ["main", "fallback"],
|
||||
"max_tokens": 64000,
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"id": "claude-haiku-4-5-20251001",
|
||||
"swe_score": 0.45,
|
||||
"cost_per_1m_tokens": {
|
||||
"input": 1.0,
|
||||
"output": 5.0
|
||||
},
|
||||
"allowed_roles": ["main", "fallback"],
|
||||
"max_tokens": 200000,
|
||||
"supported": true
|
||||
}
|
||||
],
|
||||
"claude-code": [
|
||||
@@ -67,6 +89,17 @@
|
||||
"allowed_roles": ["main", "fallback", "research"],
|
||||
"max_tokens": 64000,
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"id": "haiku",
|
||||
"swe_score": 0.45,
|
||||
"cost_per_1m_tokens": {
|
||||
"input": 0,
|
||||
"output": 0
|
||||
},
|
||||
"allowed_roles": ["main", "fallback", "research"],
|
||||
"max_tokens": 200000,
|
||||
"supported": true
|
||||
}
|
||||
],
|
||||
"codex-cli": [
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
|
||||
import { createClaudeCode } from 'ai-sdk-provider-claude-code';
|
||||
import { BaseAIProvider } from './base-provider.js';
|
||||
import { getClaudeCodeSettingsForCommand } from '../../scripts/modules/config-manager.js';
|
||||
import {
|
||||
getClaudeCodeSettingsForCommand,
|
||||
getSupportedModelsForProvider
|
||||
} from '../../scripts/modules/config-manager.js';
|
||||
import { execSync } from 'child_process';
|
||||
import { log } from '../../scripts/modules/utils.js';
|
||||
|
||||
@@ -24,14 +27,24 @@ let _claudeCliAvailable = null;
|
||||
*
|
||||
* Features:
|
||||
* - No API key required (uses local Claude Code CLI)
|
||||
* - Supports 'sonnet' and 'opus' models
|
||||
* - Supported models loaded from supported-models.json
|
||||
* - Command-specific configuration support
|
||||
*/
|
||||
export class ClaudeCodeProvider extends BaseAIProvider {
|
||||
constructor() {
|
||||
super();
|
||||
this.name = 'Claude Code';
|
||||
this.supportedModels = ['sonnet', 'opus'];
|
||||
// Load supported models from supported-models.json
|
||||
this.supportedModels = getSupportedModelsForProvider('claude-code');
|
||||
|
||||
// Validate that models were loaded successfully
|
||||
if (this.supportedModels.length === 0) {
|
||||
log(
|
||||
'warn',
|
||||
'No supported models found for claude-code provider. Check supported-models.json configuration.'
|
||||
);
|
||||
}
|
||||
|
||||
// Claude Code requires explicit JSON schema mode
|
||||
this.needsExplicitJsonSchema = true;
|
||||
// Claude Code does not support temperature parameter
|
||||
|
||||
@@ -10,7 +10,10 @@ import { createCodexCli } from 'ai-sdk-provider-codex-cli';
|
||||
import { BaseAIProvider } from './base-provider.js';
|
||||
import { execSync } from 'child_process';
|
||||
import { log } from '../../scripts/modules/utils.js';
|
||||
import { getCodexCliSettingsForCommand } from '../../scripts/modules/config-manager.js';
|
||||
import {
|
||||
getCodexCliSettingsForCommand,
|
||||
getSupportedModelsForProvider
|
||||
} from '../../scripts/modules/config-manager.js';
|
||||
|
||||
export class CodexCliProvider extends BaseAIProvider {
|
||||
constructor() {
|
||||
@@ -20,8 +23,17 @@ export class CodexCliProvider extends BaseAIProvider {
|
||||
this.needsExplicitJsonSchema = false;
|
||||
// Codex CLI does not support temperature parameter
|
||||
this.supportsTemperature = false;
|
||||
// Restrict to supported models for OAuth subscription usage
|
||||
this.supportedModels = ['gpt-5', 'gpt-5-codex'];
|
||||
// Load supported models from supported-models.json
|
||||
this.supportedModels = getSupportedModelsForProvider('codex-cli');
|
||||
|
||||
// Validate that models were loaded successfully
|
||||
if (this.supportedModels.length === 0) {
|
||||
log(
|
||||
'warn',
|
||||
'No supported models found for codex-cli provider. Check supported-models.json configuration.'
|
||||
);
|
||||
}
|
||||
|
||||
// CLI availability check cache
|
||||
this._codexCliChecked = false;
|
||||
this._codexCliAvailable = null;
|
||||
|
||||
@@ -43,9 +43,9 @@ describe('Claude Code Error Handling', () => {
|
||||
|
||||
// These should work even if CLI is not available
|
||||
expect(provider.name).toBe('Claude Code');
|
||||
expect(provider.getSupportedModels()).toEqual(['sonnet', 'opus']);
|
||||
expect(provider.getSupportedModels()).toEqual(['opus', 'sonnet', 'haiku']);
|
||||
expect(provider.isModelSupported('sonnet')).toBe(true);
|
||||
expect(provider.isModelSupported('haiku')).toBe(false);
|
||||
expect(provider.isModelSupported('haiku')).toBe(true);
|
||||
expect(provider.isRequiredApiKey()).toBe(false);
|
||||
expect(() => provider.validateAuth()).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -40,14 +40,14 @@ describe('Claude Code Integration (Optional)', () => {
|
||||
it('should create a working provider instance', () => {
|
||||
const provider = new ClaudeCodeProvider();
|
||||
expect(provider.name).toBe('Claude Code');
|
||||
expect(provider.getSupportedModels()).toEqual(['sonnet', 'opus']);
|
||||
expect(provider.getSupportedModels()).toEqual(['opus', 'sonnet', 'haiku']);
|
||||
});
|
||||
|
||||
it('should support model validation', () => {
|
||||
const provider = new ClaudeCodeProvider();
|
||||
expect(provider.isModelSupported('sonnet')).toBe(true);
|
||||
expect(provider.isModelSupported('opus')).toBe(true);
|
||||
expect(provider.isModelSupported('haiku')).toBe(false);
|
||||
expect(provider.isModelSupported('haiku')).toBe(true);
|
||||
expect(provider.isModelSupported('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ jest.unstable_mockModule('../../../src/ai-providers/base-provider.js', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock config getters
|
||||
jest.unstable_mockModule('../../../scripts/modules/config-manager.js', () => ({
|
||||
getClaudeCodeSettingsForCommand: jest.fn(() => ({})),
|
||||
getSupportedModelsForProvider: jest.fn(() => ['opus', 'sonnet', 'haiku']),
|
||||
getDebugFlag: jest.fn(() => false),
|
||||
getLogLevel: jest.fn(() => 'info')
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
const { ClaudeCodeProvider } = await import(
|
||||
'../../../src/ai-providers/claude-code.js'
|
||||
@@ -96,13 +104,13 @@ describe('ClaudeCodeProvider', () => {
|
||||
describe('model support', () => {
|
||||
it('should return supported models', () => {
|
||||
const models = provider.getSupportedModels();
|
||||
expect(models).toEqual(['sonnet', 'opus']);
|
||||
expect(models).toEqual(['opus', 'sonnet', 'haiku']);
|
||||
});
|
||||
|
||||
it('should check if model is supported', () => {
|
||||
expect(provider.isModelSupported('sonnet')).toBe(true);
|
||||
expect(provider.isModelSupported('opus')).toBe(true);
|
||||
expect(provider.isModelSupported('haiku')).toBe(false);
|
||||
expect(provider.isModelSupported('haiku')).toBe(true);
|
||||
expect(provider.isModelSupported('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ jest.unstable_mockModule('ai-sdk-provider-codex-cli', () => ({
|
||||
// Mock config getters
|
||||
jest.unstable_mockModule('../../../scripts/modules/config-manager.js', () => ({
|
||||
getCodexCliSettingsForCommand: jest.fn(() => ({ allowNpx: true })),
|
||||
getSupportedModelsForProvider: jest.fn(() => ['gpt-5', 'gpt-5-codex']),
|
||||
// Provide commonly imported getters to satisfy other module imports if any
|
||||
getDebugFlag: jest.fn(() => false),
|
||||
getLogLevel: jest.fn(() => 'info')
|
||||
|
||||
Reference in New Issue
Block a user