Compare commits

...

2 Commits

Author SHA1 Message Date
Ralph Khreish
7265a6cf53 feat: implement export tasks (#1260) 2025-10-06 16:03:56 +02:00
Ralph Khreish
db6f405f23 feat: add api-storage improvements (#1278) 2025-10-06 15:23:48 +02:00
33 changed files with 1988 additions and 441 deletions

View File

@@ -0,0 +1,255 @@
/**
* @fileoverview Centralized Command Registry
* Provides a single location for registering all CLI commands
*/
import { Command } from 'commander';
// Import all commands
import { ListTasksCommand } from './commands/list.command.js';
import { ShowCommand } from './commands/show.command.js';
import { AuthCommand } from './commands/auth.command.js';
import { ContextCommand } from './commands/context.command.js';
import { StartCommand } from './commands/start.command.js';
import { SetStatusCommand } from './commands/set-status.command.js';
import { ExportCommand } from './commands/export.command.js';
/**
* Command metadata for registration
*/
export interface CommandMetadata {
name: string;
description: string;
commandClass: typeof Command;
category?: 'task' | 'auth' | 'utility' | 'development';
}
/**
* Registry of all available commands
*/
export class CommandRegistry {
/**
* All available commands with their metadata
*/
private static commands: CommandMetadata[] = [
// Task Management Commands
{
name: 'list',
description: 'List all tasks with filtering and status overview',
commandClass: ListTasksCommand as any,
category: 'task'
},
{
name: 'show',
description: 'Display detailed information about a specific task',
commandClass: ShowCommand as any,
category: 'task'
},
{
name: 'start',
description: 'Start working on a task with claude-code',
commandClass: StartCommand as any,
category: 'task'
},
{
name: 'set-status',
description: 'Update the status of one or more tasks',
commandClass: SetStatusCommand as any,
category: 'task'
},
{
name: 'export',
description: 'Export tasks to external systems',
commandClass: ExportCommand as any,
category: 'task'
},
// Authentication & Context Commands
{
name: 'auth',
description: 'Manage authentication with tryhamster.com',
commandClass: AuthCommand as any,
category: 'auth'
},
{
name: 'context',
description: 'Manage workspace context (organization/brief)',
commandClass: ContextCommand as any,
category: 'auth'
}
];
/**
* Register all commands on a program instance
* @param program - Commander program to register commands on
*/
static registerAll(program: Command): void {
for (const cmd of this.commands) {
this.registerCommand(program, cmd);
}
}
/**
* Register specific commands by category
* @param program - Commander program to register commands on
* @param category - Category of commands to register
*/
static registerByCategory(
program: Command,
category: 'task' | 'auth' | 'utility' | 'development'
): void {
const categoryCommands = this.commands.filter(
(cmd) => cmd.category === category
);
for (const cmd of categoryCommands) {
this.registerCommand(program, cmd);
}
}
/**
* Register a single command by name
* @param program - Commander program to register the command on
* @param name - Name of the command to register
*/
static registerByName(program: Command, name: string): void {
const cmd = this.commands.find((c) => c.name === name);
if (cmd) {
this.registerCommand(program, cmd);
} else {
throw new Error(`Command '${name}' not found in registry`);
}
}
/**
* Register a single command
* @param program - Commander program to register the command on
* @param metadata - Command metadata
*/
private static registerCommand(
program: Command,
metadata: CommandMetadata
): void {
const CommandClass = metadata.commandClass as any;
// Use the static registration method that all commands have
if (CommandClass.registerOn) {
CommandClass.registerOn(program);
} else if (CommandClass.register) {
CommandClass.register(program);
} else {
// Fallback to creating instance and adding
const instance = new CommandClass();
program.addCommand(instance);
}
}
/**
* Get all registered command names
*/
static getCommandNames(): string[] {
return this.commands.map((cmd) => cmd.name);
}
/**
* Get commands by category
*/
static getCommandsByCategory(
category: 'task' | 'auth' | 'utility' | 'development'
): CommandMetadata[] {
return this.commands.filter((cmd) => cmd.category === category);
}
/**
* Add a new command to the registry
* @param metadata - Command metadata to add
*/
static addCommand(metadata: CommandMetadata): void {
// Check if command already exists
if (this.commands.some((cmd) => cmd.name === metadata.name)) {
throw new Error(`Command '${metadata.name}' already exists in registry`);
}
this.commands.push(metadata);
}
/**
* Remove a command from the registry
* @param name - Name of the command to remove
*/
static removeCommand(name: string): boolean {
const index = this.commands.findIndex((cmd) => cmd.name === name);
if (index >= 0) {
this.commands.splice(index, 1);
return true;
}
return false;
}
/**
* Get command metadata by name
* @param name - Name of the command
*/
static getCommand(name: string): CommandMetadata | undefined {
return this.commands.find((cmd) => cmd.name === name);
}
/**
* Check if a command exists
* @param name - Name of the command
*/
static hasCommand(name: string): boolean {
return this.commands.some((cmd) => cmd.name === name);
}
/**
* Get a formatted list of all commands for display
*/
static getFormattedCommandList(): string {
const categories = {
task: 'Task Management',
auth: 'Authentication & Context',
utility: 'Utilities',
development: 'Development'
};
let output = '';
for (const [category, title] of Object.entries(categories)) {
const cmds = this.getCommandsByCategory(
category as keyof typeof categories
);
if (cmds.length > 0) {
output += `\n${title}:\n`;
for (const cmd of cmds) {
output += ` ${cmd.name.padEnd(20)} ${cmd.description}\n`;
}
}
}
return output;
}
}
/**
* Convenience function to register all CLI commands
* @param program - Commander program instance
*/
export function registerAllCommands(program: Command): void {
CommandRegistry.registerAll(program);
}
/**
* Convenience function to register commands by category
* @param program - Commander program instance
* @param category - Category to register
*/
export function registerCommandsByCategory(
program: Command,
category: 'task' | 'auth' | 'utility' | 'development'
): void {
CommandRegistry.registerByCategory(program, category);
}
// Export the registry for direct access if needed
export default CommandRegistry;

View File

@@ -493,18 +493,7 @@ export class AuthCommand extends Command {
} }
/** /**
* Static method to register this command on an existing program * Register this command on an existing program
* This is for gradual migration - allows commands.js to use this
*/
static registerOn(program: Command): Command {
const authCommand = new AuthCommand();
program.addCommand(authCommand);
return authCommand;
}
/**
* Alternative registration that returns the command for chaining
* Can also configure the command name if needed
*/ */
static register(program: Command, name?: string): AuthCommand { static register(program: Command, name?: string): AuthCommand {
const authCommand = new AuthCommand(name); const authCommand = new AuthCommand(name);

View File

@@ -694,16 +694,7 @@ export class ContextCommand extends Command {
} }
/** /**
* Static method to register this command on an existing program * Register this command on an existing program
*/
static registerOn(program: Command): Command {
const contextCommand = new ContextCommand();
program.addCommand(contextCommand);
return contextCommand;
}
/**
* Alternative registration that returns the command for chaining
*/ */
static register(program: Command, name?: string): ContextCommand { static register(program: Command, name?: string): ContextCommand {
const contextCommand = new ContextCommand(name); const contextCommand = new ContextCommand(name);

View File

@@ -0,0 +1,379 @@
/**
* @fileoverview Export command for exporting tasks to external systems
* Provides functionality to export tasks to Hamster briefs
*/
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 { TaskMasterCore, type ExportResult } from '@tm/core';
import * as ui from '../utils/ui.js';
/**
* Result type from export command
*/
export interface ExportCommandResult {
success: boolean;
action: 'export' | 'validate' | 'cancelled';
result?: ExportResult;
message?: string;
}
/**
* ExportCommand extending Commander's Command class
* Handles task export to external systems
*/
export class ExportCommand extends Command {
private authManager: AuthManager;
private taskMasterCore?: TaskMasterCore;
private lastResult?: ExportCommandResult;
constructor(name?: string) {
super(name || 'export');
// Initialize auth manager
this.authManager = AuthManager.getInstance();
// Configure the command
this.description('Export tasks to external systems (e.g., Hamster briefs)');
// Add options
this.option('--org <id>', 'Organization ID to export to');
this.option('--brief <id>', 'Brief ID to export tasks to');
this.option('--tag <tag>', 'Export tasks from a specific tag');
this.option(
'--status <status>',
'Filter tasks by status (pending, in-progress, done, etc.)'
);
this.option('--exclude-subtasks', 'Exclude subtasks from export');
this.option('-y, --yes', 'Skip confirmation prompt');
// Accept optional positional argument for brief ID or Hamster URL
this.argument('[briefOrUrl]', 'Brief ID or Hamster brief URL');
// Default action
this.action(async (briefOrUrl?: string, options?: any) => {
await this.executeExport(briefOrUrl, options);
});
}
/**
* Initialize the TaskMasterCore
*/
private async initializeServices(): Promise<void> {
if (this.taskMasterCore) {
return;
}
try {
// Initialize TaskMasterCore
this.taskMasterCore = await TaskMasterCore.create({
projectPath: process.cwd()
});
} catch (error) {
throw new Error(
`Failed to initialize services: ${(error as Error).message}`
);
}
}
/**
* Execute the export command
*/
private async executeExport(
briefOrUrl?: string,
options?: any
): Promise<void> {
let spinner: Ora | undefined;
try {
// Check authentication
if (!this.authManager.isAuthenticated()) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
process.exit(1);
}
// Initialize services
await this.initializeServices();
// Get current context
const context = this.authManager.getContext();
// Determine org and brief IDs
let orgId = options?.org || context?.orgId;
let briefId = options?.brief || briefOrUrl || context?.briefId;
// If a URL/ID was provided as argument, resolve it
if (briefOrUrl && !options?.brief) {
spinner = ora('Resolving brief...').start();
const resolvedBrief = await this.resolveBriefInput(briefOrUrl);
if (resolvedBrief) {
briefId = resolvedBrief.briefId;
orgId = resolvedBrief.orgId;
spinner.succeed('Brief resolved');
} else {
spinner.fail('Could not resolve brief');
process.exit(1);
}
}
// Validate we have necessary IDs
if (!orgId) {
ui.displayError(
'No organization selected. Run "tm context org" or use --org flag.'
);
process.exit(1);
}
if (!briefId) {
ui.displayError(
'No brief specified. Run "tm context brief", provide a brief ID/URL, or use --brief flag.'
);
process.exit(1);
}
// Confirm export if not auto-confirmed
if (!options?.yes) {
const confirmed = await this.confirmExport(orgId, briefId, context);
if (!confirmed) {
ui.displayWarning('Export cancelled');
this.lastResult = {
success: false,
action: 'cancelled',
message: 'User cancelled export'
};
process.exit(0);
}
}
// Perform export
spinner = ora('Exporting tasks...').start();
const exportResult = await this.taskMasterCore!.exportTasks({
orgId,
briefId,
tag: options?.tag,
status: options?.status,
excludeSubtasks: options?.excludeSubtasks || false
});
if (exportResult.success) {
spinner.succeed(
`Successfully exported ${exportResult.taskCount} task(s) to brief`
);
// Display summary
console.log(chalk.cyan('\n📤 Export Summary\n'));
console.log(chalk.white(` Organization: ${orgId}`));
console.log(chalk.white(` Brief: ${briefId}`));
console.log(chalk.white(` Tasks exported: ${exportResult.taskCount}`));
if (options?.tag) {
console.log(chalk.gray(` Tag: ${options.tag}`));
}
if (options?.status) {
console.log(chalk.gray(` Status filter: ${options.status}`));
}
if (exportResult.message) {
console.log(chalk.gray(`\n ${exportResult.message}`));
}
} else {
spinner.fail('Export failed');
if (exportResult.error) {
console.error(chalk.red(`\n✗ ${exportResult.error.message}`));
}
}
this.lastResult = {
success: exportResult.success,
action: 'export',
result: exportResult
};
} catch (error: any) {
if (spinner?.isSpinning) spinner.fail('Export failed');
this.handleError(error);
process.exit(1);
}
}
/**
* Resolve brief input to get brief and org IDs
*/
private async resolveBriefInput(
briefOrUrl: string
): Promise<{ briefId: string; orgId: string } | null> {
try {
// Extract brief ID from input
const briefId = this.extractBriefId(briefOrUrl);
if (!briefId) {
return null;
}
// Fetch brief to get organization
const brief = await this.authManager.getBrief(briefId);
if (!brief) {
ui.displayError('Brief not found or you do not have access');
return null;
}
return {
briefId: brief.id,
orgId: brief.accountId
};
} catch (error) {
console.error(chalk.red(`Failed to resolve brief: ${error}`));
return null;
}
}
/**
* Extract a brief ID from raw input (ID or URL)
*/
private extractBriefId(input: string): string | null {
const raw = input?.trim() ?? '';
if (!raw) return null;
const parseUrl = (s: string): URL | null => {
try {
return new URL(s);
} catch {}
try {
return new URL(`https://${s}`);
} catch {}
return null;
};
const fromParts = (path: string): string | null => {
const parts = path.split('/').filter(Boolean);
const briefsIdx = parts.lastIndexOf('briefs');
const candidate =
briefsIdx >= 0 && parts.length > briefsIdx + 1
? parts[briefsIdx + 1]
: parts[parts.length - 1];
return candidate?.trim() || null;
};
// Try URL parsing
const url = parseUrl(raw);
if (url) {
const qId = url.searchParams.get('id') || url.searchParams.get('briefId');
const candidate = (qId || fromParts(url.pathname)) ?? null;
if (candidate) {
if (this.isLikelyId(candidate) || candidate.length >= 8) {
return candidate;
}
}
}
// Check if it looks like a path
if (raw.includes('/')) {
const candidate = fromParts(raw);
if (candidate && (this.isLikelyId(candidate) || candidate.length >= 8)) {
return candidate;
}
}
// Return raw if it looks like an ID
return raw;
}
/**
* Check if a string looks like a brief ID
*/
private isLikelyId(value: string): boolean {
const uuidRegex =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
const slugRegex = /^[A-Za-z0-9_-]{16,}$/;
return (
uuidRegex.test(value) || ulidRegex.test(value) || slugRegex.test(value)
);
}
/**
* Confirm export with the user
*/
private async confirmExport(
orgId: string,
briefId: string,
context: UserContext | null
): Promise<boolean> {
console.log(chalk.cyan('\n📤 Export Tasks\n'));
// Show org name if available
if (context?.orgName) {
console.log(chalk.white(` Organization: ${context.orgName}`));
console.log(chalk.gray(` ID: ${orgId}`));
} else {
console.log(chalk.white(` Organization ID: ${orgId}`));
}
// Show brief info
if (context?.briefName) {
console.log(chalk.white(`\n Brief: ${context.briefName}`));
console.log(chalk.gray(` ID: ${briefId}`));
} else {
console.log(chalk.white(`\n Brief ID: ${briefId}`));
}
const { confirmed } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmed',
message: 'Do you want to proceed with export?',
default: true
}
]);
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)
*/
public getLastResult(): ExportCommandResult | undefined {
return this.lastResult;
}
/**
* Clean up resources
*/
async cleanup(): Promise<void> {
// No resources to clean up
}
/**
* Register this command on an existing program
*/
static register(program: Command, name?: string): ExportCommand {
const exportCommand = new ExportCommand(name);
program.addCommand(exportCommand);
return exportCommand;
}
}

View File

@@ -246,7 +246,7 @@ export class ListTasksCommand extends Command {
task.subtasks.forEach((subtask) => { task.subtasks.forEach((subtask) => {
const subIcon = STATUS_ICONS[subtask.status]; const subIcon = STATUS_ICONS[subtask.status];
console.log( console.log(
` ${chalk.gray(`${task.id}.${subtask.id}`)} ${subIcon} ${chalk.gray(subtask.title)}` ` ${chalk.gray(String(subtask.id))} ${subIcon} ${chalk.gray(subtask.title)}`
); );
}); });
} }
@@ -297,7 +297,7 @@ export class ListTasksCommand extends Command {
nextTask nextTask
); );
// Task table - no title, just show the table directly // Task table
console.log( console.log(
ui.createTaskTable(tasks, { ui.createTaskTable(tasks, {
showSubtasks: withSubtasks, showSubtasks: withSubtasks,
@@ -474,18 +474,7 @@ export class ListTasksCommand extends Command {
} }
/** /**
* Static method to register this command on an existing program * Register this command on an existing program
* This is for gradual migration - allows commands.js to use this
*/
static registerOn(program: Command): Command {
const listCommand = new ListTasksCommand();
program.addCommand(listCommand);
return listCommand;
}
/**
* Alternative registration that returns the command for chaining
* Can also configure the command name if needed
*/ */
static register(program: Command, name?: string): ListTasksCommand { static register(program: Command, name?: string): ListTasksCommand {
const listCommand = new ListTasksCommand(name); const listCommand = new ListTasksCommand(name);

View File

@@ -258,9 +258,6 @@ export class SetStatusCommand extends Command {
) )
); );
} }
// Show storage info
console.log(chalk.gray(`\nUsing ${result.storageType} storage`));
} }
/** /**
@@ -290,18 +287,7 @@ export class SetStatusCommand extends Command {
} }
/** /**
* Static method to register this command on an existing program * Register this command on an existing program
* This is for gradual migration - allows commands.js to use this
*/
static registerOn(program: Command): Command {
const setStatusCommand = new SetStatusCommand();
program.addCommand(setStatusCommand);
return setStatusCommand;
}
/**
* Alternative registration that returns the command for chaining
* Can also configure the command name if needed
*/ */
static register(program: Command, name?: string): SetStatusCommand { static register(program: Command, name?: string): SetStatusCommand {
const setStatusCommand = new SetStatusCommand(name); const setStatusCommand = new SetStatusCommand(name);

View File

@@ -322,18 +322,7 @@ export class ShowCommand extends Command {
} }
/** /**
* Static method to register this command on an existing program * Register this command on an existing program
* This is for gradual migration - allows commands.js to use this
*/
static registerOn(program: Command): Command {
const showCommand = new ShowCommand();
program.addCommand(showCommand);
return showCommand;
}
/**
* Alternative registration that returns the command for chaining
* Can also configure the command name if needed
*/ */
static register(program: Command, name?: string): ShowCommand { static register(program: Command, name?: string): ShowCommand {
const showCommand = new ShowCommand(name); const showCommand = new ShowCommand(name);

View File

@@ -493,16 +493,7 @@ export class StartCommand extends Command {
} }
/** /**
* Static method to register this command on an existing program * Register this command on an existing program
*/
static registerOn(program: Command): Command {
const startCommand = new StartCommand();
program.addCommand(startCommand);
return startCommand;
}
/**
* Alternative registration that returns the command for chaining
*/ */
static register(program: Command, name?: string): StartCommand { static register(program: Command, name?: string): StartCommand {
const startCommand = new StartCommand(name); const startCommand = new StartCommand(name);

View File

@@ -10,6 +10,15 @@ export { AuthCommand } from './commands/auth.command.js';
export { ContextCommand } from './commands/context.command.js'; export { ContextCommand } from './commands/context.command.js';
export { StartCommand } from './commands/start.command.js'; export { StartCommand } from './commands/start.command.js';
export { SetStatusCommand } from './commands/set-status.command.js'; export { SetStatusCommand } from './commands/set-status.command.js';
export { ExportCommand } from './commands/export.command.js';
// Command Registry
export {
CommandRegistry,
registerAllCommands,
registerCommandsByCategory,
type CommandMetadata
} from './command-registry.js';
// UI utilities (for other commands to use) // UI utilities (for other commands to use)
export * as ui from './utils/ui.js'; export * as ui from './utils/ui.js';

View File

@@ -192,8 +192,7 @@ export function displaySubtasks(
status: any; status: any;
description?: string; description?: string;
dependencies?: string[]; dependencies?: string[];
}>, }>
parentId: string | number
): void { ): void {
const terminalWidth = process.stdout.columns * 0.95 || 100; const terminalWidth = process.stdout.columns * 0.95 || 100;
// Display subtasks header // Display subtasks header
@@ -228,7 +227,7 @@ export function displaySubtasks(
}); });
subtasks.forEach((subtask) => { subtasks.forEach((subtask) => {
const subtaskId = `${parentId}.${subtask.id}`; const subtaskId = String(subtask.id);
// Format dependencies // Format dependencies
const deps = const deps =
@@ -329,7 +328,7 @@ export function displayTaskDetails(
console.log(chalk.gray(` No subtasks with status '${statusFilter}'`)); console.log(chalk.gray(` No subtasks with status '${statusFilter}'`));
} else if (filteredSubtasks.length > 0) { } else if (filteredSubtasks.length > 0) {
console.log(); // Empty line for spacing console.log(); // Empty line for spacing
displaySubtasks(filteredSubtasks, task.id); displaySubtasks(filteredSubtasks);
} }
} }

View File

@@ -286,12 +286,12 @@ export function createTaskTable(
// Adjust column widths to better match the original layout // Adjust column widths to better match the original layout
const baseColWidths = showComplexity const baseColWidths = showComplexity
? [ ? [
Math.floor(terminalWidth * 0.06), Math.floor(terminalWidth * 0.1),
Math.floor(terminalWidth * 0.4), Math.floor(terminalWidth * 0.4),
Math.floor(terminalWidth * 0.15), Math.floor(terminalWidth * 0.15),
Math.floor(terminalWidth * 0.12), Math.floor(terminalWidth * 0.1),
Math.floor(terminalWidth * 0.2), Math.floor(terminalWidth * 0.2),
Math.floor(terminalWidth * 0.12) Math.floor(terminalWidth * 0.1)
] // ID, Title, Status, Priority, Dependencies, Complexity ] // ID, Title, Status, Priority, Dependencies, Complexity
: [ : [
Math.floor(terminalWidth * 0.08), Math.floor(terminalWidth * 0.08),
@@ -377,7 +377,11 @@ export function createTaskTable(
} }
if (showComplexity) { if (showComplexity) {
subRow.push(chalk.gray('--')); const complexityDisplay =
typeof subtask.complexity === 'number'
? getComplexityWithColor(subtask.complexity)
: '--';
subRow.push(chalk.gray(complexityDisplay));
} }
table.push(subRow); table.push(subRow);

41
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "task-master-ai", "name": "task-master-ai",
"version": "0.27.3", "version": "0.28.0-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "task-master-ai", "name": "task-master-ai",
"version": "0.27.3", "version": "0.28.0-rc.1",
"license": "MIT WITH Commons-Clause", "license": "MIT WITH Commons-Clause",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -131,7 +131,7 @@
} }
}, },
"apps/extension": { "apps/extension": {
"version": "0.25.4", "version": "0.25.5-rc.0",
"dependencies": { "dependencies": {
"task-master-ai": "*" "task-master-ai": "*"
}, },
@@ -635,7 +635,6 @@
"apps/extension/node_modules/zod": { "apps/extension/node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -1830,7 +1829,6 @@
"version": "7.28.4", "version": "7.28.4",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@@ -2663,7 +2661,6 @@
"version": "6.3.1", "version": "6.3.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -4583,6 +4580,7 @@
"version": "0.23.2", "version": "0.23.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@@ -5172,6 +5170,7 @@
"version": "0.23.2", "version": "0.23.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@@ -5180,7 +5179,6 @@
"version": "3.25.76", "version": "3.25.76",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -5471,7 +5469,6 @@
"node_modules/@modelcontextprotocol/sdk/node_modules/zod": { "node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -5572,7 +5569,6 @@
"node_modules/@opentelemetry/api": { "node_modules/@opentelemetry/api": {
"version": "1.9.0", "version": "1.9.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
} }
@@ -8592,7 +8588,6 @@
"version": "19.1.8", "version": "19.1.8",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -8601,7 +8596,6 @@
"version": "19.1.6", "version": "19.1.6",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
@@ -9047,7 +9041,6 @@
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -9113,7 +9106,6 @@
"node_modules/ai": { "node_modules/ai": {
"version": "5.0.57", "version": "5.0.57",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@ai-sdk/gateway": "1.0.30", "@ai-sdk/gateway": "1.0.30",
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "2.0.0",
@@ -9333,7 +9325,6 @@
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.17.1", "version": "8.17.1",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -10339,7 +10330,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@@ -12203,8 +12193,7 @@
"node_modules/devtools-protocol": { "node_modules/devtools-protocol": {
"version": "0.0.1312386", "version": "0.0.1312386",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause"
"peer": true
}, },
"node_modules/dezalgo": { "node_modules/dezalgo": {
"version": "1.0.4", "version": "1.0.4",
@@ -12798,7 +12787,6 @@
"version": "0.25.10", "version": "0.25.10",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -13111,7 +13099,6 @@
"node_modules/express": { "node_modules/express": {
"version": "4.21.2", "version": "4.21.2",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -15465,7 +15452,6 @@
"version": "6.3.1", "version": "6.3.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.0", "@alcalzone/ansi-tokenize": "^0.2.0",
"ansi-escapes": "^7.0.0", "ansi-escapes": "^7.0.0",
@@ -16423,7 +16409,6 @@
"version": "29.7.0", "version": "29.7.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@@ -18041,7 +18026,6 @@
"version": "1.4.0", "version": "1.4.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 10.16.0" "node": ">= 10.16.0"
} }
@@ -18367,6 +18351,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -18591,6 +18576,7 @@
"version": "1.4.0", "version": "1.4.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
}, },
@@ -18721,7 +18707,6 @@
"node_modules/marked": { "node_modules/marked": {
"version": "15.0.12", "version": "15.0.12",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
}, },
@@ -21444,7 +21429,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -22827,7 +22811,6 @@
"integrity": "sha512-U+NPR0Bkg3wm61dteD2L4nAM1U9dtaqVrpDXwC36IKRHpEO/Ubpid4Nijpa2imPchcVNHfxVFwSSMJdwdGFUbg==", "integrity": "sha512-U+NPR0Bkg3wm61dteD2L4nAM1U9dtaqVrpDXwC36IKRHpEO/Ubpid4Nijpa2imPchcVNHfxVFwSSMJdwdGFUbg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.93.0", "@oxc-project/types": "=0.93.0",
"@rolldown/pluginutils": "1.0.0-beta.41", "@rolldown/pluginutils": "1.0.0-beta.41",
@@ -25256,7 +25239,6 @@
"version": "5.9.2", "version": "5.9.2",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -25373,7 +25355,6 @@
"version": "11.0.5", "version": "11.0.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/unist": "^3.0.0", "@types/unist": "^3.0.0",
"bail": "^2.0.0", "bail": "^2.0.0",
@@ -25816,7 +25797,6 @@
"version": "5.4.20", "version": "5.4.20",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -25929,6 +25909,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -26512,7 +26493,7 @@
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "1.10.2", "version": "1.10.2",
"dev": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -26655,7 +26636,6 @@
"node_modules/zod": { "node_modules/zod": {
"version": "4.1.11", "version": "4.1.11",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -27397,7 +27377,6 @@
"version": "3.2.4", "version": "3.2.4",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",

View File

@@ -53,7 +53,7 @@ export class TaskEntity implements Task {
// Normalize subtask IDs to strings // Normalize subtask IDs to strings
this.subtasks = (data.subtasks || []).map((subtask) => ({ this.subtasks = (data.subtasks || []).map((subtask) => ({
...subtask, ...subtask,
id: Number(subtask.id), // Keep subtask IDs as numbers per interface id: String(subtask.id),
parentId: String(subtask.parentId) parentId: String(subtask.parentId)
})); }));

View File

@@ -51,7 +51,8 @@ export const ERROR_CODES = {
INTERNAL_ERROR: 'INTERNAL_ERROR', INTERNAL_ERROR: 'INTERNAL_ERROR',
INVALID_INPUT: 'INVALID_INPUT', INVALID_INPUT: 'INVALID_INPUT',
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED', NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
UNKNOWN_ERROR: 'UNKNOWN_ERROR' UNKNOWN_ERROR: 'UNKNOWN_ERROR',
NOT_FOUND: 'NOT_FOUND'
} as const; } as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];

View File

@@ -11,7 +11,9 @@ export {
type ListTasksResult, type ListTasksResult,
type StartTaskOptions, type StartTaskOptions,
type StartTaskResult, type StartTaskResult,
type ConflictCheckResult type ConflictCheckResult,
type ExportTasksOptions,
type ExportResult
} from './task-master-core.js'; } from './task-master-core.js';
// Re-export types // Re-export types

View File

@@ -5,6 +5,16 @@
import type { Task, TaskMetadata, TaskStatus } from '../types/index.js'; import type { Task, TaskMetadata, TaskStatus } from '../types/index.js';
/**
* Options for loading tasks from storage
*/
export interface LoadTasksOptions {
/** Filter tasks by status */
status?: TaskStatus;
/** Exclude subtasks from loaded tasks (default: false) */
excludeSubtasks?: boolean;
}
/** /**
* Result type for updateTaskStatus operations * Result type for updateTaskStatus operations
*/ */
@@ -21,11 +31,12 @@ export interface UpdateStatusResult {
*/ */
export interface IStorage { export interface IStorage {
/** /**
* Load all tasks from storage, optionally filtered by tag * Load all tasks from storage, optionally filtered by tag and other criteria
* @param tag - Optional tag to filter tasks by * @param tag - Optional tag to filter tasks by
* @param options - Optional filtering options (status, excludeSubtasks)
* @returns Promise that resolves to an array of tasks * @returns Promise that resolves to an array of tasks
*/ */
loadTasks(tag?: string): Promise<Task[]>; loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]>;
/** /**
* Load a single task by ID * Load a single task by ID
@@ -205,7 +216,7 @@ export abstract class BaseStorage implements IStorage {
} }
// Abstract methods that must be implemented by concrete classes // Abstract methods that must be implemented by concrete classes
abstract loadTasks(tag?: string): Promise<Task[]>; abstract loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]>;
abstract loadTask(taskId: string, tag?: string): Promise<Task | null>; abstract loadTask(taskId: string, tag?: string): Promise<Task | null>;
abstract saveTasks(tasks: Task[], tag?: string): Promise<void>; abstract saveTasks(tasks: Task[], tag?: string): Promise<void>;
abstract appendTasks(tasks: Task[], tag?: string): Promise<void>; abstract appendTasks(tasks: Task[], tag?: string): Promise<void>;

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, vi } from 'vitest';
import { TaskMapper } from './TaskMapper.js';
import type { Tables } from '../types/database.types.js';
type TaskRow = Tables<'tasks'>;
describe('TaskMapper', () => {
describe('extractMetadataField', () => {
it('should extract string field from metadata', () => {
const taskRow: TaskRow = {
id: '123',
display_id: '1',
title: 'Test Task',
description: 'Test description',
status: 'todo',
priority: 'medium',
parent_task_id: null,
subtask_position: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: {
details: 'Some details',
testStrategy: 'Test with unit tests'
},
complexity: null,
assignee_id: null,
estimated_hours: null,
actual_hours: null,
due_date: null,
completed_at: null
};
const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map());
expect(task.details).toBe('Some details');
expect(task.testStrategy).toBe('Test with unit tests');
});
it('should use default value when metadata field is missing', () => {
const taskRow: TaskRow = {
id: '123',
display_id: '1',
title: 'Test Task',
description: 'Test description',
status: 'todo',
priority: 'medium',
parent_task_id: null,
subtask_position: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: {},
complexity: null,
assignee_id: null,
estimated_hours: null,
actual_hours: null,
due_date: null,
completed_at: null
};
const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map());
expect(task.details).toBe('');
expect(task.testStrategy).toBe('');
});
it('should use default value when metadata is null', () => {
const taskRow: TaskRow = {
id: '123',
display_id: '1',
title: 'Test Task',
description: 'Test description',
status: 'todo',
priority: 'medium',
parent_task_id: null,
subtask_position: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: null,
complexity: null,
assignee_id: null,
estimated_hours: null,
actual_hours: null,
due_date: null,
completed_at: null
};
const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map());
expect(task.details).toBe('');
expect(task.testStrategy).toBe('');
});
it('should use default value and warn when metadata field has wrong type', () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {});
const taskRow: TaskRow = {
id: '123',
display_id: '1',
title: 'Test Task',
description: 'Test description',
status: 'todo',
priority: 'medium',
parent_task_id: null,
subtask_position: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: {
details: 12345, // Wrong type: number instead of string
testStrategy: ['test1', 'test2'] // Wrong type: array instead of string
},
complexity: null,
assignee_id: null,
estimated_hours: null,
actual_hours: null,
due_date: null,
completed_at: null
};
const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map());
// Should use empty string defaults when type doesn't match
expect(task.details).toBe('');
expect(task.testStrategy).toBe('');
// Should have logged warnings
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Type mismatch in metadata field "details"')
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Type mismatch in metadata field "testStrategy"'
)
);
consoleWarnSpy.mockRestore();
});
});
describe('mapStatus', () => {
it('should map database status to internal status', () => {
expect(TaskMapper.mapStatus('todo')).toBe('pending');
expect(TaskMapper.mapStatus('in_progress')).toBe('in-progress');
expect(TaskMapper.mapStatus('done')).toBe('done');
});
});
});

View File

@@ -2,22 +2,32 @@ import { Task, Subtask } from '../types/index.js';
import { Database, Tables } from '../types/database.types.js'; import { Database, Tables } from '../types/database.types.js';
type TaskRow = Tables<'tasks'>; type TaskRow = Tables<'tasks'>;
type DependencyRow = Tables<'task_dependencies'>;
// Legacy type for backward compatibility
type DependencyRow = Tables<'task_dependencies'> & {
depends_on_task?: { display_id: string } | null;
depends_on_task_id?: string;
};
export class TaskMapper { export class TaskMapper {
/** /**
* Maps database tasks to internal Task format * Maps database tasks to internal Task format
* @param dbTasks - Array of tasks from database
* @param dependencies - Either a Map of task_id to display_ids or legacy array format
*/ */
static mapDatabaseTasksToTasks( static mapDatabaseTasksToTasks(
dbTasks: TaskRow[], dbTasks: TaskRow[],
dbDependencies: DependencyRow[] dependencies: Map<string, string[]> | DependencyRow[]
): Task[] { ): Task[] {
if (!dbTasks || dbTasks.length === 0) { if (!dbTasks || dbTasks.length === 0) {
return []; return [];
} }
// Group dependencies by task_id // Handle both Map and array formats for backward compatibility
const dependenciesByTaskId = this.groupDependenciesByTaskId(dbDependencies); const dependenciesByTaskId =
dependencies instanceof Map
? dependencies
: this.groupDependenciesByTaskId(dependencies);
// Separate parent tasks and subtasks // Separate parent tasks and subtasks
const parentTasks = dbTasks.filter((t) => !t.parent_task_id); const parentTasks = dbTasks.filter((t) => !t.parent_task_id);
@@ -43,21 +53,23 @@ export class TaskMapper {
): Task { ): Task {
// Map subtasks // Map subtasks
const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => ({ const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => ({
id: index + 1, // Use numeric ID for subtasks id: subtask.display_id || String(index + 1), // Use display_id if available (API storage), fallback to numeric (file storage)
parentId: dbTask.id, parentId: dbTask.id,
title: subtask.title, title: subtask.title,
description: subtask.description || '', description: subtask.description || '',
status: this.mapStatus(subtask.status), status: this.mapStatus(subtask.status),
priority: this.mapPriority(subtask.priority), priority: this.mapPriority(subtask.priority),
dependencies: dependenciesByTaskId.get(subtask.id) || [], dependencies: dependenciesByTaskId.get(subtask.id) || [],
details: (subtask.metadata as any)?.details || '', details: this.extractMetadataField(subtask.metadata, 'details', ''),
testStrategy: (subtask.metadata as any)?.testStrategy || '', testStrategy: this.extractMetadataField(
subtask.metadata,
'testStrategy',
''
),
createdAt: subtask.created_at, createdAt: subtask.created_at,
updatedAt: subtask.updated_at, updatedAt: subtask.updated_at,
assignee: subtask.assignee_id || undefined, assignee: subtask.assignee_id || undefined,
complexity: subtask.complexity complexity: subtask.complexity ?? undefined
? this.mapComplexityToInternal(subtask.complexity)
: undefined
})); }));
return { return {
@@ -67,22 +79,25 @@ export class TaskMapper {
status: this.mapStatus(dbTask.status), status: this.mapStatus(dbTask.status),
priority: this.mapPriority(dbTask.priority), priority: this.mapPriority(dbTask.priority),
dependencies: dependenciesByTaskId.get(dbTask.id) || [], dependencies: dependenciesByTaskId.get(dbTask.id) || [],
details: (dbTask.metadata as any)?.details || '', details: this.extractMetadataField(dbTask.metadata, 'details', ''),
testStrategy: (dbTask.metadata as any)?.testStrategy || '', testStrategy: this.extractMetadataField(
dbTask.metadata,
'testStrategy',
''
),
subtasks, subtasks,
createdAt: dbTask.created_at, createdAt: dbTask.created_at,
updatedAt: dbTask.updated_at, updatedAt: dbTask.updated_at,
assignee: dbTask.assignee_id || undefined, assignee: dbTask.assignee_id || undefined,
complexity: dbTask.complexity complexity: dbTask.complexity ?? undefined,
? this.mapComplexityToInternal(dbTask.complexity)
: undefined,
effort: dbTask.estimated_hours || undefined, effort: dbTask.estimated_hours || undefined,
actualEffort: dbTask.actual_hours || undefined actualEffort: dbTask.actual_hours || undefined
}; };
} }
/** /**
* Groups dependencies by task ID * Groups dependencies by task ID (legacy method for backward compatibility)
* @deprecated Use DependencyFetcher.fetchDependenciesWithDisplayIds instead
*/ */
private static groupDependenciesByTaskId( private static groupDependenciesByTaskId(
dependencies: DependencyRow[] dependencies: DependencyRow[]
@@ -92,7 +107,14 @@ export class TaskMapper {
if (dependencies) { if (dependencies) {
for (const dep of dependencies) { for (const dep of dependencies) {
const deps = dependenciesByTaskId.get(dep.task_id) || []; const deps = dependenciesByTaskId.get(dep.task_id) || [];
deps.push(dep.depends_on_task_id); // Handle both old format (UUID string) and new format (object with display_id)
const dependencyId =
typeof dep.depends_on_task === 'object'
? dep.depends_on_task?.display_id
: dep.depends_on_task_id;
if (dependencyId) {
deps.push(dependencyId);
}
dependenciesByTaskId.set(dep.task_id, deps); dependenciesByTaskId.set(dep.task_id, deps);
} }
} }
@@ -157,14 +179,38 @@ export class TaskMapper {
} }
/** /**
* Maps numeric complexity to descriptive complexity * Safely extracts a field from metadata JSON with runtime type validation
* @param metadata The metadata object (could be null or any type)
* @param field The field to extract
* @param defaultValue Default value if field doesn't exist
* @returns The extracted value if it matches the expected type, otherwise defaultValue
*/ */
private static mapComplexityToInternal( private static extractMetadataField<T>(
complexity: number metadata: unknown,
): Task['complexity'] { field: string,
if (complexity <= 2) return 'simple'; defaultValue: T
if (complexity <= 5) return 'moderate'; ): T {
if (complexity <= 8) return 'complex'; if (!metadata || typeof metadata !== 'object') {
return 'very-complex'; return defaultValue;
}
const value = (metadata as Record<string, unknown>)[field];
if (value === undefined) {
return defaultValue;
}
// Runtime type validation: ensure value matches the type of defaultValue
const expectedType = typeof defaultValue;
const actualType = typeof value;
if (expectedType !== actualType) {
console.warn(
`Type mismatch in metadata field "${field}": expected ${expectedType}, got ${actualType}. Using default value.`
);
return defaultValue;
}
return value as T;
} }
} }

View File

@@ -1,224 +0,0 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Task } from '../types/index.js';
import { Database } from '../types/database.types.js';
import { TaskMapper } from '../mappers/TaskMapper.js';
import { AuthManager } from '../auth/auth-manager.js';
import { z } from 'zod';
// Zod schema for task status validation
const TaskStatusSchema = z.enum([
'pending',
'in-progress',
'done',
'review',
'deferred',
'cancelled',
'blocked'
]);
// Zod schema for task updates
const TaskUpdateSchema = z
.object({
title: z.string().min(1).optional(),
description: z.string().optional(),
status: TaskStatusSchema.optional(),
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
details: z.string().optional(),
testStrategy: z.string().optional()
})
.partial();
export class SupabaseTaskRepository {
constructor(private supabase: SupabaseClient<Database>) {}
async getTasks(_projectId?: string): Promise<Task[]> {
// Get the current context to determine briefId
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
if (!context || !context.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief'
);
}
// Get all tasks for the brief using the exact query structure
const { data: tasks, error } = await this.supabase
.from('tasks')
.select(`
*,
document:document_id (
id,
document_name,
title,
description
)
`)
.eq('brief_id', context.briefId)
.order('position', { ascending: true })
.order('subtask_position', { ascending: true })
.order('created_at', { ascending: true });
if (error) {
throw new Error(`Failed to fetch tasks: ${error.message}`);
}
if (!tasks || tasks.length === 0) {
return [];
}
// Get all dependencies for these tasks
const taskIds = tasks.map((t: any) => t.id);
const { data: depsData, error: depsError } = await this.supabase
.from('task_dependencies')
.select('*')
.in('task_id', taskIds);
if (depsError) {
throw new Error(
`Failed to fetch task dependencies: ${depsError.message}`
);
}
// Use mapper to convert to internal format
return TaskMapper.mapDatabaseTasksToTasks(tasks, depsData || []);
}
async getTask(_projectId: string, taskId: string): Promise<Task | null> {
// Get the current context to determine briefId (projectId not used in Supabase context)
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
if (!context || !context.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief'
);
}
const { data, error } = await this.supabase
.from('tasks')
.select('*')
.eq('brief_id', context.briefId)
.eq('display_id', taskId.toUpperCase())
.single();
if (error) {
if (error.code === 'PGRST116') {
return null; // Not found
}
throw new Error(`Failed to fetch task: ${error.message}`);
}
// Get dependencies for this task
const { data: depsData } = await this.supabase
.from('task_dependencies')
.select('*')
.eq('task_id', taskId);
// Get subtasks if this is a parent task
const { data: subtasksData } = await this.supabase
.from('tasks')
.select('*')
.eq('parent_task_id', taskId)
.order('subtask_position', { ascending: true });
// Create dependency map
const dependenciesByTaskId = new Map<string, string[]>();
if (depsData) {
dependenciesByTaskId.set(
taskId,
depsData.map(
(d: Database['public']['Tables']['task_dependencies']['Row']) =>
d.depends_on_task_id
)
);
}
// Use mapper to convert single task
return TaskMapper.mapDatabaseTaskToTask(
data,
subtasksData || [],
dependenciesByTaskId
);
}
async updateTask(
projectId: string,
taskId: string,
updates: Partial<Task>
): Promise<Task> {
// Get the current context to determine briefId
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
if (!context || !context.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief'
);
}
// Validate updates using Zod schema
try {
TaskUpdateSchema.parse(updates);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessages = error.issues
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ');
throw new Error(`Invalid task update data: ${errorMessages}`);
}
throw error;
}
// Convert Task fields to database fields - only include fields that actually exist in the database
const dbUpdates: any = {};
if (updates.title !== undefined) dbUpdates.title = updates.title;
if (updates.description !== undefined)
dbUpdates.description = updates.description;
if (updates.status !== undefined)
dbUpdates.status = this.mapStatusToDatabase(updates.status);
if (updates.priority !== undefined) dbUpdates.priority = updates.priority;
// Skip fields that don't exist in database schema: details, testStrategy, etc.
// Update the task
const { error } = await this.supabase
.from('tasks')
.update(dbUpdates)
.eq('brief_id', context.briefId)
.eq('display_id', taskId.toUpperCase());
if (error) {
throw new Error(`Failed to update task: ${error.message}`);
}
// Return the updated task by fetching it
const updatedTask = await this.getTask(projectId, taskId);
if (!updatedTask) {
throw new Error(`Failed to retrieve updated task ${taskId}`);
}
return updatedTask;
}
/**
* Maps internal status to database status
*/
private mapStatusToDatabase(
status: string
): Database['public']['Enums']['task_status'] {
switch (status) {
case 'pending':
return 'todo';
case 'in-progress':
case 'in_progress': // Accept both formats
return 'in_progress';
case 'done':
return 'done';
default:
throw new Error(
`Invalid task status: ${status}. Valid statuses are: pending, in-progress, done`
);
}
}
}

View File

@@ -0,0 +1,68 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '../../types/database.types.js';
import { DependencyWithDisplayId } from '../../types/repository-types.js';
/**
* Handles fetching and processing of task dependencies with display_ids
*/
export class DependencyFetcher {
constructor(private supabase: SupabaseClient<Database>) {}
/**
* Fetches dependencies for given task IDs with display_ids joined
* @param taskIds Array of task IDs to fetch dependencies for
* @returns Map of task ID to array of dependency display_ids
*/
async fetchDependenciesWithDisplayIds(
taskIds: string[]
): Promise<Map<string, string[]>> {
if (!taskIds || taskIds.length === 0) {
return new Map();
}
const { data, error } = await this.supabase
.from('task_dependencies')
.select(`
task_id,
depends_on_task:tasks!task_dependencies_depends_on_task_id_fkey (
display_id
)
`)
.in('task_id', taskIds);
if (error) {
throw new Error(`Failed to fetch task dependencies: ${error.message}`);
}
return this.processDependencyData(data as DependencyWithDisplayId[]);
}
/**
* Processes raw dependency data into a map structure
*/
private processDependencyData(
dependencies: DependencyWithDisplayId[]
): Map<string, string[]> {
const dependenciesByTaskId = new Map<string, string[]>();
if (!dependencies) {
return dependenciesByTaskId;
}
for (const dep of dependencies) {
if (!dep.task_id) continue;
const currentDeps = dependenciesByTaskId.get(dep.task_id) || [];
// Extract display_id from the joined object
const displayId = dep.depends_on_task?.display_id;
if (displayId) {
currentDeps.push(displayId);
}
dependenciesByTaskId.set(dep.task_id, currentDeps);
}
return dependenciesByTaskId;
}
}

View File

@@ -0,0 +1,5 @@
/**
* Supabase repository implementations
*/
export { SupabaseTaskRepository } from './supabase-task-repository.js';
export { DependencyFetcher } from './dependency-fetcher.js';

View File

@@ -0,0 +1,275 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Task } from '../../types/index.js';
import { Database, Json } from '../../types/database.types.js';
import { TaskMapper } from '../../mappers/TaskMapper.js';
import { AuthManager } from '../../auth/auth-manager.js';
import { DependencyFetcher } from './dependency-fetcher.js';
import {
TaskWithRelations,
TaskDatabaseUpdate
} from '../../types/repository-types.js';
import { LoadTasksOptions } from '../../interfaces/storage.interface.js';
import { z } from 'zod';
// Zod schema for task status validation
const TaskStatusSchema = z.enum([
'pending',
'in-progress',
'done',
'review',
'deferred',
'cancelled',
'blocked'
]);
// Zod schema for task updates
const TaskUpdateSchema = z
.object({
title: z.string().min(1).optional(),
description: z.string().optional(),
status: TaskStatusSchema.optional(),
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
details: z.string().optional(),
testStrategy: z.string().optional()
})
.partial();
export class SupabaseTaskRepository {
private dependencyFetcher: DependencyFetcher;
private authManager: AuthManager;
constructor(private supabase: SupabaseClient<Database>) {
this.dependencyFetcher = new DependencyFetcher(supabase);
this.authManager = AuthManager.getInstance();
}
/**
* Gets the current brief ID from auth context
* @throws {Error} If no brief is selected
*/
private getBriefIdOrThrow(): string {
const context = this.authManager.getContext();
if (!context?.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief'
);
}
return context.briefId;
}
async getTasks(
_projectId?: string,
options?: LoadTasksOptions
): Promise<Task[]> {
const briefId = this.getBriefIdOrThrow();
// Build query with filters
let query = this.supabase
.from('tasks')
.select(`
*,
document:document_id (
id,
document_name,
title,
description
)
`)
.eq('brief_id', briefId);
// Apply status filter at database level if specified
if (options?.status) {
const dbStatus = this.mapStatusToDatabase(options.status);
query = query.eq('status', dbStatus);
}
// Apply subtask exclusion at database level if specified
if (options?.excludeSubtasks) {
// Only fetch parent tasks (where parent_task_id is null)
query = query.is('parent_task_id', null);
}
// Execute query with ordering
const { data: tasks, error } = await query
.order('position', { ascending: true })
.order('subtask_position', { ascending: true })
.order('created_at', { ascending: true });
if (error) {
throw new Error(`Failed to fetch tasks: ${error.message}`);
}
if (!tasks || tasks.length === 0) {
return [];
}
// Type-safe task ID extraction
const typedTasks = tasks as TaskWithRelations[];
const taskIds = typedTasks.map((t) => t.id);
const dependenciesMap =
await this.dependencyFetcher.fetchDependenciesWithDisplayIds(taskIds);
// Use mapper to convert to internal format
return TaskMapper.mapDatabaseTasksToTasks(tasks, dependenciesMap);
}
async getTask(_projectId: string, taskId: string): Promise<Task | null> {
const briefId = this.getBriefIdOrThrow();
const { data, error } = await this.supabase
.from('tasks')
.select('*')
.eq('brief_id', briefId)
.eq('display_id', taskId.toUpperCase())
.single();
if (error) {
if (error.code === 'PGRST116') {
return null; // Not found
}
throw new Error(`Failed to fetch task: ${error.message}`);
}
// Get subtasks if this is a parent task
const { data: subtasksData } = await this.supabase
.from('tasks')
.select('*')
.eq('parent_task_id', data.id)
.order('subtask_position', { ascending: true });
// Get all task IDs (parent + subtasks) to fetch dependencies
const allTaskIds = [data.id, ...(subtasksData?.map((st) => st.id) || [])];
// Fetch dependencies using the dedicated fetcher
const dependenciesByTaskId =
await this.dependencyFetcher.fetchDependenciesWithDisplayIds(allTaskIds);
// Use mapper to convert single task
return TaskMapper.mapDatabaseTaskToTask(
data,
subtasksData || [],
dependenciesByTaskId
);
}
async updateTask(
projectId: string,
taskId: string,
updates: Partial<Task>
): Promise<Task> {
const briefId = this.getBriefIdOrThrow();
// Validate updates using Zod schema
try {
TaskUpdateSchema.parse(updates);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessages = error.issues
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ');
throw new Error(`Invalid task update data: ${errorMessages}`);
}
throw error;
}
// Convert Task fields to database fields with proper typing
const dbUpdates: TaskDatabaseUpdate = {};
if (updates.title !== undefined) dbUpdates.title = updates.title;
if (updates.description !== undefined)
dbUpdates.description = updates.description;
if (updates.status !== undefined)
dbUpdates.status = this.mapStatusToDatabase(updates.status);
if (updates.priority !== undefined)
dbUpdates.priority = this.mapPriorityToDatabase(updates.priority);
// Handle metadata fields (details, testStrategy, etc.)
// Load existing metadata to preserve fields not being updated
const { data: existingMetadataRow, error: existingMetadataError } =
await this.supabase
.from('tasks')
.select('metadata')
.eq('brief_id', briefId)
.eq('display_id', taskId.toUpperCase())
.single();
if (existingMetadataError) {
throw new Error(
`Failed to load existing task metadata: ${existingMetadataError.message}`
);
}
const metadata: Record<string, unknown> = {
...((existingMetadataRow?.metadata as Record<string, unknown>) ?? {})
};
if (updates.details !== undefined) metadata.details = updates.details;
if (updates.testStrategy !== undefined)
metadata.testStrategy = updates.testStrategy;
if (Object.keys(metadata).length > 0) {
dbUpdates.metadata = metadata as Json;
}
// Update the task
const { error } = await this.supabase
.from('tasks')
.update(dbUpdates)
.eq('brief_id', briefId)
.eq('display_id', taskId.toUpperCase());
if (error) {
throw new Error(`Failed to update task: ${error.message}`);
}
// Return the updated task by fetching it
const updatedTask = await this.getTask(projectId, taskId);
if (!updatedTask) {
throw new Error(`Failed to retrieve updated task ${taskId}`);
}
return updatedTask;
}
/**
* Maps internal status to database status
*/
private mapStatusToDatabase(
status: string
): Database['public']['Enums']['task_status'] {
switch (status) {
case 'pending':
return 'todo';
case 'in-progress':
case 'in_progress': // Accept both formats
return 'in_progress';
case 'done':
return 'done';
default:
throw new Error(
`Invalid task status: ${status}. Valid statuses are: pending, in-progress, done`
);
}
}
/**
* Maps internal priority to database priority
* Task Master uses 'critical', database uses 'urgent'
*/
private mapPriorityToDatabase(
priority: string
): Database['public']['Enums']['task_priority'] {
switch (priority) {
case 'critical':
return 'urgent';
case 'low':
case 'medium':
case 'high':
return priority as Database['public']['Enums']['task_priority'];
default:
throw new Error(
`Invalid task priority: ${priority}. Valid priorities are: low, medium, high, critical`
);
}
}
}

View File

@@ -1,8 +1,9 @@
import { Task, TaskTag } from '../types/index.js'; import { Task, TaskTag } from '../types/index.js';
import { LoadTasksOptions } from '../interfaces/storage.interface.js';
export interface TaskRepository { export interface TaskRepository {
// Task operations // Task operations
getTasks(projectId: string): Promise<Task[]>; getTasks(projectId: string, options?: LoadTasksOptions): Promise<Task[]>;
getTask(projectId: string, taskId: string): Promise<Task | null>; getTask(projectId: string, taskId: string): Promise<Task | null>;
createTask(projectId: string, task: Omit<Task, 'id'>): Promise<Task>; createTask(projectId: string, task: Omit<Task, 'id'>): Promise<Task>;
updateTask( updateTask(

View File

@@ -0,0 +1,496 @@
/**
* @fileoverview Export Service
* Core service for exporting tasks to external systems (e.g., Hamster briefs)
*/
import type { Task, TaskStatus } from '../types/index.js';
import type { UserContext } from '../auth/types.js';
import { ConfigManager } from '../config/config-manager.js';
import { AuthManager } from '../auth/auth-manager.js';
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
import { FileStorage } from '../storage/file-storage/index.js';
// Type definitions for the bulk API response
interface TaskImportResult {
externalId?: string;
index: number;
success: boolean;
taskId?: string;
error?: string;
validationErrors?: string[];
}
interface BulkTasksResponse {
dryRun: boolean;
totalTasks: number;
successCount: number;
failedCount: number;
skippedCount: number;
results: TaskImportResult[];
summary: {
message: string;
duration: number;
};
}
/**
* Options for exporting tasks
*/
export interface ExportTasksOptions {
/** Optional tag to export tasks from (uses active tag if not provided) */
tag?: string;
/** Brief ID to export to */
briefId?: string;
/** Organization ID (required if briefId is provided) */
orgId?: string;
/** Filter by task status */
status?: TaskStatus;
/** Exclude subtasks from export (default: false, subtasks included by default) */
excludeSubtasks?: boolean;
}
/**
* Result of the export operation
*/
export interface ExportResult {
/** Whether the export was successful */
success: boolean;
/** Number of tasks exported */
taskCount: number;
/** The brief ID tasks were exported to */
briefId: string;
/** The organization ID */
orgId: string;
/** Optional message */
message?: string;
/** Error details if export failed */
error?: {
code: string;
message: string;
};
}
/**
* Brief information from API
*/
export interface Brief {
id: string;
accountId: string;
createdAt: string;
name?: string;
}
/**
* ExportService handles task export to external systems
*/
export class ExportService {
private configManager: ConfigManager;
private authManager: AuthManager;
constructor(configManager: ConfigManager, authManager: AuthManager) {
this.configManager = configManager;
this.authManager = authManager;
}
/**
* Export tasks to a brief
*/
async exportTasks(options: ExportTasksOptions): Promise<ExportResult> {
// Validate authentication
if (!this.authManager.isAuthenticated()) {
throw new TaskMasterError(
'Authentication required for export',
ERROR_CODES.AUTHENTICATION_ERROR
);
}
// Get current context
const context = this.authManager.getContext();
// Determine org and brief IDs
let orgId = options.orgId || context?.orgId;
let briefId = options.briefId || context?.briefId;
// Validate we have necessary IDs
if (!orgId) {
throw new TaskMasterError(
'Organization ID is required for export. Use "tm context org" to select one.',
ERROR_CODES.MISSING_CONFIGURATION
);
}
if (!briefId) {
throw new TaskMasterError(
'Brief ID is required for export. Use "tm context brief" or provide --brief flag.',
ERROR_CODES.MISSING_CONFIGURATION
);
}
// Get tasks from the specified or active tag
const activeTag = this.configManager.getActiveTag();
const tag = options.tag || activeTag;
// Always read tasks from local file storage for export
// (we're exporting local tasks to a remote brief)
const fileStorage = new FileStorage(this.configManager.getProjectRoot());
await fileStorage.initialize();
// Load tasks with filters applied at storage layer
const filteredTasks = await fileStorage.loadTasks(tag, {
status: options.status,
excludeSubtasks: options.excludeSubtasks
});
// Get total count (without filters) for comparison
const allTasks = await fileStorage.loadTasks(tag);
const taskListResult = {
tasks: filteredTasks,
total: allTasks.length,
filtered: filteredTasks.length,
tag,
storageType: 'file' as const
};
if (taskListResult.tasks.length === 0) {
return {
success: false,
taskCount: 0,
briefId,
orgId,
message: 'No tasks found to export',
error: {
code: 'NO_TASKS',
message: 'No tasks match the specified criteria'
}
};
}
try {
// Call the export API with the original tasks
// performExport will handle the transformation based on the method used
await this.performExport(orgId, briefId, taskListResult.tasks);
return {
success: true,
taskCount: taskListResult.tasks.length,
briefId,
orgId,
message: `Successfully exported ${taskListResult.tasks.length} task(s) to brief`
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
taskCount: 0,
briefId,
orgId,
error: {
code: 'EXPORT_FAILED',
message: errorMessage
}
};
}
}
/**
* Export tasks from a brief ID or URL
*/
async exportFromBriefInput(briefInput: string): Promise<ExportResult> {
// Extract brief ID from input
const briefId = this.extractBriefId(briefInput);
if (!briefId) {
throw new TaskMasterError(
'Invalid brief ID or URL provided',
ERROR_CODES.VALIDATION_ERROR
);
}
// Fetch brief to get organization
const brief = await this.authManager.getBrief(briefId);
if (!brief) {
throw new TaskMasterError(
'Brief not found or you do not have access',
ERROR_CODES.NOT_FOUND
);
}
// Export with the resolved org and brief
return this.exportTasks({
orgId: brief.accountId,
briefId: brief.id
});
}
/**
* Validate export context before prompting
*/
async validateContext(): Promise<{
hasOrg: boolean;
hasBrief: boolean;
context: UserContext | null;
}> {
const context = this.authManager.getContext();
return {
hasOrg: !!context?.orgId,
hasBrief: !!context?.briefId,
context
};
}
/**
* Transform tasks for API bulk import format (flat structure)
*/
private transformTasksForBulkImport(tasks: Task[]): any[] {
const flatTasks: any[] = [];
// Process each task and its subtasks
tasks.forEach((task) => {
// Add parent task
flatTasks.push({
externalId: String(task.id),
title: task.title,
description: this.enrichDescription(task),
status: this.mapStatusForAPI(task.status),
priority: task.priority || 'medium',
dependencies: task.dependencies?.map(String) || [],
details: task.details,
testStrategy: task.testStrategy,
complexity: task.complexity,
metadata: {
complexity: task.complexity,
originalId: task.id,
originalDescription: task.description,
originalDetails: task.details,
originalTestStrategy: task.testStrategy
}
});
// Add subtasks if they exist
if (task.subtasks && task.subtasks.length > 0) {
task.subtasks.forEach((subtask) => {
flatTasks.push({
externalId: `${task.id}.${subtask.id}`,
parentExternalId: String(task.id),
title: subtask.title,
description: this.enrichDescription(subtask),
status: this.mapStatusForAPI(subtask.status),
priority: subtask.priority || 'medium',
dependencies:
subtask.dependencies?.map((dep) => {
// Convert subtask dependencies to full ID format
if (String(dep).includes('.')) {
return String(dep);
}
return `${task.id}.${dep}`;
}) || [],
details: subtask.details,
testStrategy: subtask.testStrategy,
complexity: subtask.complexity,
metadata: {
complexity: subtask.complexity,
originalId: subtask.id,
originalDescription: subtask.description,
originalDetails: subtask.details,
originalTestStrategy: subtask.testStrategy
}
});
});
}
});
return flatTasks;
}
/**
* Enrich task/subtask description with implementation details and test strategy
* Creates a comprehensive markdown-formatted description
*/
private enrichDescription(taskOrSubtask: Task | any): string {
const sections: string[] = [];
// Start with original description if it exists
if (taskOrSubtask.description) {
sections.push(taskOrSubtask.description);
}
// Add implementation details section
if (taskOrSubtask.details) {
sections.push('## Implementation Details\n');
sections.push(taskOrSubtask.details);
}
// Add test strategy section
if (taskOrSubtask.testStrategy) {
sections.push('## Test Strategy\n');
sections.push(taskOrSubtask.testStrategy);
}
// Join sections with double newlines for better markdown formatting
return sections.join('\n\n').trim() || 'No description provided';
}
/**
* Map internal status to API status format
*/
private mapStatusForAPI(status?: string): string {
switch (status) {
case 'pending':
return 'todo';
case 'in-progress':
return 'in_progress';
case 'done':
return 'done';
default:
return 'todo';
}
}
/**
* Perform the actual export API call
*/
private async performExport(
orgId: string,
briefId: string,
tasks: any[]
): Promise<void> {
// Check if we should use the API endpoint or direct Supabase
const useAPIEndpoint = process.env.TM_PUBLIC_BASE_DOMAIN;
if (useAPIEndpoint) {
// Use the new bulk import API endpoint
const apiUrl = `${process.env.TM_PUBLIC_BASE_DOMAIN}/ai/api/v1/briefs/${briefId}/tasks/bulk`;
// Transform tasks to flat structure for API
const flatTasks = this.transformTasksForBulkImport(tasks);
// Prepare request body
const requestBody = {
source: 'task-master-cli',
accountId: orgId,
options: {
dryRun: false,
stopOnError: false
},
tasks: flatTasks
};
// Get auth token
const credentials = this.authManager.getCredentials();
if (!credentials || !credentials.token) {
throw new Error('Not authenticated');
}
// Make API request
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${credentials.token}`
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`API request failed: ${response.status} - ${errorText}`
);
}
const result = (await response.json()) as BulkTasksResponse;
if (result.failedCount > 0) {
const failedTasks = result.results
.filter((r) => !r.success)
.map((r) => `${r.externalId}: ${r.error}`)
.join(', ');
console.warn(
`Warning: ${result.failedCount} tasks failed to import: ${failedTasks}`
);
}
console.log(
`Successfully exported ${result.successCount} of ${result.totalTasks} tasks to brief ${briefId}`
);
} else {
// Direct Supabase approach is no longer supported
// The extractTasks method has been removed from SupabaseTaskRepository
// as we now exclusively use the API endpoint for exports
throw new Error(
'Export API endpoint not configured. Please set TM_PUBLIC_BASE_DOMAIN environment variable to enable task export.'
);
}
}
/**
* Extract a brief ID from raw input (ID or URL)
*/
private extractBriefId(input: string): string | null {
const raw = input?.trim() ?? '';
if (!raw) return null;
const parseUrl = (s: string): URL | null => {
try {
return new URL(s);
} catch {}
try {
return new URL(`https://${s}`);
} catch {}
return null;
};
const fromParts = (path: string): string | null => {
const parts = path.split('/').filter(Boolean);
const briefsIdx = parts.lastIndexOf('briefs');
const candidate =
briefsIdx >= 0 && parts.length > briefsIdx + 1
? parts[briefsIdx + 1]
: parts[parts.length - 1];
return candidate?.trim() || null;
};
// Try to parse as URL
const url = parseUrl(raw);
if (url) {
const qId = url.searchParams.get('id') || url.searchParams.get('briefId');
const candidate = (qId || fromParts(url.pathname)) ?? null;
if (candidate) {
if (this.isLikelyId(candidate) || candidate.length >= 8) {
return candidate;
}
}
}
// Check if it looks like a path without scheme
if (raw.includes('/')) {
const candidate = fromParts(raw);
if (candidate && (this.isLikelyId(candidate) || candidate.length >= 8)) {
return candidate;
}
}
// Return as-is if it looks like an ID
if (this.isLikelyId(raw) || raw.length >= 8) {
return raw;
}
return null;
}
/**
* Check if a string looks like a brief ID (UUID-like)
*/
private isLikelyId(value: string): boolean {
const uuidRegex =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
const slugRegex = /^[A-Za-z0-9_-]{16,}$/;
return (
uuidRegex.test(value) || ulidRegex.test(value) || slugRegex.test(value)
);
}
}

View File

@@ -5,4 +5,9 @@
export { TaskService } from './task-service.js'; export { TaskService } from './task-service.js';
export { OrganizationService } from './organization.service.js'; export { OrganizationService } from './organization.service.js';
export { ExportService } from './export.service.js';
export type { Organization, Brief } from './organization.service.js'; export type { Organization, Brief } from './organization.service.js';
export type {
ExportTasksOptions,
ExportResult
} from './export.service.js';

View File

@@ -14,6 +14,7 @@ import { ConfigManager } from '../config/config-manager.js';
import { StorageFactory } from '../storage/storage-factory.js'; import { StorageFactory } from '../storage/storage-factory.js';
import { TaskEntity } from '../entities/task.entity.js'; import { TaskEntity } from '../entities/task.entity.js';
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js'; import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
import { getLogger } from '../logger/factory.js';
/** /**
* Result returned by getTaskList * Result returned by getTaskList
@@ -51,6 +52,7 @@ export class TaskService {
private configManager: ConfigManager; private configManager: ConfigManager;
private storage: IStorage; private storage: IStorage;
private initialized = false; private initialized = false;
private logger = getLogger('TaskService');
constructor(configManager: ConfigManager) { constructor(configManager: ConfigManager) {
this.configManager = configManager; this.configManager = configManager;
@@ -90,37 +92,76 @@ export class TaskService {
const tag = options.tag || activeTag; const tag = options.tag || activeTag;
try { try {
// Load raw tasks from storage - storage only knows about tags // Determine if we can push filters to storage layer
const rawTasks = await this.storage.loadTasks(tag); const canPushStatusFilter =
options.filter?.status &&
!options.filter.priority &&
!options.filter.tags &&
!options.filter.assignee &&
!options.filter.search &&
options.filter.hasSubtasks === undefined;
// Build storage-level options
const storageOptions: any = {};
// Push status filter to storage if it's the only filter
if (canPushStatusFilter) {
const statuses = Array.isArray(options.filter!.status)
? options.filter!.status
: [options.filter!.status];
// Only push single status to storage (multiple statuses need in-memory filtering)
if (statuses.length === 1) {
storageOptions.status = statuses[0];
}
}
// Push subtask exclusion to storage
if (options.includeSubtasks === false) {
storageOptions.excludeSubtasks = true;
}
// Load tasks from storage with pushed-down filters
const rawTasks = await this.storage.loadTasks(tag, storageOptions);
// Get total count without status filters, but preserve subtask exclusion
const baseOptions: any = {};
if (options.includeSubtasks === false) {
baseOptions.excludeSubtasks = true;
}
const allTasks =
storageOptions.status !== undefined
? await this.storage.loadTasks(tag, baseOptions)
: rawTasks;
// Convert to TaskEntity for business logic operations // Convert to TaskEntity for business logic operations
const taskEntities = TaskEntity.fromArray(rawTasks); const taskEntities = TaskEntity.fromArray(rawTasks);
// Apply filters if provided // Apply remaining filters in-memory if needed
let filteredEntities = taskEntities; let filteredEntities = taskEntities;
if (options.filter) { if (options.filter && !canPushStatusFilter) {
filteredEntities = this.applyFilters(taskEntities, options.filter);
} else if (
options.filter?.status &&
Array.isArray(options.filter.status) &&
options.filter.status.length > 1
) {
// Multiple statuses - filter in-memory
filteredEntities = this.applyFilters(taskEntities, options.filter); filteredEntities = this.applyFilters(taskEntities, options.filter);
} }
// Convert back to plain objects // Convert back to plain objects
let tasks = filteredEntities.map((entity) => entity.toJSON()); const tasks = filteredEntities.map((entity) => entity.toJSON());
// Handle subtasks option
if (options.includeSubtasks === false) {
tasks = tasks.map((task) => ({
...task,
subtasks: []
}));
}
return { return {
tasks, tasks,
total: rawTasks.length, total: allTasks.length,
filtered: filteredEntities.length, filtered: filteredEntities.length,
tag: tag, // Return the actual tag being used (either explicitly provided or active tag) tag: tag, // Return the actual tag being used (either explicitly provided or active tag)
storageType: this.getStorageType() storageType: this.getStorageType()
}; };
} catch (error) { } catch (error) {
this.logger.error('Failed to get task list', error);
throw new TaskMasterError( throw new TaskMasterError(
'Failed to get task list', 'Failed to get task list',
ERROR_CODES.INTERNAL_ERROR, ERROR_CODES.INTERNAL_ERROR,

View File

@@ -6,7 +6,8 @@
import type { import type {
IStorage, IStorage,
StorageStats, StorageStats,
UpdateStatusResult UpdateStatusResult,
LoadTasksOptions
} from '../interfaces/storage.interface.js'; } from '../interfaces/storage.interface.js';
import type { import type {
Task, Task,
@@ -16,7 +17,7 @@ import type {
} from '../types/index.js'; } from '../types/index.js';
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js'; import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
import { TaskRepository } from '../repositories/task-repository.interface.js'; import { TaskRepository } from '../repositories/task-repository.interface.js';
import { SupabaseTaskRepository } from '../repositories/supabase-task-repository.js'; import { SupabaseTaskRepository } from '../repositories/supabase/index.js';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { AuthManager } from '../auth/auth-manager.js'; import { AuthManager } from '../auth/auth-manager.js';
@@ -146,7 +147,7 @@ export class ApiStorage implements IStorage {
* Load tasks from API * Load tasks from API
* In our system, the tag parameter represents a brief ID * In our system, the tag parameter represents a brief ID
*/ */
async loadTasks(tag?: string): Promise<Task[]> { async loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]> {
await this.ensureInitialized(); await this.ensureInitialized();
try { try {
@@ -160,9 +161,9 @@ export class ApiStorage implements IStorage {
); );
} }
// Load tasks from the current brief context // Load tasks from the current brief context with filters pushed to repository
const tasks = await this.retryOperation(() => const tasks = await this.retryOperation(() =>
this.repository.getTasks(this.projectId) this.repository.getTasks(this.projectId, options)
); );
// Update the tag cache with the loaded task IDs // Update the tag cache with the loaded task IDs

View File

@@ -6,7 +6,8 @@ import type { Task, TaskMetadata, TaskStatus } from '../../types/index.js';
import type { import type {
IStorage, IStorage,
StorageStats, StorageStats,
UpdateStatusResult UpdateStatusResult,
LoadTasksOptions
} from '../../interfaces/storage.interface.js'; } from '../../interfaces/storage.interface.js';
import { FormatHandler } from './format-handler.js'; import { FormatHandler } from './format-handler.js';
import { FileOperations } from './file-operations.js'; import { FileOperations } from './file-operations.js';
@@ -92,15 +93,30 @@ export class FileStorage implements IStorage {
* Load tasks from the single tasks.json file for a specific tag * Load tasks from the single tasks.json file for a specific tag
* Enriches tasks with complexity data from the complexity report * Enriches tasks with complexity data from the complexity report
*/ */
async loadTasks(tag?: string): Promise<Task[]> { async loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]> {
const filePath = this.pathResolver.getTasksPath(); const filePath = this.pathResolver.getTasksPath();
const resolvedTag = tag || 'master'; const resolvedTag = tag || 'master';
try { try {
const rawData = await this.fileOps.readJson(filePath); const rawData = await this.fileOps.readJson(filePath);
const tasks = this.formatHandler.extractTasks(rawData, resolvedTag); let tasks = this.formatHandler.extractTasks(rawData, resolvedTag);
// Apply filters if provided
if (options) {
// Filter by status if specified
if (options.status) {
tasks = tasks.filter((task) => task.status === options.status);
}
// Exclude subtasks if specified
if (options.excludeSubtasks) {
tasks = tasks.map((task) => ({
...task,
subtasks: []
}));
}
}
// Enrich tasks with complexity data
return await this.enrichTasksWithComplexity(tasks, resolvedTag); return await this.enrichTasksWithComplexity(tasks, resolvedTag);
} catch (error: any) { } catch (error: any) {
if (error.code === 'ENOENT') { if (error.code === 'ENOENT') {

View File

@@ -14,7 +14,14 @@ import {
type StartTaskResult, type StartTaskResult,
type ConflictCheckResult type ConflictCheckResult
} from './services/task-execution-service.js'; } from './services/task-execution-service.js';
import {
ExportService,
type ExportTasksOptions,
type ExportResult
} from './services/export.service.js';
import { AuthManager } from './auth/auth-manager.js';
import { ERROR_CODES, TaskMasterError } from './errors/task-master-error.js'; import { ERROR_CODES, TaskMasterError } from './errors/task-master-error.js';
import type { UserContext } from './auth/types.js';
import type { IConfiguration } from './interfaces/configuration.interface.js'; import type { IConfiguration } from './interfaces/configuration.interface.js';
import type { import type {
Task, Task,
@@ -47,6 +54,10 @@ export type {
StartTaskResult, StartTaskResult,
ConflictCheckResult ConflictCheckResult
} from './services/task-execution-service.js'; } from './services/task-execution-service.js';
export type {
ExportTasksOptions,
ExportResult
} from './services/export.service.js';
/** /**
* TaskMasterCore facade class * TaskMasterCore facade class
@@ -56,6 +67,7 @@ export class TaskMasterCore {
private configManager: ConfigManager; private configManager: ConfigManager;
private taskService: TaskService; private taskService: TaskService;
private taskExecutionService: TaskExecutionService; private taskExecutionService: TaskExecutionService;
private exportService: ExportService;
private executorService: ExecutorService | null = null; private executorService: ExecutorService | null = null;
/** /**
@@ -80,6 +92,7 @@ export class TaskMasterCore {
this.configManager = null as any; this.configManager = null as any;
this.taskService = null as any; this.taskService = null as any;
this.taskExecutionService = null as any; this.taskExecutionService = null as any;
this.exportService = null as any;
} }
/** /**
@@ -109,6 +122,10 @@ export class TaskMasterCore {
// Create task execution service // Create task execution service
this.taskExecutionService = new TaskExecutionService(this.taskService); this.taskExecutionService = new TaskExecutionService(this.taskService);
// Create export service
const authManager = AuthManager.getInstance();
this.exportService = new ExportService(this.configManager, authManager);
} catch (error) { } catch (error) {
throw new TaskMasterError( throw new TaskMasterError(
'Failed to initialize TaskMasterCore', 'Failed to initialize TaskMasterCore',
@@ -242,6 +259,33 @@ export class TaskMasterCore {
return this.taskExecutionService.getNextAvailableTask(); return this.taskExecutionService.getNextAvailableTask();
} }
// ==================== Export Service Methods ====================
/**
* Export tasks to an external system (e.g., Hamster brief)
*/
async exportTasks(options: ExportTasksOptions): Promise<ExportResult> {
return this.exportService.exportTasks(options);
}
/**
* Export tasks from a brief ID or URL
*/
async exportFromBriefInput(briefInput: string): Promise<ExportResult> {
return this.exportService.exportFromBriefInput(briefInput);
}
/**
* Validate export context before prompting
*/
async validateExportContext(): Promise<{
hasOrg: boolean;
hasBrief: boolean;
context: UserContext | null;
}> {
return this.exportService.validateContext();
}
// ==================== Executor Service Methods ==================== // ==================== Executor Service Methods ====================
/** /**

View File

@@ -82,10 +82,11 @@ export interface Task {
} }
/** /**
* Subtask interface extending Task with numeric ID * Subtask interface extending Task
* ID can be number (file storage) or string (API storage with display_id)
*/ */
export interface Subtask extends Omit<Task, 'id' | 'subtasks'> { export interface Subtask extends Omit<Task, 'id' | 'subtasks'> {
id: number; id: number | string;
parentId: string; parentId: string;
subtasks?: never; // Subtasks cannot have their own subtasks subtasks?: never; // Subtasks cannot have their own subtasks
} }

View File

@@ -0,0 +1,83 @@
/**
* Type definitions for repository operations
*/
import { Database, Tables } from './database.types.js';
/**
* Task row from database with optional joined relations
*/
export interface TaskWithRelations extends Tables<'tasks'> {
document?: {
id: string;
document_name: string;
title: string;
description: string | null;
} | null;
}
/**
* Dependency row with joined display_id
*/
export interface DependencyWithDisplayId {
task_id: string;
depends_on_task: {
display_id: string;
} | null;
}
/**
* Task metadata structure
*/
export interface TaskMetadata {
details?: string;
testStrategy?: string;
[key: string]: unknown; // Allow additional fields but be explicit
}
/**
* Database update payload for tasks
*/
export type TaskDatabaseUpdate =
Database['public']['Tables']['tasks']['Update'];
/**
* Configuration for task queries
*/
export interface TaskQueryConfig {
briefId: string;
includeSubtasks?: boolean;
includeDependencies?: boolean;
includeDocument?: boolean;
}
/**
* Result of a task fetch operation
*/
export interface TaskFetchResult {
task: Tables<'tasks'>;
subtasks: Tables<'tasks'>[];
dependencies: Map<string, string[]>;
}
/**
* Task validation errors
*/
export class TaskValidationError extends Error {
constructor(
message: string,
public readonly field: string,
public readonly value: unknown
) {
super(message);
this.name = 'TaskValidationError';
}
}
/**
* Context validation errors
*/
export class ContextValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ContextValidationError';
}
}

View File

@@ -12,17 +12,11 @@ import https from 'https';
import http from 'http'; import http from 'http';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import search from '@inquirer/search'; import search from '@inquirer/search';
import ora from 'ora'; // Import ora
import { log, readJSON } from './utils.js'; import { log, readJSON } from './utils.js';
// Import new commands from @tm/cli // Import command registry and utilities from @tm/cli
import { import {
ListTasksCommand, registerAllCommands,
ShowCommand,
AuthCommand,
ContextCommand,
StartCommand,
SetStatusCommand,
checkForUpdate, checkForUpdate,
performAutoUpdate, performAutoUpdate,
displayUpgradeNotification displayUpgradeNotification
@@ -32,7 +26,6 @@ import {
parsePRD, parsePRD,
updateTasks, updateTasks,
generateTaskFiles, generateTaskFiles,
listTasks,
expandTask, expandTask,
expandAllTasks, expandAllTasks,
clearSubtasks, clearSubtasks,
@@ -53,11 +46,7 @@ import {
validateStrength validateStrength
} from './task-manager.js'; } from './task-manager.js';
import { import { moveTasksBetweenTags } from './task-manager/move-task.js';
moveTasksBetweenTags,
MoveTaskError,
MOVE_ERROR_CODES
} from './task-manager/move-task.js';
import { import {
createTag, createTag,
@@ -72,9 +61,7 @@ import {
addDependency, addDependency,
removeDependency, removeDependency,
validateDependenciesCommand, validateDependenciesCommand,
fixDependenciesCommand, fixDependenciesCommand
DependencyError,
DEPENDENCY_ERROR_CODES
} from './dependency-manager.js'; } from './dependency-manager.js';
import { import {
@@ -103,7 +90,6 @@ import {
displayBanner, displayBanner,
displayHelp, displayHelp,
displayNextTask, displayNextTask,
displayTaskById,
displayComplexityReport, displayComplexityReport,
getStatusWithColor, getStatusWithColor,
confirmTaskOverwrite, confirmTaskOverwrite,
@@ -112,8 +98,6 @@ import {
displayModelConfiguration, displayModelConfiguration,
displayAvailableModels, displayAvailableModels,
displayApiKeyStatus, displayApiKeyStatus,
displayAiUsageSummary,
displayMultipleTasksSummary,
displayTaggedTasksFYI, displayTaggedTasksFYI,
displayCurrentTagIndicator, displayCurrentTagIndicator,
displayCrossTagDependencyError, displayCrossTagDependencyError,
@@ -137,10 +121,6 @@ import {
setModel, setModel,
getApiKeyStatusReport getApiKeyStatusReport
} from './task-manager/models.js'; } from './task-manager/models.js';
import {
isValidTaskStatus,
TASK_STATUS_OPTIONS
} from '../../src/constants/task-status.js';
import { import {
isValidRulesAction, isValidRulesAction,
RULES_ACTIONS, RULES_ACTIONS,
@@ -1687,29 +1667,12 @@ function registerCommands(programInstance) {
}); });
}); });
// Register the set-status command from @tm/cli // ========================================
// Handles task status updates with proper error handling and validation // Register All Commands from @tm/cli
SetStatusCommand.registerOn(programInstance); // ========================================
// Use the centralized command registry to register all CLI commands
// NEW: Register the new list command from @tm/cli // This replaces individual command registrations and reduces duplication
// This command handles all its own configuration and logic registerAllCommands(programInstance);
ListTasksCommand.registerOn(programInstance);
// Register the auth command from @tm/cli
// Handles authentication with tryhamster.com
AuthCommand.registerOn(programInstance);
// Register the context command from @tm/cli
// Manages workspace context (org/brief selection)
ContextCommand.registerOn(programInstance);
// Register the show command from @tm/cli
// Displays detailed information about tasks
ShowCommand.registerOn(programInstance);
// Register the start command from @tm/cli
// Starts working on a task by launching claude-code with a standardized prompt
StartCommand.registerOn(programInstance);
// expand command // expand command
programInstance programInstance

View File

@@ -1,6 +1,10 @@
import { defineConfig } from 'tsdown'; import { defineConfig } from 'tsdown';
import { baseConfig, mergeConfig } from '@tm/build-config'; import { baseConfig, mergeConfig } from '@tm/build-config';
import 'dotenv/config'; import { config } from 'dotenv';
import { resolve } from 'path';
// Load .env file explicitly with absolute path
config({ path: resolve(process.cwd(), '.env') });
// Get all TM_PUBLIC_* env variables for build-time injection // Get all TM_PUBLIC_* env variables for build-time injection
const getBuildTimeEnvs = () => { const getBuildTimeEnvs = () => {