mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat: add tm tags command to remote (#1386)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,8 @@
|
||||
"@tm/core": "*",
|
||||
"chalk": "5.6.2",
|
||||
"boxen": "^8.0.1",
|
||||
"ora": "^8.1.1"
|
||||
"ora": "^8.1.1",
|
||||
"cli-table3": "^0.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
|
||||
99
packages/tm-bridge/src/add-tag-bridge.ts
Normal file
99
packages/tm-bridge/src/add-tag-bridge.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ui } from '@tm/cli';
|
||||
import type { BaseBridgeParams } from './bridge-types.js';
|
||||
import { checkStorageType } from './bridge-utils.js';
|
||||
|
||||
/**
|
||||
* Parameters for the add-tag bridge function
|
||||
*/
|
||||
export interface AddTagBridgeParams extends BaseBridgeParams {
|
||||
/** Tag name to create */
|
||||
tagName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned when API storage redirects to web UI
|
||||
*/
|
||||
export interface RemoteAddTagResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared bridge function for add-tag command.
|
||||
* Checks if using API storage and redirects to web UI if so.
|
||||
*
|
||||
* For API storage, tags are called "briefs" and must be created
|
||||
* through the Hamster web interface.
|
||||
*
|
||||
* @param params - Bridge parameters
|
||||
* @returns Result object if API storage handled it, null if should fall through to file storage
|
||||
*/
|
||||
export async function tryAddTagViaRemote(
|
||||
params: AddTagBridgeParams
|
||||
): Promise<RemoteAddTagResult | null> {
|
||||
const {
|
||||
tagName,
|
||||
projectRoot,
|
||||
isMCP = false,
|
||||
outputFormat = 'text',
|
||||
report
|
||||
} = params;
|
||||
|
||||
// Check storage type using shared utility
|
||||
const { isApiStorage, tmCore } = await checkStorageType(
|
||||
projectRoot,
|
||||
report,
|
||||
'falling back to file-based tag creation'
|
||||
);
|
||||
|
||||
if (!isApiStorage || !tmCore) {
|
||||
// Not API storage - signal caller to fall through to file-based logic
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the brief creation URL from tmCore
|
||||
const redirectUrl = tmCore.auth.getBriefCreationUrl();
|
||||
|
||||
if (!redirectUrl) {
|
||||
report(
|
||||
'error',
|
||||
'Could not generate brief creation URL. Please ensure you have selected an organization using "tm context org"'
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Failed to generate brief creation URL. Please ensure an organization is selected.',
|
||||
redirectUrl: ''
|
||||
};
|
||||
}
|
||||
|
||||
// Show CLI output if not MCP
|
||||
if (!isMCP && outputFormat === 'text') {
|
||||
console.log(
|
||||
ui.displayCardBox({
|
||||
header: '# Create a Brief in Hamster Studio',
|
||||
body: [
|
||||
'Your tags are separate task lists. When connected to Hamster,\ntask lists are attached to briefs.',
|
||||
'Create a new brief and its task list will automatically be\navailable when generated.'
|
||||
],
|
||||
callToAction: {
|
||||
label: 'Visit:',
|
||||
action: redirectUrl
|
||||
},
|
||||
footer:
|
||||
'To access tasks for a specific brief, use:\n' +
|
||||
' • tm briefs select <brief-name>\n' +
|
||||
' • tm briefs select <brief-id>\n' +
|
||||
' • tm briefs select (interactive)'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Return success result with redirect URL
|
||||
return {
|
||||
success: true,
|
||||
message: `API storage detected. Please create tag "${tagName}" at: ${redirectUrl}`,
|
||||
redirectUrl
|
||||
};
|
||||
}
|
||||
46
packages/tm-bridge/src/bridge-types.ts
Normal file
46
packages/tm-bridge/src/bridge-types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared types and interfaces for bridge functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Log levels used by bridge report functions
|
||||
*/
|
||||
export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success';
|
||||
|
||||
/**
|
||||
* Report function signature used by all bridges
|
||||
*/
|
||||
export type ReportFunction = (level: LogLevel, ...args: unknown[]) => void;
|
||||
|
||||
/**
|
||||
* Output format for bridge results
|
||||
*/
|
||||
export type OutputFormat = 'text' | 'json';
|
||||
|
||||
/**
|
||||
* Common parameters shared by all bridge functions
|
||||
*/
|
||||
export interface BaseBridgeParams {
|
||||
/** Project root directory */
|
||||
projectRoot: string;
|
||||
/** Whether called from MCP context (default: false) */
|
||||
isMCP?: boolean;
|
||||
/** Output format (default: 'text') */
|
||||
outputFormat?: OutputFormat;
|
||||
/** Logging function */
|
||||
report: ReportFunction;
|
||||
/** Optional tag for task organization */
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from checking if API storage should handle an operation
|
||||
*/
|
||||
export interface StorageCheckResult {
|
||||
/** Whether API storage is being used */
|
||||
isApiStorage: boolean;
|
||||
/** TmCore instance if initialization succeeded */
|
||||
tmCore?: import('@tm/core').TmCore;
|
||||
/** Error message if initialization failed */
|
||||
error?: string;
|
||||
}
|
||||
69
packages/tm-bridge/src/bridge-utils.ts
Normal file
69
packages/tm-bridge/src/bridge-utils.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Shared utility functions for bridge operations
|
||||
*/
|
||||
|
||||
import { type TmCore, createTmCore } from '@tm/core';
|
||||
import type { ReportFunction, StorageCheckResult } from './bridge-types.js';
|
||||
|
||||
/**
|
||||
* Initialize TmCore and check if API storage is being used.
|
||||
*
|
||||
* This function encapsulates the common pattern used by all bridge functions:
|
||||
* 1. Try to create TmCore instance
|
||||
* 2. Check the storage type
|
||||
* 3. Return results or handle errors gracefully
|
||||
*
|
||||
* @param projectRoot - Project root directory
|
||||
* @param report - Logging function
|
||||
* @param fallbackMessage - Message to log if TmCore initialization fails
|
||||
* @returns Storage check result with TmCore instance if successful
|
||||
*
|
||||
* @example
|
||||
* const { isApiStorage, tmCore } = await checkStorageType(
|
||||
* projectRoot,
|
||||
* report,
|
||||
* 'falling back to file-based operation'
|
||||
* );
|
||||
*
|
||||
* if (!isApiStorage) {
|
||||
* // Continue with file-based logic
|
||||
* return null;
|
||||
* }
|
||||
*/
|
||||
export async function checkStorageType(
|
||||
projectRoot: string,
|
||||
report: ReportFunction,
|
||||
fallbackMessage = 'falling back to file-based operation'
|
||||
): Promise<StorageCheckResult> {
|
||||
let tmCore: TmCore;
|
||||
|
||||
try {
|
||||
tmCore = await createTmCore({
|
||||
projectPath: projectRoot || process.cwd()
|
||||
});
|
||||
} catch (tmCoreError) {
|
||||
const errorMessage =
|
||||
tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError);
|
||||
report('warn', `TmCore check failed, ${fallbackMessage}: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
isApiStorage: false,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we're using API storage (use resolved storage type, not config)
|
||||
const storageType = tmCore.tasks.getStorageType();
|
||||
|
||||
if (storageType !== 'api') {
|
||||
return {
|
||||
isApiStorage: false,
|
||||
tmCore
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isApiStorage: true,
|
||||
tmCore
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import chalk from 'chalk';
|
||||
import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { createTmCore, type TmCore } from '@tm/core';
|
||||
import type { BaseBridgeParams } from './bridge-types.js';
|
||||
import { checkStorageType } from './bridge-utils.js';
|
||||
|
||||
/**
|
||||
* Parameters for the expand bridge function
|
||||
*/
|
||||
export interface ExpandBridgeParams {
|
||||
export interface ExpandBridgeParams extends BaseBridgeParams {
|
||||
/** Task ID (can be numeric "1" or alphanumeric "TAS-49") */
|
||||
taskId: string | number;
|
||||
/** Number of subtasks to generate (optional) */
|
||||
@@ -17,16 +18,6 @@ export interface ExpandBridgeParams {
|
||||
additionalContext?: string;
|
||||
/** Force regeneration even if subtasks exist */
|
||||
force?: boolean;
|
||||
/** Project root directory */
|
||||
projectRoot: string;
|
||||
/** Optional tag for task organization */
|
||||
tag?: string;
|
||||
/** Whether called from MCP context (default: false) */
|
||||
isMCP?: boolean;
|
||||
/** Output format (default: 'text') */
|
||||
outputFormat?: 'text' | 'json';
|
||||
/** Logging function */
|
||||
report: (level: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,32 +54,15 @@ export async function tryExpandViaRemote(
|
||||
report
|
||||
} = params;
|
||||
|
||||
let tmCore: TmCore;
|
||||
// Check storage type using shared utility
|
||||
const { isApiStorage, tmCore } = await checkStorageType(
|
||||
projectRoot,
|
||||
report,
|
||||
'falling back to file-based expansion'
|
||||
);
|
||||
|
||||
try {
|
||||
tmCore = await createTmCore({
|
||||
projectPath: projectRoot || process.cwd()
|
||||
});
|
||||
} catch (tmCoreError) {
|
||||
const errorMessage =
|
||||
tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError);
|
||||
report(
|
||||
'warn',
|
||||
`TmCore check failed, falling back to file-based expansion: ${errorMessage}`
|
||||
);
|
||||
// Return null to signal fall-through to file storage logic
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we're using API storage (use resolved storage type, not config)
|
||||
const storageType = tmCore.tasks.getStorageType();
|
||||
|
||||
if (storageType !== 'api') {
|
||||
if (!isApiStorage || !tmCore) {
|
||||
// Not API storage - signal caller to fall through to file-based logic
|
||||
report(
|
||||
'debug',
|
||||
`Using file storage - processing expansion locally for task ${taskId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
* DELETE THIS PACKAGE when legacy scripts are removed.
|
||||
*/
|
||||
|
||||
// Shared types and utilities
|
||||
export type {
|
||||
LogLevel,
|
||||
ReportFunction,
|
||||
OutputFormat,
|
||||
BaseBridgeParams,
|
||||
StorageCheckResult
|
||||
} from './bridge-types.js';
|
||||
|
||||
export { checkStorageType } from './bridge-utils.js';
|
||||
|
||||
// Bridge functions
|
||||
export {
|
||||
tryUpdateViaRemote,
|
||||
type UpdateBridgeParams,
|
||||
@@ -20,3 +32,22 @@ export {
|
||||
type ExpandBridgeParams,
|
||||
type RemoteExpandResult
|
||||
} from './expand-bridge.js';
|
||||
|
||||
export {
|
||||
tryListTagsViaRemote,
|
||||
type TagsBridgeParams,
|
||||
type RemoteTagsResult,
|
||||
type TagInfo
|
||||
} from './tags-bridge.js';
|
||||
|
||||
export {
|
||||
tryUseTagViaRemote,
|
||||
type UseTagBridgeParams,
|
||||
type RemoteUseTagResult
|
||||
} from './use-tag-bridge.js';
|
||||
|
||||
export {
|
||||
tryAddTagViaRemote,
|
||||
type AddTagBridgeParams,
|
||||
type RemoteAddTagResult
|
||||
} from './add-tag-bridge.js';
|
||||
|
||||
160
packages/tm-bridge/src/tags-bridge.ts
Normal file
160
packages/tm-bridge/src/tags-bridge.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { ui } from '@tm/cli';
|
||||
import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
import type { TagInfo } from '@tm/core';
|
||||
import type { BaseBridgeParams } from './bridge-types.js';
|
||||
import { checkStorageType } from './bridge-utils.js';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { TagInfo };
|
||||
|
||||
/**
|
||||
* Parameters for the tags bridge function
|
||||
*/
|
||||
export interface TagsBridgeParams extends BaseBridgeParams {
|
||||
/** Whether to show metadata (default: false) */
|
||||
showMetadata?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned when API storage handles the tags listing
|
||||
*/
|
||||
export interface RemoteTagsResult {
|
||||
success: boolean;
|
||||
tags: TagInfo[];
|
||||
currentTag: string | null;
|
||||
totalTags: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared bridge function for list-tags command.
|
||||
* Checks if using API storage and delegates to remote service if so.
|
||||
*
|
||||
* For API storage, tags are called "briefs" and task counts are fetched
|
||||
* from the remote database.
|
||||
*
|
||||
* @param params - Bridge parameters
|
||||
* @returns Result object if API storage handled it, null if should fall through to file storage
|
||||
*/
|
||||
export async function tryListTagsViaRemote(
|
||||
params: TagsBridgeParams
|
||||
): Promise<RemoteTagsResult | null> {
|
||||
const { projectRoot, isMCP = false, outputFormat = 'text', report } = params;
|
||||
|
||||
// Check storage type using shared utility
|
||||
const { isApiStorage, tmCore } = await checkStorageType(
|
||||
projectRoot,
|
||||
report,
|
||||
'falling back to file-based tags'
|
||||
);
|
||||
|
||||
if (!isApiStorage || !tmCore) {
|
||||
// Not API storage - signal caller to fall through to file-based logic
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get tags with statistics from tm-core
|
||||
// Tags are already sorted by status and updatedAt from brief-service
|
||||
const tagsResult = await tmCore.tasks.getTagsWithStats();
|
||||
|
||||
// Sort tags: current tag first, then preserve status/updatedAt ordering from service
|
||||
tagsResult.tags.sort((a, b) => {
|
||||
// Always keep current tag at the top
|
||||
if (a.isCurrent) return -1;
|
||||
if (b.isCurrent) return 1;
|
||||
// For non-current tags, preserve the status/updatedAt ordering already applied
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (outputFormat === 'text' && !isMCP) {
|
||||
// Display results in a table format
|
||||
if (tagsResult.tags.length === 0) {
|
||||
console.log(
|
||||
boxen(chalk.yellow('No tags found'), {
|
||||
padding: 1,
|
||||
borderColor: 'yellow',
|
||||
borderStyle: 'round',
|
||||
margin: { top: 1, bottom: 1 }
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Create table headers (with temporary Updated column)
|
||||
const headers = [
|
||||
chalk.cyan.bold('Tag Name'),
|
||||
chalk.cyan.bold('Status'),
|
||||
chalk.cyan.bold('Updated'),
|
||||
chalk.cyan.bold('Tasks'),
|
||||
chalk.cyan.bold('Completed')
|
||||
];
|
||||
|
||||
// Calculate dynamic column widths based on terminal width
|
||||
const terminalWidth = Math.max(
|
||||
(process.stdout.columns as number) || 120,
|
||||
80
|
||||
);
|
||||
const usableWidth = Math.floor(terminalWidth * 0.95);
|
||||
|
||||
// Column order: Tag Name, Status, Updated, Tasks, Completed
|
||||
const widths = [0.35, 0.25, 0.2, 0.1, 0.1];
|
||||
const colWidths = widths.map((w, i) =>
|
||||
Math.max(Math.floor(usableWidth * w), i === 0 ? 20 : 8)
|
||||
);
|
||||
|
||||
const table = new Table({
|
||||
head: headers,
|
||||
colWidths: colWidths,
|
||||
wordWrap: true
|
||||
});
|
||||
|
||||
// Add rows
|
||||
tagsResult.tags.forEach((tag) => {
|
||||
const row = [];
|
||||
|
||||
// Tag name with current indicator and short ID (last 8 chars)
|
||||
const shortId = tag.briefId ? tag.briefId.slice(-8) : 'unknown';
|
||||
const tagDisplay = tag.isCurrent
|
||||
? `${chalk.green('●')} ${chalk.green.bold(tag.name)} ${chalk.gray(`(current - ${shortId})`)}`
|
||||
: ` ${tag.name} ${chalk.gray(`(${shortId})`)}`;
|
||||
row.push(tagDisplay);
|
||||
|
||||
row.push(ui.getBriefStatusWithColor(tag.status, true));
|
||||
|
||||
// Updated date (temporary for validation)
|
||||
const updatedDate = tag.updatedAt
|
||||
? new Date(tag.updatedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: chalk.gray('N/A');
|
||||
row.push(chalk.gray(updatedDate));
|
||||
|
||||
// Task counts
|
||||
row.push(chalk.white(tag.taskCount.toString()));
|
||||
row.push(chalk.green(tag.completedTasks.toString()));
|
||||
|
||||
table.push(row);
|
||||
});
|
||||
|
||||
console.log(table.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Return success result - signals that we handled it
|
||||
return {
|
||||
success: true,
|
||||
tags: tagsResult.tags,
|
||||
currentTag: tagsResult.currentTag,
|
||||
totalTags: tagsResult.totalTags,
|
||||
message: `Found ${tagsResult.totalTags} tag(s)`
|
||||
};
|
||||
} catch (error) {
|
||||
// tm-core already formatted the error properly, just re-throw
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,19 @@
|
||||
import chalk from 'chalk';
|
||||
import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { createTmCore, type TmCore } from '@tm/core';
|
||||
import type { BaseBridgeParams } from './bridge-types.js';
|
||||
import { checkStorageType } from './bridge-utils.js';
|
||||
|
||||
/**
|
||||
* Parameters for the update bridge function
|
||||
*/
|
||||
export interface UpdateBridgeParams {
|
||||
export interface UpdateBridgeParams extends BaseBridgeParams {
|
||||
/** Task ID (can be numeric "1", alphanumeric "TAS-49", or dotted "1.2" or "TAS-49.1") */
|
||||
taskId: string | number;
|
||||
/** Update prompt for AI */
|
||||
prompt: string;
|
||||
/** Project root directory */
|
||||
projectRoot: string;
|
||||
/** Optional tag for task organization */
|
||||
tag?: string;
|
||||
/** Whether to append or full update (default: false) */
|
||||
appendMode?: boolean;
|
||||
/** Whether called from MCP context (default: false) */
|
||||
isMCP?: boolean;
|
||||
/** Output format (default: 'text') */
|
||||
outputFormat?: 'text' | 'json';
|
||||
/** Logging function */
|
||||
report: (level: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,32 +52,15 @@ export async function tryUpdateViaRemote(
|
||||
report
|
||||
} = params;
|
||||
|
||||
let tmCore: TmCore;
|
||||
// Check storage type using shared utility
|
||||
const { isApiStorage, tmCore } = await checkStorageType(
|
||||
projectRoot,
|
||||
report,
|
||||
'falling back to file-based update'
|
||||
);
|
||||
|
||||
try {
|
||||
tmCore = await createTmCore({
|
||||
projectPath: projectRoot || process.cwd()
|
||||
});
|
||||
} catch (tmCoreError) {
|
||||
const errorMessage =
|
||||
tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError);
|
||||
report(
|
||||
'warn',
|
||||
`TmCore check failed, falling back to file-based update: ${errorMessage}`
|
||||
);
|
||||
// Return null to signal fall-through to file storage logic
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we're using API storage (use resolved storage type, not config)
|
||||
const storageType = tmCore.tasks.getStorageType();
|
||||
|
||||
if (storageType !== 'api') {
|
||||
if (!isApiStorage || !tmCore) {
|
||||
// Not API storage - signal caller to fall through to file-based logic
|
||||
report(
|
||||
'info',
|
||||
`Using file storage - processing update locally for task ${taskId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
145
packages/tm-bridge/src/use-tag-bridge.ts
Normal file
145
packages/tm-bridge/src/use-tag-bridge.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import type { BaseBridgeParams } from './bridge-types.js';
|
||||
import { checkStorageType } from './bridge-utils.js';
|
||||
|
||||
/**
|
||||
* Parameters for the use-tag bridge function
|
||||
*/
|
||||
export interface UseTagBridgeParams extends BaseBridgeParams {
|
||||
/** Tag name to switch to */
|
||||
tagName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned when API storage handles the tag switch
|
||||
*/
|
||||
export interface RemoteUseTagResult {
|
||||
success: boolean;
|
||||
previousTag: string | null;
|
||||
currentTag: string;
|
||||
switched: boolean;
|
||||
taskCount: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared bridge function for use-tag command.
|
||||
* Checks if using API storage and delegates to remote service if so.
|
||||
*
|
||||
* For API storage, tags are called "briefs" and switching tags means
|
||||
* changing the current brief context.
|
||||
*
|
||||
* @param params - Bridge parameters
|
||||
* @returns Result object if API storage handled it, null if should fall through to file storage
|
||||
*/
|
||||
export async function tryUseTagViaRemote(
|
||||
params: UseTagBridgeParams
|
||||
): Promise<RemoteUseTagResult | null> {
|
||||
const {
|
||||
tagName,
|
||||
projectRoot,
|
||||
isMCP = false,
|
||||
outputFormat = 'text',
|
||||
report
|
||||
} = params;
|
||||
|
||||
// Check storage type using shared utility
|
||||
const { isApiStorage, tmCore } = await checkStorageType(
|
||||
projectRoot,
|
||||
report,
|
||||
'falling back to file-based tag switching'
|
||||
);
|
||||
|
||||
if (!isApiStorage || !tmCore) {
|
||||
// Not API storage - signal caller to fall through to file-based logic
|
||||
return null;
|
||||
}
|
||||
|
||||
// API STORAGE PATH: Switch brief in Hamster
|
||||
report('info', `Switching to tag (brief) "${tagName}" in Hamster`);
|
||||
|
||||
// Show CLI output if not MCP
|
||||
if (!isMCP && outputFormat === 'text') {
|
||||
console.log(
|
||||
boxen(chalk.blue.bold(`Switching Tag in Hamster`), {
|
||||
padding: 1,
|
||||
borderColor: 'blue',
|
||||
borderStyle: 'round',
|
||||
margin: { top: 1, bottom: 1 }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const spinner =
|
||||
!isMCP && outputFormat === 'text'
|
||||
? ora({ text: `Switching to tag "${tagName}"...`, color: 'cyan' }).start()
|
||||
: null;
|
||||
|
||||
try {
|
||||
// Get current context before switching
|
||||
const previousContext = tmCore.auth.getContext();
|
||||
const previousTag = previousContext?.briefName || null;
|
||||
|
||||
// Switch to the new tag/brief
|
||||
// This will look up the brief by name and update the context
|
||||
await tmCore.tasks.switchTag(tagName);
|
||||
|
||||
// Get updated context after switching
|
||||
const newContext = tmCore.auth.getContext();
|
||||
const currentTag = newContext?.briefName || tagName;
|
||||
|
||||
// Get task count for the new tag
|
||||
const tasks = await tmCore.tasks.list();
|
||||
const taskCount = tasks.tasks.length;
|
||||
|
||||
if (spinner) {
|
||||
spinner.succeed(`Switched to tag "${currentTag}"`);
|
||||
}
|
||||
|
||||
if (outputFormat === 'text' && !isMCP) {
|
||||
// Display success message
|
||||
const briefId = newContext?.briefId
|
||||
? newContext.briefId.slice(-8)
|
||||
: 'unknown';
|
||||
console.log(
|
||||
boxen(
|
||||
chalk.green.bold('✓ Tag Switched Successfully') +
|
||||
'\n\n' +
|
||||
(previousTag
|
||||
? chalk.white(`Previous Tag: ${chalk.cyan(previousTag)}\n`)
|
||||
: '') +
|
||||
chalk.white(`Current Tag: ${chalk.green.bold(currentTag)}`) +
|
||||
'\n' +
|
||||
chalk.gray(`Brief ID: ${briefId}`) +
|
||||
'\n' +
|
||||
chalk.white(`Available Tasks: ${chalk.yellow(taskCount)}`),
|
||||
{
|
||||
padding: 1,
|
||||
borderColor: 'green',
|
||||
borderStyle: 'round',
|
||||
margin: { top: 1, bottom: 0 }
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Return success result - signals that we handled it
|
||||
return {
|
||||
success: true,
|
||||
previousTag,
|
||||
currentTag,
|
||||
switched: true,
|
||||
taskCount,
|
||||
message: `Successfully switched to tag "${currentTag}"`
|
||||
};
|
||||
} catch (error) {
|
||||
if (spinner) {
|
||||
spinner.fail('Failed to switch tag');
|
||||
}
|
||||
|
||||
// tm-core already formatted the error properly, just re-throw
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,25 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
TaskStatus,
|
||||
TaskComplexity,
|
||||
TaskPriority,
|
||||
TaskComplexity
|
||||
TaskStatus
|
||||
} from '../types/index.js';
|
||||
|
||||
// Import from root package.json (monorepo root) for version info
|
||||
import packageJson from '../../../../../package.json' with { type: 'json' };
|
||||
|
||||
/**
|
||||
* Task Master version from root package.json
|
||||
* Centralized to avoid fragile relative paths throughout the codebase
|
||||
*/
|
||||
export const TASKMASTER_VERSION = packageJson.version || 'unknown';
|
||||
|
||||
/**
|
||||
* Package name from root package.json
|
||||
*/
|
||||
export const PACKAGE_NAME = packageJson.name || 'task-master-ai';
|
||||
|
||||
/**
|
||||
* Valid task status values
|
||||
*/
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
StorageType,
|
||||
TaskComplexity,
|
||||
TaskPriority,
|
||||
StorageType
|
||||
TaskPriority
|
||||
} from '../types/index.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* This file defines the contract for all storage implementations
|
||||
*/
|
||||
|
||||
import type { Task, TaskMetadata, TaskStatus } from '../types/index.js';
|
||||
import type { ExpandTaskResult } from '../../modules/integration/services/task-expansion.service.js';
|
||||
import type { Task, TaskMetadata, TaskStatus } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Options for loading tasks from storage
|
||||
@@ -164,6 +164,19 @@ export interface IStorage {
|
||||
*/
|
||||
getAllTags(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Create a new tag
|
||||
* @param tagName - Name of the tag to create
|
||||
* @param options - Creation options
|
||||
* @param options.copyFrom - Tag to copy tasks from (optional)
|
||||
* @param options.description - Tag description (optional)
|
||||
* @returns Promise that resolves when creation is complete
|
||||
*/
|
||||
createTag(
|
||||
tagName: string,
|
||||
options?: { copyFrom?: string; description?: string }
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete all tasks and metadata for a specific tag
|
||||
* @param tag - Tag to delete
|
||||
@@ -216,6 +229,57 @@ export interface IStorage {
|
||||
* @returns The brief name if using API storage with a selected brief, null otherwise
|
||||
*/
|
||||
getCurrentBriefName(): string | null;
|
||||
|
||||
/**
|
||||
* Get all tags with detailed statistics including task counts
|
||||
* @returns Promise that resolves to tags with statistics
|
||||
*/
|
||||
getTagsWithStats(): Promise<TagsWithStatsResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag information with detailed statistics
|
||||
*/
|
||||
export interface TagInfo {
|
||||
/** Tag/Brief name */
|
||||
name: string;
|
||||
/** Whether this is the current/active tag */
|
||||
isCurrent: boolean;
|
||||
/** Total number of tasks in this tag */
|
||||
taskCount: number;
|
||||
/** Number of completed tasks */
|
||||
completedTasks: number;
|
||||
/** Breakdown of tasks by status */
|
||||
statusBreakdown: Record<string, number>;
|
||||
/** Subtask counts if available */
|
||||
subtaskCounts?: {
|
||||
totalSubtasks: number;
|
||||
subtasksByStatus: Record<string, number>;
|
||||
};
|
||||
/** Tag creation date */
|
||||
created?: string;
|
||||
|
||||
/** Tag last modified date */
|
||||
updatedAt?: string;
|
||||
|
||||
/** Tag description */
|
||||
description?: string;
|
||||
/** Brief/Tag status (for API storage briefs) */
|
||||
status?: string;
|
||||
/** Brief ID/UUID (for API storage) */
|
||||
briefId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by getTagsWithStats
|
||||
*/
|
||||
export interface TagsWithStatsResult {
|
||||
/** List of tags with statistics */
|
||||
tags: TagInfo[];
|
||||
/** Current active tag name */
|
||||
currentTag: string | null;
|
||||
/** Total number of tags */
|
||||
totalTags: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,6 +367,10 @@ export abstract class BaseStorage implements IStorage {
|
||||
abstract loadMetadata(tag?: string): Promise<TaskMetadata | null>;
|
||||
abstract saveMetadata(metadata: TaskMetadata, tag?: string): Promise<void>;
|
||||
abstract getAllTags(): Promise<string[]>;
|
||||
abstract createTag(
|
||||
tagName: string,
|
||||
options?: { copyFrom?: string; description?: string }
|
||||
): Promise<void>;
|
||||
abstract deleteTag(tag: string): Promise<void>;
|
||||
abstract renameTag(oldTag: string, newTag: string): Promise<void>;
|
||||
abstract copyTag(sourceTag: string, targetTag: string): Promise<void>;
|
||||
@@ -311,6 +379,7 @@ export abstract class BaseStorage implements IStorage {
|
||||
abstract getStats(): Promise<StorageStats>;
|
||||
abstract getStorageType(): 'file' | 'api';
|
||||
abstract getCurrentBriefName(): string | null;
|
||||
abstract getTagsWithStats(): Promise<TagsWithStatsResult>;
|
||||
/**
|
||||
* Utility method to generate backup filename
|
||||
* @param originalPath - Original file path
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @fileoverview Tests for MCP logging integration
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { Logger, LogLevel, type LogCallback } from './logger.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type LogCallback, LogLevel, Logger } from './logger.js';
|
||||
|
||||
describe('Logger - MCP Integration', () => {
|
||||
// Store original environment
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TaskMapper } from './TaskMapper.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Tables } from '../types/database.types.js';
|
||||
import { TaskMapper } from './TaskMapper.js';
|
||||
|
||||
type TaskRow = Tables<'tasks'>;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Task, Subtask } from '../types/index.js';
|
||||
import { Database, Tables } from '../types/database.types.js';
|
||||
import { Subtask, Task } from '../types/index.js';
|
||||
|
||||
type TaskRow = Tables<'tasks'>;
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface TaskMetadata {
|
||||
projectName?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,6 +53,9 @@ export {
|
||||
normalizeProjectRoot
|
||||
} from './project-root-finder.js';
|
||||
|
||||
// Export path construction utilities
|
||||
export { getProjectPaths } from './path-helpers.js';
|
||||
|
||||
// Additional utility exports
|
||||
|
||||
/**
|
||||
|
||||
40
packages/tm-core/src/common/utils/path-helpers.ts
Normal file
40
packages/tm-core/src/common/utils/path-helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @fileoverview Path construction utilities for Task Master
|
||||
* Provides standardized paths for Task Master project structure
|
||||
*/
|
||||
|
||||
import { join, resolve } from 'node:path';
|
||||
import { TASKMASTER_TASKS_FILE } from '../constants/paths.js';
|
||||
import { findProjectRoot } from './project-root-finder.js';
|
||||
|
||||
/**
|
||||
* Get standard Task Master project paths
|
||||
* Automatically detects project root using smart detection
|
||||
*
|
||||
* @param projectPath - Optional explicit project path (if not provided, auto-detects)
|
||||
* @returns Object with projectRoot and tasksPath
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Auto-detect project root
|
||||
* const { projectRoot, tasksPath } = getProjectPaths();
|
||||
*
|
||||
* // Or specify explicit path
|
||||
* const { projectRoot, tasksPath } = getProjectPaths('./my-project');
|
||||
* // projectRoot: '/absolute/path/to/my-project'
|
||||
* // tasksPath: '/absolute/path/to/my-project/.taskmaster/tasks/tasks.json'
|
||||
* ```
|
||||
*/
|
||||
export function getProjectPaths(projectPath?: string): {
|
||||
projectRoot: string;
|
||||
tasksPath: string;
|
||||
} {
|
||||
const projectRoot = projectPath
|
||||
? resolve(process.cwd(), projectPath)
|
||||
: findProjectRoot();
|
||||
|
||||
return {
|
||||
projectRoot,
|
||||
tasksPath: join(projectRoot, TASKMASTER_TASKS_FILE)
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizeProjectPath,
|
||||
denormalizeProjectPath,
|
||||
isValidNormalizedPath
|
||||
isValidNormalizedPath,
|
||||
normalizeProjectPath
|
||||
} from './path-normalizer.js';
|
||||
|
||||
describe('Path Normalizer (base64url encoding)', () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
compareRunIds,
|
||||
generateRunId,
|
||||
isValidRunId,
|
||||
parseRunId,
|
||||
compareRunIds
|
||||
parseRunId
|
||||
} from './run-id-generator.js';
|
||||
|
||||
describe('Run ID Generator', () => {
|
||||
|
||||
@@ -36,6 +36,12 @@ export type * from './common/types/index.js';
|
||||
// Common interfaces
|
||||
export type * from './common/interfaces/index.js';
|
||||
|
||||
// Storage interfaces - TagInfo and TagsWithStatsResult
|
||||
export type {
|
||||
TagInfo,
|
||||
TagsWithStatsResult
|
||||
} from './common/interfaces/storage.interface.js';
|
||||
|
||||
// Constants
|
||||
export * from './common/constants/index.js';
|
||||
|
||||
@@ -75,6 +81,10 @@ export type {
|
||||
} from './modules/auth/types.js';
|
||||
export { AuthenticationError } from './modules/auth/types.js';
|
||||
|
||||
// Brief types
|
||||
export type { Brief } from './modules/briefs/types.js';
|
||||
export type { TagWithStats } from './modules/briefs/services/brief-service.js';
|
||||
|
||||
// Workflow types
|
||||
export type {
|
||||
StartWorkflowOptions,
|
||||
@@ -112,6 +122,10 @@ export type {
|
||||
// Auth - Advanced
|
||||
export { AuthManager } from './modules/auth/managers/auth-manager.js';
|
||||
|
||||
// Briefs - Advanced
|
||||
export { BriefsDomain } from './modules/briefs/briefs-domain.js';
|
||||
export { BriefService } from './modules/briefs/services/brief-service.js';
|
||||
|
||||
// Workflow - Advanced
|
||||
export { WorkflowOrchestrator } from './modules/workflow/orchestrators/workflow-orchestrator.js';
|
||||
export { WorkflowStateManager } from './modules/workflow/managers/workflow-state-manager.js';
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import type {
|
||||
AIModel,
|
||||
AIOptions,
|
||||
AIResponse,
|
||||
IAIProvider,
|
||||
ProviderUsageStats,
|
||||
ProviderInfo,
|
||||
AIModel
|
||||
ProviderUsageStats
|
||||
} from '../interfaces/ai-provider.interface.js';
|
||||
|
||||
// Constants for retry logic
|
||||
|
||||
111
packages/tm-core/src/modules/auth/auth-domain.spec.ts
Normal file
111
packages/tm-core/src/modules/auth/auth-domain.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Auth Domain tests
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { AuthDomain } from './auth-domain.js';
|
||||
|
||||
describe('AuthDomain', () => {
|
||||
let authDomain: AuthDomain;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original environment
|
||||
originalEnv = { ...process.env };
|
||||
authDomain = new AuthDomain();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getBriefCreationUrl', () => {
|
||||
it('should return null if no base domain is configured', () => {
|
||||
// Clear environment variables
|
||||
delete process.env.TM_BASE_DOMAIN;
|
||||
delete process.env.TM_PUBLIC_BASE_DOMAIN;
|
||||
|
||||
// Create fresh instance with cleared env
|
||||
const domain = new AuthDomain();
|
||||
const url = domain.getBriefCreationUrl();
|
||||
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if org slug is not available in context', () => {
|
||||
// Set base domain but context will have no orgSlug
|
||||
process.env.TM_BASE_DOMAIN = 'localhost:8080';
|
||||
|
||||
const domain = new AuthDomain();
|
||||
// Mock getContext to return null (no context set)
|
||||
vi.spyOn(domain, 'getContext').mockReturnValue(null);
|
||||
|
||||
const url = domain.getBriefCreationUrl();
|
||||
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('should construct URL with http protocol for localhost', () => {
|
||||
process.env.TM_BASE_DOMAIN = 'localhost:8080';
|
||||
|
||||
// Mock getContext to return a context with orgSlug
|
||||
const domain = new AuthDomain();
|
||||
vi.spyOn(domain, 'getContext').mockReturnValue({
|
||||
orgSlug: 'test-org',
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
const url = domain.getBriefCreationUrl();
|
||||
|
||||
expect(url).toBe('http://localhost:8080/home/test-org/briefs/create');
|
||||
});
|
||||
|
||||
it('should construct URL with https protocol for production domain', () => {
|
||||
process.env.TM_BASE_DOMAIN = 'tryhamster.com';
|
||||
|
||||
const domain = new AuthDomain();
|
||||
vi.spyOn(domain, 'getContext').mockReturnValue({
|
||||
orgSlug: 'acme-corp',
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
const url = domain.getBriefCreationUrl();
|
||||
|
||||
expect(url).toBe('https://tryhamster.com/home/acme-corp/briefs/create');
|
||||
});
|
||||
|
||||
it('should use existing protocol if base domain includes it', () => {
|
||||
process.env.TM_BASE_DOMAIN = 'https://staging.hamster.dev';
|
||||
|
||||
const domain = new AuthDomain();
|
||||
vi.spyOn(domain, 'getContext').mockReturnValue({
|
||||
orgSlug: 'staging-org',
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
const url = domain.getBriefCreationUrl();
|
||||
|
||||
expect(url).toBe(
|
||||
'https://staging.hamster.dev/home/staging-org/briefs/create'
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer TM_BASE_DOMAIN over TM_PUBLIC_BASE_DOMAIN', () => {
|
||||
process.env.TM_BASE_DOMAIN = 'localhost:8080';
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN = 'tryhamster.com';
|
||||
|
||||
const domain = new AuthDomain();
|
||||
vi.spyOn(domain, 'getContext').mockReturnValue({
|
||||
orgSlug: 'my-org',
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
const url = domain.getBriefCreationUrl();
|
||||
|
||||
// Should use TM_BASE_DOMAIN (localhost), not TM_PUBLIC_BASE_DOMAIN
|
||||
expect(url).toBe('http://localhost:8080/home/my-org/briefs/create');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,18 +4,18 @@
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import type { StorageType } from '../../common/types/index.js';
|
||||
import type { Brief } from '../briefs/types.js';
|
||||
import { AuthManager } from './managers/auth-manager.js';
|
||||
import type {
|
||||
Organization,
|
||||
RemoteTask
|
||||
} from './services/organization.service.js';
|
||||
import type {
|
||||
AuthCredentials,
|
||||
OAuthFlowOptions,
|
||||
UserContext
|
||||
} from './types.js';
|
||||
import type {
|
||||
Organization,
|
||||
Brief,
|
||||
RemoteTask
|
||||
} from './services/organization.service.js';
|
||||
import type { StorageType } from '../../common/types/index.js';
|
||||
|
||||
/**
|
||||
* Display information for storage context
|
||||
@@ -210,6 +210,21 @@ export class AuthDomain {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for creating a new brief in the web UI
|
||||
* Returns null if not using API storage or if org slug is not available
|
||||
*/
|
||||
getBriefCreationUrl(): string | null {
|
||||
const context = this.getContext();
|
||||
const baseUrl = this.getWebAppUrl();
|
||||
|
||||
if (!baseUrl || !context?.orgSlug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${baseUrl}/home/${context.orgSlug}/briefs/create`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get web app base URL from environment configuration
|
||||
* @private
|
||||
|
||||
@@ -6,34 +6,54 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import { AuthConfig } from './types.js';
|
||||
|
||||
// Single base domain for all URLs
|
||||
// Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
|
||||
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option
|
||||
// Runtime: process.env.TM_BASE_DOMAIN can override for staging/development
|
||||
// Default: https://tryhamster.com for production
|
||||
const BASE_DOMAIN =
|
||||
process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux)
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN; // Build-time (baked into compiled code)
|
||||
/**
|
||||
* Get the base domain from environment variables
|
||||
* Evaluated lazily to allow dotenv to load first
|
||||
* Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
|
||||
* Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option
|
||||
* Runtime: process.env.TM_BASE_DOMAIN can override for staging/development
|
||||
* Default: https://tryhamster.com for production
|
||||
*/
|
||||
function getBaseDomain(): string {
|
||||
return (
|
||||
process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux)
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN || // Build-time (baked into compiled code)
|
||||
'https://tryhamster.com' // Fallback default
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default authentication configuration
|
||||
* Get default authentication configuration
|
||||
* All URL configuration is derived from the single BASE_DOMAIN
|
||||
* Evaluated lazily to allow dotenv to load environment variables first
|
||||
*/
|
||||
export const DEFAULT_AUTH_CONFIG: AuthConfig = {
|
||||
// Base domain for all services
|
||||
baseUrl: BASE_DOMAIN!,
|
||||
function getDefaultAuthConfig(): AuthConfig {
|
||||
return {
|
||||
// Base domain for all services
|
||||
baseUrl: getBaseDomain(),
|
||||
|
||||
// Configuration directory and file paths
|
||||
configDir: path.join(os.homedir(), '.taskmaster'),
|
||||
configFile: path.join(os.homedir(), '.taskmaster', 'auth.json')
|
||||
};
|
||||
// Configuration directory and file paths
|
||||
configDir: path.join(os.homedir(), '.taskmaster'),
|
||||
configFile: path.join(os.homedir(), '.taskmaster', 'auth.json')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get merged configuration with optional overrides
|
||||
*/
|
||||
export function getAuthConfig(overrides?: Partial<AuthConfig>): AuthConfig {
|
||||
return {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...getDefaultAuthConfig(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default authentication configuration (exported for backward compatibility)
|
||||
* Note: This is now a getter property that evaluates lazily
|
||||
*/
|
||||
export const DEFAULT_AUTH_CONFIG: AuthConfig = new Proxy({} as AuthConfig, {
|
||||
get(_target, prop) {
|
||||
return getDefaultAuthConfig()[prop as keyof AuthConfig];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ export { OAuthService } from './services/oauth-service.js';
|
||||
export { SupabaseSessionStorage } from './services/supabase-session-storage.js';
|
||||
export type {
|
||||
Organization,
|
||||
Brief,
|
||||
RemoteTask
|
||||
} from './services/organization.service.js';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Tests for AuthManager singleton behavior
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock the logger to verify warnings (must be hoisted before SUT import)
|
||||
const mockLogger = {
|
||||
|
||||
@@ -2,31 +2,31 @@
|
||||
* Authentication manager for Task Master CLI
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthCredentials,
|
||||
OAuthFlowOptions,
|
||||
AuthenticationError,
|
||||
AuthConfig,
|
||||
UserContext,
|
||||
UserContextWithBrief
|
||||
} from '../types.js';
|
||||
import { ContextStore } from '../services/context-store.js';
|
||||
import { OAuthService } from '../services/oauth-service.js';
|
||||
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
|
||||
import {
|
||||
OrganizationService,
|
||||
type Organization,
|
||||
type Brief,
|
||||
type RemoteTask
|
||||
} from '../services/organization.service.js';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { Brief } from '../../briefs/types.js';
|
||||
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
|
||||
import { ContextStore } from '../services/context-store.js';
|
||||
import { OAuthService } from '../services/oauth-service.js';
|
||||
import {
|
||||
type Organization,
|
||||
OrganizationService,
|
||||
type RemoteTask
|
||||
} from '../services/organization.service.js';
|
||||
import {
|
||||
AuthConfig,
|
||||
AuthCredentials,
|
||||
AuthenticationError,
|
||||
OAuthFlowOptions,
|
||||
UserContext,
|
||||
UserContextWithBrief
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
* Authentication manager class
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { UserContext, AuthenticationError } from '../types.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import { AuthenticationError, UserContext } from '../types.js';
|
||||
|
||||
const DEFAULT_CONTEXT_FILE = path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || '~',
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
* OAuth 2.0 Authorization Code Flow service
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
import crypto from 'crypto';
|
||||
import http from 'http';
|
||||
import os from 'os';
|
||||
import {
|
||||
AuthCredentials,
|
||||
AuthenticationError,
|
||||
OAuthFlowOptions,
|
||||
AuthConfig,
|
||||
CliData
|
||||
} from '../types.js';
|
||||
import { ContextStore } from '../services/context-store.js';
|
||||
import { URL } from 'url';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { TASKMASTER_VERSION } from '../../../common/constants/index.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
|
||||
import { getAuthConfig } from '../config.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import packageJson from '../../../../../../package.json' with { type: 'json' };
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { ContextStore } from '../services/context-store.js';
|
||||
import {
|
||||
AuthConfig,
|
||||
AuthCredentials,
|
||||
AuthenticationError,
|
||||
CliData,
|
||||
OAuthFlowOptions
|
||||
} from '../types.js';
|
||||
|
||||
export class OAuthService {
|
||||
private logger = getLogger('OAuthService');
|
||||
@@ -417,10 +417,10 @@ export class OAuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLI version from package.json if available
|
||||
* Get CLI version from centralized constants
|
||||
*/
|
||||
private getCliVersion(): string {
|
||||
return packageJson.version || 'unknown';
|
||||
return TASKMASTER_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { Database } from '../../../common/types/database.types.js';
|
||||
import {
|
||||
TaskMasterError,
|
||||
ERROR_CODES
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import { Database } from '../../../common/types/database.types.js';
|
||||
import type { Brief } from '../../briefs/types.js';
|
||||
|
||||
/**
|
||||
* Organization data structure
|
||||
@@ -20,24 +21,6 @@ export interface Organization {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brief data structure
|
||||
*/
|
||||
export interface Brief {
|
||||
id: string;
|
||||
accountId: string;
|
||||
documentId: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
document?: {
|
||||
id: string;
|
||||
title: string;
|
||||
document_name: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Task data structure from the remote database
|
||||
*/
|
||||
@@ -181,6 +164,7 @@ export class OrganizationService {
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
tasks(count),
|
||||
document:document_id (
|
||||
id,
|
||||
document_name,
|
||||
@@ -211,6 +195,9 @@ export class OrganizationService {
|
||||
status: brief.status,
|
||||
createdAt: brief.created_at,
|
||||
updatedAt: brief.updated_at,
|
||||
taskCount: Array.isArray(brief.tasks)
|
||||
? (brief.tasks[0]?.count ?? 0)
|
||||
: 0,
|
||||
document: brief.document
|
||||
? {
|
||||
id: brief.document.id,
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
* - Persistence to ~/.taskmaster/session.json
|
||||
*/
|
||||
|
||||
import type { SupportedStorage } from '@supabase/supabase-js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { SupportedStorage } from '@supabase/supabase-js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
|
||||
const DEFAULT_SESSION_FILE = path.join(
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface UserContext {
|
||||
orgSlug?: string;
|
||||
briefId?: string;
|
||||
briefName?: string;
|
||||
briefStatus?: string;
|
||||
briefUpdatedAt?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
182
packages/tm-core/src/modules/briefs/briefs-domain.ts
Normal file
182
packages/tm-core/src/modules/briefs/briefs-domain.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @fileoverview Briefs Domain Facade
|
||||
* Public API for brief-related operations
|
||||
*/
|
||||
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../common/errors/task-master-error.js';
|
||||
import { AuthManager } from '../auth/managers/auth-manager.js';
|
||||
import type { TaskRepository } from '../tasks/repositories/task-repository.interface.js';
|
||||
import { BriefService, type TagWithStats } from './services/brief-service.js';
|
||||
|
||||
/**
|
||||
* Briefs Domain - Unified API for brief operations
|
||||
* Handles brief switching, matching, and statistics
|
||||
*/
|
||||
export class BriefsDomain {
|
||||
private briefService: BriefService;
|
||||
private authManager: AuthManager;
|
||||
|
||||
constructor() {
|
||||
this.briefService = new BriefService();
|
||||
this.authManager = AuthManager.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a brief by name, ID, URL, or partial ID without updating context
|
||||
* Returns the full brief object
|
||||
*
|
||||
* Supports:
|
||||
* - Hamster URLs (e.g., https://app.tryhamster.com/home/hamster/briefs/abc123)
|
||||
* - Full UUID
|
||||
* - Last 8 characters of UUID
|
||||
* - Brief name (exact or partial match)
|
||||
*
|
||||
* @param input - Raw input: URL, UUID, last 8 chars, or brief name
|
||||
* @param orgId - Optional organization ID. If not provided, uses current context.
|
||||
* @returns The resolved brief object
|
||||
*/
|
||||
async resolveBrief(input: string, orgId?: string): Promise<any> {
|
||||
// Extract brief ID/name from input (could be URL, ID, or name)
|
||||
const briefIdOrName = this.extractBriefIdentifier(input);
|
||||
|
||||
// Get org ID from parameter or context
|
||||
const resolvedOrgId = orgId || this.authManager.getContext()?.orgId;
|
||||
|
||||
if (!resolvedOrgId) {
|
||||
throw new TaskMasterError(
|
||||
'No organization selected. Run "tm context org" first.',
|
||||
ERROR_CODES.CONFIG_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all briefs for the org
|
||||
const briefs = await this.authManager.getBriefs(resolvedOrgId);
|
||||
|
||||
// Find matching brief using service
|
||||
const matchingBrief = await this.briefService.findBrief(
|
||||
briefs,
|
||||
briefIdOrName
|
||||
);
|
||||
|
||||
this.briefService.validateBriefFound(matchingBrief, briefIdOrName);
|
||||
|
||||
return matchingBrief;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract brief identifier from raw input
|
||||
* Handles URLs, paths, and direct IDs
|
||||
*
|
||||
* @param input - Raw input string
|
||||
* @returns Extracted brief identifier
|
||||
*/
|
||||
private extractBriefIdentifier(input: string): string {
|
||||
const raw = input?.trim() ?? '';
|
||||
if (!raw) {
|
||||
throw new TaskMasterError(
|
||||
'Brief identifier cannot be empty',
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 1) URL (absolute or scheme‑less)
|
||||
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) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Looks like a path without scheme
|
||||
if (raw.includes('/')) {
|
||||
const candidate = fromParts(raw);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Fallback: raw token (UUID, last 8 chars, or name)
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different brief by name or ID
|
||||
* Validates context, finds matching brief, and updates auth context
|
||||
*/
|
||||
async switchBrief(briefNameOrId: string): Promise<void> {
|
||||
// Use resolveBrief to find the brief
|
||||
const matchingBrief = await this.resolveBrief(briefNameOrId);
|
||||
|
||||
// Update context with the found brief
|
||||
await this.authManager.updateContext({
|
||||
briefId: matchingBrief.id,
|
||||
briefName:
|
||||
matchingBrief.document?.title || `Brief ${matchingBrief.id.slice(-8)}`,
|
||||
briefStatus: matchingBrief.status,
|
||||
briefUpdatedAt: matchingBrief.updatedAt
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all briefs with detailed statistics including task counts
|
||||
* Used for API storage to show brief statistics
|
||||
*/
|
||||
async getBriefsWithStats(
|
||||
repository: TaskRepository,
|
||||
projectId: string
|
||||
): Promise<{
|
||||
tags: TagWithStats[];
|
||||
currentTag: string | null;
|
||||
totalTags: number;
|
||||
}> {
|
||||
const context = this.authManager.getContext();
|
||||
|
||||
if (!context?.orgId) {
|
||||
throw new TaskMasterError(
|
||||
'No organization context available',
|
||||
ERROR_CODES.MISSING_CONFIGURATION,
|
||||
{
|
||||
operation: 'getBriefsWithStats',
|
||||
userMessage:
|
||||
'No organization selected. Please authenticate first using: tm auth login'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get all briefs for the organization (through auth manager)
|
||||
const briefs = await this.authManager.getBriefs(context.orgId);
|
||||
|
||||
// Use BriefService to calculate stats
|
||||
return this.briefService.getTagsWithStats(
|
||||
briefs,
|
||||
context.briefId,
|
||||
repository,
|
||||
projectId
|
||||
);
|
||||
}
|
||||
}
|
||||
7
packages/tm-core/src/modules/briefs/index.ts
Normal file
7
packages/tm-core/src/modules/briefs/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Briefs module exports
|
||||
*/
|
||||
|
||||
export { BriefsDomain } from './briefs-domain.js';
|
||||
export { BriefService, type TagWithStats } from './services/brief-service.js';
|
||||
export type { Brief } from './types.js';
|
||||
225
packages/tm-core/src/modules/briefs/services/brief-service.ts
Normal file
225
packages/tm-core/src/modules/briefs/services/brief-service.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @fileoverview Brief Service
|
||||
* Handles brief lookup, matching, and statistics
|
||||
*/
|
||||
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
|
||||
import type { Brief } from '../types.js';
|
||||
|
||||
/**
|
||||
* Tag statistics with detailed breakdown
|
||||
*/
|
||||
export interface TagWithStats {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
taskCount: number;
|
||||
completedTasks: number;
|
||||
statusBreakdown: Record<string, number>;
|
||||
subtaskCounts?: {
|
||||
totalSubtasks: number;
|
||||
subtasksByStatus: Record<string, number>;
|
||||
};
|
||||
created?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
briefId?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for brief-related operations
|
||||
*/
|
||||
export class BriefService {
|
||||
/**
|
||||
* Find a brief by name or ID with flexible matching
|
||||
*/
|
||||
async findBrief(
|
||||
briefs: Brief[],
|
||||
nameOrId: string
|
||||
): Promise<Brief | undefined> {
|
||||
return briefs.find((brief) => this.matches(brief, nameOrId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a brief against a query string
|
||||
* Supports: exact name match, partial name match, full ID, last 8 chars of ID
|
||||
*/
|
||||
private matches(brief: Brief, query: string): boolean {
|
||||
const briefName = brief.document?.title || '';
|
||||
|
||||
// Exact match (case-insensitive)
|
||||
if (briefName.toLowerCase() === query.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Partial match
|
||||
if (briefName.toLowerCase().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match by ID (full or last 8 chars)
|
||||
if (
|
||||
brief.id === query ||
|
||||
brief.id.toLowerCase() === query.toLowerCase() ||
|
||||
brief.id.slice(-8).toLowerCase() === query.toLowerCase()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags with detailed statistics for all briefs in an organization
|
||||
* Used for API storage to show brief statistics
|
||||
*/
|
||||
async getTagsWithStats(
|
||||
briefs: Brief[],
|
||||
currentBriefId: string | undefined,
|
||||
repository: TaskRepository,
|
||||
_projectId?: string
|
||||
): Promise<{
|
||||
tags: TagWithStats[];
|
||||
currentTag: string | null;
|
||||
totalTags: number;
|
||||
}> {
|
||||
// For each brief, get task counts by querying tasks
|
||||
const tagsWithStats = await Promise.all(
|
||||
briefs.map(async (brief: Brief) => {
|
||||
try {
|
||||
// Get all tasks for this brief
|
||||
const tasks = await repository.getTasks(brief.id, {});
|
||||
|
||||
// Calculate statistics
|
||||
const statusBreakdown: Record<string, number> = {};
|
||||
let completedTasks = 0;
|
||||
|
||||
const subtaskCounts = {
|
||||
totalSubtasks: 0,
|
||||
subtasksByStatus: {} as Record<string, number>
|
||||
};
|
||||
|
||||
tasks.forEach((task) => {
|
||||
// Count task status
|
||||
const status = task.status || 'pending';
|
||||
statusBreakdown[status] = (statusBreakdown[status] || 0) + 1;
|
||||
|
||||
if (status === 'done') {
|
||||
completedTasks++;
|
||||
}
|
||||
|
||||
// Count subtasks
|
||||
if (task.subtasks && task.subtasks.length > 0) {
|
||||
subtaskCounts.totalSubtasks += task.subtasks.length;
|
||||
|
||||
task.subtasks.forEach((subtask) => {
|
||||
const subStatus = subtask.status || 'pending';
|
||||
subtaskCounts.subtasksByStatus[subStatus] =
|
||||
(subtaskCounts.subtasksByStatus[subStatus] || 0) + 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name:
|
||||
brief.document?.title ||
|
||||
brief.document?.document_name ||
|
||||
brief.id,
|
||||
isCurrent: currentBriefId === brief.id,
|
||||
taskCount: tasks.length,
|
||||
completedTasks,
|
||||
statusBreakdown,
|
||||
subtaskCounts:
|
||||
subtaskCounts.totalSubtasks > 0 ? subtaskCounts : undefined,
|
||||
created: brief.createdAt,
|
||||
description: brief.document?.description,
|
||||
status: brief.status,
|
||||
briefId: brief.id,
|
||||
updatedAt: brief.updatedAt
|
||||
};
|
||||
} catch (error) {
|
||||
// If we can't get tasks for a brief, return it with 0 tasks
|
||||
console.warn(`Failed to get tasks for brief ${brief.id}:`, error);
|
||||
return {
|
||||
name:
|
||||
brief.document?.title ||
|
||||
brief.document?.document_name ||
|
||||
brief.id,
|
||||
isCurrent: currentBriefId === brief.id,
|
||||
taskCount: 0,
|
||||
completedTasks: 0,
|
||||
statusBreakdown: {},
|
||||
created: brief.createdAt,
|
||||
description: brief.document?.description,
|
||||
status: brief.status,
|
||||
briefId: brief.id,
|
||||
updatedAt: brief.updatedAt
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Define priority order for brief statuses
|
||||
const statusPriority: Record<string, number> = {
|
||||
delivering: 1,
|
||||
aligned: 2,
|
||||
refining: 3,
|
||||
draft: 4,
|
||||
delivered: 5,
|
||||
done: 6,
|
||||
archived: 7
|
||||
};
|
||||
|
||||
// Sort tags: first by status priority, then by updatedAt (most recent first) within each status
|
||||
const sortedTags = tagsWithStats.sort((a, b) => {
|
||||
// Get status priorities (default to 999 for unknown statuses)
|
||||
const statusA = (a.status || '').toLowerCase();
|
||||
const statusB = (b.status || '').toLowerCase();
|
||||
const priorityA = statusPriority[statusA] ?? 999;
|
||||
const priorityB = statusPriority[statusB] ?? 999;
|
||||
|
||||
// Sort by status priority first
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB;
|
||||
}
|
||||
|
||||
// Within same status, sort by updatedAt (most recent first)
|
||||
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return dateB - dateA; // Descending order (most recent first)
|
||||
});
|
||||
|
||||
// Find current brief name
|
||||
const currentBrief = briefs.find((b) => b.id === currentBriefId);
|
||||
const currentTag = currentBrief
|
||||
? currentBrief.document?.title ||
|
||||
currentBrief.document?.document_name ||
|
||||
null
|
||||
: null;
|
||||
|
||||
return {
|
||||
tags: sortedTags,
|
||||
currentTag,
|
||||
totalTags: sortedTags.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a brief was found, throw error if not
|
||||
*/
|
||||
validateBriefFound(
|
||||
brief: Brief | undefined,
|
||||
nameOrId: string
|
||||
): asserts brief is Brief {
|
||||
if (!brief) {
|
||||
throw new TaskMasterError(
|
||||
`Brief "${nameOrId}" not found in organization`,
|
||||
ERROR_CODES.NOT_FOUND
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
packages/tm-core/src/modules/briefs/types.ts
Normal file
23
packages/tm-core/src/modules/briefs/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @fileoverview Briefs module types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Brief data structure
|
||||
* Represents a project brief containing tasks and requirements
|
||||
*/
|
||||
export interface Brief {
|
||||
id: string;
|
||||
accountId: string;
|
||||
documentId: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
taskCount?: number;
|
||||
document?: {
|
||||
id: string;
|
||||
title: string;
|
||||
document_name: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
* Public API for configuration management
|
||||
*/
|
||||
|
||||
import type { ConfigManager } from './managers/config-manager.js';
|
||||
import type {
|
||||
PartialConfiguration,
|
||||
RuntimeStorageConfig
|
||||
} from '../../common/interfaces/configuration.interface.js';
|
||||
import type { ConfigManager } from './managers/config-manager.js';
|
||||
|
||||
/**
|
||||
* Config Domain - Unified API for configuration operations
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
* Tests the orchestration of all configuration services
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { ConfigManager } from './config-manager.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
|
||||
import { ConfigLoader } from '../services/config-loader.service.js';
|
||||
import { ConfigMerger } from '../services/config-merger.service.js';
|
||||
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
|
||||
import { ConfigPersistence } from '../services/config-persistence.service.js';
|
||||
import { EnvironmentConfigProvider } from '../services/environment-config-provider.service.js';
|
||||
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
|
||||
import { ConfigManager } from './config-manager.js';
|
||||
|
||||
// Mock all services
|
||||
vi.mock('../services/config-loader.service.js');
|
||||
|
||||
@@ -13,12 +13,12 @@ import type {
|
||||
import { DEFAULT_CONFIG_VALUES as DEFAULTS } from '../../../common/interfaces/configuration.interface.js';
|
||||
import { ConfigLoader } from '../services/config-loader.service.js';
|
||||
import {
|
||||
ConfigMerger,
|
||||
CONFIG_PRECEDENCE
|
||||
CONFIG_PRECEDENCE,
|
||||
ConfigMerger
|
||||
} from '../services/config-merger.service.js';
|
||||
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
|
||||
import { ConfigPersistence } from '../services/config-persistence.service.js';
|
||||
import { EnvironmentConfigProvider } from '../services/environment-config-provider.service.js';
|
||||
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
|
||||
|
||||
/**
|
||||
* ConfigManager orchestrates all configuration services
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* @fileoverview Unit tests for ConfigLoader service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ConfigLoader } from './config-loader.service.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
|
||||
import { ConfigLoader } from './config-loader.service.js';
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
|
||||
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
|
||||
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
|
||||
|
||||
/**
|
||||
* ConfigLoader handles loading configuration from files
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @fileoverview Unit tests for ConfigMerger service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ConfigMerger, CONFIG_PRECEDENCE } from './config-merger.service.js';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { CONFIG_PRECEDENCE, ConfigMerger } from './config-merger.service.js';
|
||||
|
||||
describe('ConfigMerger', () => {
|
||||
let merger: ConfigMerger;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* @fileoverview Unit tests for ConfigPersistence service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ConfigPersistence } from './config-persistence.service.js';
|
||||
import type { PartialConfiguration } from '@tm/core/common/interfaces/configuration.interface.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ConfigPersistence } from './config-persistence.service.js';
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @fileoverview Unit tests for EnvironmentConfigProvider service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EnvironmentConfigProvider } from './environment-config-provider.service.js';
|
||||
|
||||
describe('EnvironmentConfigProvider', () => {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* @fileoverview Unit tests for RuntimeStateManager service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { RuntimeStateManager } from './runtime-state-manager.service.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
|
||||
import { RuntimeStateManager } from './runtime-state-manager.service.js';
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Base executor class providing common functionality for all executors
|
||||
*/
|
||||
|
||||
import type { Task } from '../../../common/types/index.js';
|
||||
import type { ITaskExecutor, ExecutorType, ExecutionResult } from '../types.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import type { Task } from '../../../common/types/index.js';
|
||||
import type { ExecutionResult, ExecutorType, ITaskExecutor } from '../types.js';
|
||||
|
||||
export abstract class BaseExecutor implements ITaskExecutor {
|
||||
protected readonly logger = getLogger('BaseExecutor');
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import type { Task } from '../../../common/types/index.js';
|
||||
import type {
|
||||
ExecutorType,
|
||||
ExecutionResult,
|
||||
ClaudeExecutorConfig
|
||||
} from '../types.js';
|
||||
import { BaseExecutor } from '../executors/base-executor.js';
|
||||
import type {
|
||||
ClaudeExecutorConfig,
|
||||
ExecutionResult,
|
||||
ExecutorType
|
||||
} from '../types.js';
|
||||
|
||||
export class ClaudeExecutor extends BaseExecutor {
|
||||
private claudeConfig: ClaudeExecutorConfig;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Factory for creating task executors
|
||||
*/
|
||||
|
||||
import type { ITaskExecutor, ExecutorOptions, ExecutorType } from '../types.js';
|
||||
import { ClaudeExecutor } from '../executors/claude-executor.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import { ClaudeExecutor } from '../executors/claude-executor.js';
|
||||
import type { ExecutorOptions, ExecutorType, ITaskExecutor } from '../types.js';
|
||||
|
||||
export class ExecutorFactory {
|
||||
private static logger = getLogger('ExecutorFactory');
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
* Service for managing task execution
|
||||
*/
|
||||
|
||||
import type { Task } from '../../../common/types/index.js';
|
||||
import type {
|
||||
ITaskExecutor,
|
||||
ExecutorOptions,
|
||||
ExecutionResult,
|
||||
ExecutorType
|
||||
} from '../types.js';
|
||||
import { ExecutorFactory } from '../executors/executor-factory.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import type { Task } from '../../../common/types/index.js';
|
||||
import { ExecutorFactory } from '../executors/executor-factory.js';
|
||||
import type {
|
||||
ExecutionResult,
|
||||
ExecutorOptions,
|
||||
ExecutorType,
|
||||
ITaskExecutor
|
||||
} from '../types.js';
|
||||
|
||||
export interface ExecutorServiceOptions {
|
||||
projectRoot: string;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
jest
|
||||
} from '@jest/globals';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { GitAdapter } from '../../../../../packages/tm-core/src/git/git-adapter.js';
|
||||
|
||||
describe('GitAdapter - Repository Detection and Validation', () => {
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
* @module git-adapter
|
||||
*/
|
||||
|
||||
import { simpleGit, type SimpleGit, type StatusResult } from 'simple-git';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { type SimpleGit, type StatusResult, simpleGit } from 'simple-git';
|
||||
|
||||
/**
|
||||
* GitAdapter class for safe git operations
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Public API for Git operations
|
||||
*/
|
||||
|
||||
import type { StatusResult } from 'simple-git';
|
||||
import { GitAdapter } from './adapters/git-adapter.js';
|
||||
import { CommitMessageGenerator } from './services/commit-message-generator.js';
|
||||
import type { CommitMessageOptions } from './services/commit-message-generator.js';
|
||||
import type { StatusResult } from 'simple-git';
|
||||
|
||||
/**
|
||||
* Git Domain - Unified API for Git operations
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
generateBranchName,
|
||||
sanitizeBranchName
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { CommitMessageGenerator } from './commit-message-generator.js';
|
||||
|
||||
describe('CommitMessageGenerator', () => {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* that follow conventional commits specification and include task metadata.
|
||||
*/
|
||||
|
||||
import { TemplateEngine } from './template-engine.js';
|
||||
import { ScopeDetector } from './scope-detector.js';
|
||||
import { TemplateEngine } from './template-engine.js';
|
||||
|
||||
export interface CommitMessageOptions {
|
||||
type: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ScopeDetector } from './scope-detector.js';
|
||||
|
||||
describe('ScopeDetector', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { TemplateEngine } from './template-engine.js';
|
||||
|
||||
describe('TemplateEngine', () => {
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createClient,
|
||||
Session,
|
||||
SupabaseClient as SupabaseJSClient,
|
||||
User,
|
||||
Session
|
||||
createClient
|
||||
} from '@supabase/supabase-js';
|
||||
import { AuthenticationError } from '../../auth/types.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import { SupabaseSessionStorage } from '../../auth/services/supabase-session-storage.js';
|
||||
import { AuthenticationError } from '../../auth/types.js';
|
||||
|
||||
export class SupabaseAuthClient {
|
||||
private client: SupabaseJSClient | null = null;
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* Public API for integration with external systems
|
||||
*/
|
||||
|
||||
import type { ConfigManager } from '../config/managers/config-manager.js';
|
||||
import { AuthManager } from '../auth/managers/auth-manager.js';
|
||||
import type { ConfigManager } from '../config/managers/config-manager.js';
|
||||
import { ExportService } from './services/export.service.js';
|
||||
import type {
|
||||
ExportTasksOptions,
|
||||
ExportResult
|
||||
ExportResult,
|
||||
ExportTasksOptions
|
||||
} from './services/export.service.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
* Core service for exporting tasks to external systems (e.g., Hamster briefs)
|
||||
*/
|
||||
|
||||
import type { Task, TaskStatus } from '../../../common/types/index.js';
|
||||
import type { UserContext } from '../../auth/types.js';
|
||||
import { ConfigManager } from '../../config/managers/config-manager.js';
|
||||
import { AuthManager } from '../../auth/managers/auth-manager.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import type { Task, TaskStatus } from '../../../common/types/index.js';
|
||||
import { AuthManager } from '../../auth/managers/auth-manager.js';
|
||||
import type { UserContext } from '../../auth/types.js';
|
||||
import { ConfigManager } from '../../config/managers/config-manager.js';
|
||||
import { FileStorage } from '../../storage/adapters/file-storage/index.js';
|
||||
|
||||
// Type definitions for the bulk API response
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
|
||||
import { getLogger } from '../../../common/logger/factory.js';
|
||||
import { AuthManager } from '../../auth/managers/auth-manager.js';
|
||||
import { ApiClient } from '../../storage/utils/api-client.js';
|
||||
import { getLogger } from '../../../common/logger/factory.js';
|
||||
import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
|
||||
|
||||
/**
|
||||
* Response from the expand task API endpoint (202 Accepted)
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
|
||||
import { AuthManager } from '../../auth/managers/auth-manager.js';
|
||||
import { ApiClient } from '../../storage/utils/api-client.js';
|
||||
import { getLogger } from '../../../common/logger/factory.js';
|
||||
import type { Task } from '../../../common/types/index.js';
|
||||
import { AuthManager } from '../../auth/managers/auth-manager.js';
|
||||
import { ApiClient } from '../../storage/utils/api-client.js';
|
||||
import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
|
||||
|
||||
/**
|
||||
* Response from the get task API endpoint
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'path';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import type {
|
||||
ComplexityReport,
|
||||
ComplexityAnalysis,
|
||||
ComplexityReport,
|
||||
TaskComplexityData
|
||||
} from '../types.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
|
||||
const logger = getLogger('ComplexityReportManager');
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* @module activity-logger
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
/**
|
||||
* Activity log entry structure
|
||||
|
||||
@@ -3,33 +3,34 @@
|
||||
* This provides storage via repository abstraction for flexibility
|
||||
*/
|
||||
|
||||
import type {
|
||||
IStorage,
|
||||
StorageStats,
|
||||
UpdateStatusResult,
|
||||
LoadTasksOptions
|
||||
} from '../../../common/interfaces/storage.interface.js';
|
||||
import type {
|
||||
Task,
|
||||
TaskMetadata,
|
||||
TaskTag,
|
||||
TaskStatus
|
||||
} from '../../../common/types/index.js';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
|
||||
import { SupabaseRepository } from '../../tasks/repositories/supabase/index.js';
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { AuthManager } from '../../auth/managers/auth-manager.js';
|
||||
import { ApiClient } from '../utils/api-client.js';
|
||||
import type {
|
||||
IStorage,
|
||||
LoadTasksOptions,
|
||||
StorageStats,
|
||||
UpdateStatusResult
|
||||
} from '../../../common/interfaces/storage.interface.js';
|
||||
import { getLogger } from '../../../common/logger/factory.js';
|
||||
import type {
|
||||
Task,
|
||||
TaskMetadata,
|
||||
TaskStatus,
|
||||
TaskTag
|
||||
} from '../../../common/types/index.js';
|
||||
import { AuthManager } from '../../auth/managers/auth-manager.js';
|
||||
import { BriefsDomain } from '../../briefs/briefs-domain.js';
|
||||
import {
|
||||
ExpandTaskResult,
|
||||
type ExpandTaskResult,
|
||||
TaskExpansionService
|
||||
} from '../../integration/services/task-expansion.service.js';
|
||||
import { TaskRetrievalService } from '../../integration/services/task-retrieval.service.js';
|
||||
import { SupabaseRepository } from '../../tasks/repositories/supabase/index.js';
|
||||
import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
|
||||
import { ApiClient } from '../utils/api-client.js';
|
||||
|
||||
/**
|
||||
* API storage configuration
|
||||
@@ -159,6 +160,49 @@ export class ApiStorage implements IStorage {
|
||||
return context?.briefName || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all briefs (tags) with detailed statistics including task counts
|
||||
* In API storage, tags are called "briefs"
|
||||
* Delegates to BriefsDomain for brief statistics calculation
|
||||
*/
|
||||
async getTagsWithStats(): Promise<{
|
||||
tags: Array<{
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
taskCount: number;
|
||||
completedTasks: number;
|
||||
statusBreakdown: Record<string, number>;
|
||||
subtaskCounts?: {
|
||||
totalSubtasks: number;
|
||||
subtasksByStatus: Record<string, number>;
|
||||
};
|
||||
created?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
briefId?: string;
|
||||
}>;
|
||||
currentTag: string | null;
|
||||
totalTags: number;
|
||||
}> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
// Delegate to BriefsDomain which owns brief operations
|
||||
const briefsDomain = new BriefsDomain();
|
||||
return await briefsDomain.getBriefsWithStats(
|
||||
this.repository,
|
||||
this.projectId
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to get tags with stats from API',
|
||||
ERROR_CODES.STORAGE_ERROR,
|
||||
{ operation: 'getTagsWithStats' },
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tags into cache
|
||||
* In our API-based system, "tags" represent briefs
|
||||
@@ -684,6 +728,21 @@ export class ApiStorage implements IStorage {
|
||||
return this.listTags();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tag (brief)
|
||||
* Not supported with API storage - users must create briefs via web interface
|
||||
*/
|
||||
async createTag(
|
||||
tagName: string,
|
||||
_options?: { copyFrom?: string; description?: string }
|
||||
): Promise<void> {
|
||||
throw new TaskMasterError(
|
||||
'Tag creation is not supported with API storage. Please create briefs through Hamster Studio.',
|
||||
ERROR_CODES.NOT_IMPLEMENTED,
|
||||
{ storageType: 'api', operation: 'createTag', tagName }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all tasks for a tag
|
||||
*/
|
||||
@@ -964,7 +1023,7 @@ export class ApiStorage implements IStorage {
|
||||
*/
|
||||
private async retryOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
attempt: number = 1
|
||||
attempt = 1
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @fileoverview File operations with atomic writes and locking
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import type { FileStorageData } from './format-handler.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,21 +2,26 @@
|
||||
* @fileoverview Refactored file-based storage implementation for Task Master
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
IStorage,
|
||||
LoadTasksOptions,
|
||||
StorageStats,
|
||||
UpdateStatusResult
|
||||
} from '../../../../common/interfaces/storage.interface.js';
|
||||
import type {
|
||||
Task,
|
||||
TaskMetadata,
|
||||
TaskStatus
|
||||
} from '../../../../common/types/index.js';
|
||||
import type {
|
||||
IStorage,
|
||||
StorageStats,
|
||||
UpdateStatusResult,
|
||||
LoadTasksOptions
|
||||
} from '../../../../common/interfaces/storage.interface.js';
|
||||
import { FormatHandler } from './format-handler.js';
|
||||
import { FileOperations } from './file-operations.js';
|
||||
import { PathResolver } from './path-resolver.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../../common/errors/task-master-error.js';
|
||||
import { ComplexityReportManager } from '../../../reports/managers/complexity-report-manager.js';
|
||||
import { FileOperations } from './file-operations.js';
|
||||
import { FormatHandler } from './format-handler.js';
|
||||
import { PathResolver } from './path-resolver.js';
|
||||
|
||||
/**
|
||||
* File-based storage implementation using a single tasks.json file with separated concerns
|
||||
@@ -583,6 +588,94 @@ export class FileStorage implements IStorage {
|
||||
await this.saveTasks(filteredTasks, tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tag in the tasks.json file
|
||||
*/
|
||||
async createTag(
|
||||
tagName: string,
|
||||
options?: { copyFrom?: string; description?: string }
|
||||
): Promise<void> {
|
||||
const filePath = this.pathResolver.getTasksPath();
|
||||
|
||||
try {
|
||||
const existingData = await this.fileOps.readJson(filePath);
|
||||
const format = this.formatHandler.detectFormat(existingData);
|
||||
|
||||
if (format === 'legacy') {
|
||||
// Legacy format - add new tag key
|
||||
if (tagName in existingData) {
|
||||
throw new TaskMasterError(
|
||||
`Tag ${tagName} already exists`,
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
// Get tasks to copy if specified
|
||||
let tasksToCopy = [];
|
||||
if (options?.copyFrom) {
|
||||
if (
|
||||
options.copyFrom in existingData &&
|
||||
existingData[options.copyFrom].tasks
|
||||
) {
|
||||
tasksToCopy = JSON.parse(
|
||||
JSON.stringify(existingData[options.copyFrom].tasks)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new tag structure
|
||||
existingData[tagName] = {
|
||||
tasks: tasksToCopy,
|
||||
metadata: {
|
||||
created: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
description:
|
||||
options?.description ||
|
||||
`Tag created on ${new Date().toLocaleDateString()}`,
|
||||
tags: [tagName]
|
||||
}
|
||||
};
|
||||
|
||||
await this.fileOps.writeJson(filePath, existingData);
|
||||
} else {
|
||||
// Standard format - need to convert to legacy format first
|
||||
const masterTasks = existingData.tasks || [];
|
||||
const masterMetadata = existingData.metadata || {};
|
||||
|
||||
// Get tasks to copy (from master in this case)
|
||||
let tasksToCopy = [];
|
||||
if (options?.copyFrom === 'master' || !options?.copyFrom) {
|
||||
tasksToCopy = JSON.parse(JSON.stringify(masterTasks));
|
||||
}
|
||||
|
||||
const newData = {
|
||||
master: {
|
||||
tasks: masterTasks,
|
||||
metadata: { ...masterMetadata, tags: ['master'] }
|
||||
},
|
||||
[tagName]: {
|
||||
tasks: tasksToCopy,
|
||||
metadata: {
|
||||
created: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
description:
|
||||
options?.description ||
|
||||
`Tag created on ${new Date().toLocaleDateString()}`,
|
||||
tags: [tagName]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await this.fileOps.writeJson(filePath, newData);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error('Tasks file not found - initialize project first');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag from the single tasks.json file
|
||||
*/
|
||||
@@ -675,6 +768,120 @@ export class FileStorage implements IStorage {
|
||||
await this.saveTasks(tasks, targetTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags with detailed statistics including task counts
|
||||
* For file storage, reads tags from tasks.json and calculates statistics
|
||||
*/
|
||||
async getTagsWithStats(): Promise<{
|
||||
tags: Array<{
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
taskCount: number;
|
||||
completedTasks: number;
|
||||
statusBreakdown: Record<string, number>;
|
||||
subtaskCounts?: {
|
||||
totalSubtasks: number;
|
||||
subtasksByStatus: Record<string, number>;
|
||||
};
|
||||
created?: string;
|
||||
description?: string;
|
||||
}>;
|
||||
currentTag: string | null;
|
||||
totalTags: number;
|
||||
}> {
|
||||
const availableTags = await this.getAllTags();
|
||||
|
||||
// Get active tag from state.json
|
||||
const activeTag = await this.getActiveTagFromState();
|
||||
|
||||
const tagsWithStats = await Promise.all(
|
||||
availableTags.map(async (tagName) => {
|
||||
try {
|
||||
// Load tasks for this tag
|
||||
const tasks = await this.loadTasks(tagName);
|
||||
|
||||
// Calculate statistics
|
||||
const statusBreakdown: Record<string, number> = {};
|
||||
let completedTasks = 0;
|
||||
|
||||
const subtaskCounts = {
|
||||
totalSubtasks: 0,
|
||||
subtasksByStatus: {} as Record<string, number>
|
||||
};
|
||||
|
||||
tasks.forEach((task) => {
|
||||
// Count task status
|
||||
const status = task.status || 'pending';
|
||||
statusBreakdown[status] = (statusBreakdown[status] || 0) + 1;
|
||||
|
||||
if (status === 'done') {
|
||||
completedTasks++;
|
||||
}
|
||||
|
||||
// Count subtasks
|
||||
if (task.subtasks && task.subtasks.length > 0) {
|
||||
subtaskCounts.totalSubtasks += task.subtasks.length;
|
||||
|
||||
task.subtasks.forEach((subtask) => {
|
||||
const subStatus = subtask.status || 'pending';
|
||||
subtaskCounts.subtasksByStatus[subStatus] =
|
||||
(subtaskCounts.subtasksByStatus[subStatus] || 0) + 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Load metadata to get created date and description
|
||||
const metadata = await this.loadMetadata(tagName);
|
||||
|
||||
return {
|
||||
name: tagName,
|
||||
isCurrent: tagName === activeTag,
|
||||
taskCount: tasks.length,
|
||||
completedTasks,
|
||||
statusBreakdown,
|
||||
subtaskCounts:
|
||||
subtaskCounts.totalSubtasks > 0 ? subtaskCounts : undefined,
|
||||
created: metadata?.created,
|
||||
description: metadata?.description
|
||||
};
|
||||
} catch (error) {
|
||||
// If we can't load tasks for a tag, return it with 0 tasks
|
||||
return {
|
||||
name: tagName,
|
||||
isCurrent: tagName === activeTag,
|
||||
taskCount: 0,
|
||||
completedTasks: 0,
|
||||
statusBreakdown: {}
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
tags: tagsWithStats,
|
||||
currentTag: activeTag,
|
||||
totalTags: tagsWithStats.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active tag from state.json
|
||||
* @returns The active tag name or 'master' as default
|
||||
*/
|
||||
private async getActiveTagFromState(): Promise<string> {
|
||||
try {
|
||||
const statePath = path.join(
|
||||
this.pathResolver.getBasePath(),
|
||||
'state.json'
|
||||
);
|
||||
const stateData = await this.fileOps.readJson(statePath);
|
||||
return stateData?.currentTag || 'master';
|
||||
} catch (error) {
|
||||
// If state.json doesn't exist or can't be read, default to 'master'
|
||||
return 'master';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich tasks with complexity data from the complexity report
|
||||
* Private helper method called by loadTasks()
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
* @fileoverview Storage factory for creating appropriate storage implementations
|
||||
*/
|
||||
|
||||
import type { IStorage } from '../../../common/interfaces/storage.interface.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import type {
|
||||
IConfiguration,
|
||||
RuntimeStorageConfig,
|
||||
StorageSettings
|
||||
} from '../../../common/interfaces/configuration.interface.js';
|
||||
import { FileStorage } from '../adapters/file-storage/index.js';
|
||||
import { ApiStorage } from '../adapters/api-storage.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import { AuthManager } from '../../auth/managers/auth-manager.js';
|
||||
import type { IStorage } from '../../../common/interfaces/storage.interface.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import { AuthManager } from '../../auth/managers/auth-manager.js';
|
||||
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
|
||||
import { ApiStorage } from '../adapters/api-storage.js';
|
||||
import { FileStorage } from '../adapters/file-storage/index.js';
|
||||
|
||||
/**
|
||||
* Factory for creating storage implementations based on configuration
|
||||
@@ -87,8 +87,19 @@ export class StorageFactory {
|
||||
apiEndpoint:
|
||||
config.storage?.apiEndpoint ||
|
||||
process.env.TM_BASE_DOMAIN ||
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN ||
|
||||
'https://tryhamster.com/api'
|
||||
};
|
||||
|
||||
// Validate that apiEndpoint is defined
|
||||
if (!nextStorage.apiEndpoint) {
|
||||
throw new TaskMasterError(
|
||||
'API endpoint could not be determined.',
|
||||
ERROR_CODES.MISSING_CONFIGURATION,
|
||||
{ storageType: 'api' }
|
||||
);
|
||||
}
|
||||
|
||||
config.storage = nextStorage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
TaskMasterError,
|
||||
ERROR_CODES
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../../common/errors/task-master-error.js';
|
||||
import type { AuthManager } from '../../auth/managers/auth-manager.js';
|
||||
|
||||
|
||||
307
packages/tm-core/src/modules/tasks/services/tag.service.ts
Normal file
307
packages/tm-core/src/modules/tasks/services/tag.service.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* @fileoverview TagService - Business logic for tag management
|
||||
* Handles tag creation, deletion, renaming, and copying
|
||||
*/
|
||||
|
||||
import type { IStorage } from '../../../common/interfaces/storage.interface.js';
|
||||
import type { TagInfo } from '../../../common/interfaces/storage.interface.js';
|
||||
import { TaskMasterError, ERROR_CODES } from '../../../common/errors/task-master-error.js';
|
||||
|
||||
/**
|
||||
* Options for creating a new tag
|
||||
*/
|
||||
export interface CreateTagOptions {
|
||||
/** Copy tasks from current tag */
|
||||
copyFromCurrent?: boolean;
|
||||
/** Copy tasks from specific tag */
|
||||
copyFromTag?: string;
|
||||
/** Tag description */
|
||||
description?: string;
|
||||
/** Create from git branch name */
|
||||
fromBranch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for deleting a tag
|
||||
* Note: Confirmation prompts are a CLI presentation concern
|
||||
* and are not handled by TagService (business logic layer)
|
||||
*/
|
||||
export interface DeleteTagOptions {
|
||||
// Currently no options - interface kept for future extensibility
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for copying a tag
|
||||
*/
|
||||
export interface CopyTagOptions {
|
||||
// Currently no options - interface kept for future extensibility
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserved tag names that cannot be used
|
||||
* Only 'master' is reserved as it's the system default tag
|
||||
* Users can use 'main' or 'default' if desired
|
||||
*/
|
||||
const RESERVED_TAG_NAMES = ['master'];
|
||||
|
||||
/**
|
||||
* Maximum length for tag names (prevents filesystem/UI issues)
|
||||
*/
|
||||
const MAX_TAG_NAME_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* TagService - Handles tag management business logic
|
||||
* Validates operations and delegates to storage layer
|
||||
*/
|
||||
export class TagService {
|
||||
constructor(private storage: IStorage) {}
|
||||
|
||||
/**
|
||||
* Validate tag name format and restrictions
|
||||
* @throws {TaskMasterError} if validation fails
|
||||
*/
|
||||
private validateTagName(name: string, context = 'Tag name'): void {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new TaskMasterError(
|
||||
`${context} is required and must be a string`,
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
// Check length
|
||||
if (name.length > MAX_TAG_NAME_LENGTH) {
|
||||
throw new TaskMasterError(
|
||||
`${context} must be ${MAX_TAG_NAME_LENGTH} characters or less`,
|
||||
ERROR_CODES.VALIDATION_ERROR,
|
||||
{ tagName: name, maxLength: MAX_TAG_NAME_LENGTH }
|
||||
);
|
||||
}
|
||||
|
||||
// Check format: alphanumeric, hyphens, underscores only
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
throw new TaskMasterError(
|
||||
`${context} can only contain letters, numbers, hyphens, and underscores`,
|
||||
ERROR_CODES.VALIDATION_ERROR,
|
||||
{ tagName: name }
|
||||
);
|
||||
}
|
||||
|
||||
// Check reserved names
|
||||
if (RESERVED_TAG_NAMES.includes(name.toLowerCase())) {
|
||||
throw new TaskMasterError(
|
||||
`"${name}" is a reserved tag name`,
|
||||
ERROR_CODES.VALIDATION_ERROR,
|
||||
{ tagName: name, reserved: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage supports tag mutation operations
|
||||
* @throws {TaskMasterError} if operation not supported
|
||||
*/
|
||||
private checkTagMutationSupport(operation: string): void {
|
||||
const storageType = this.storage.getStorageType();
|
||||
|
||||
if (storageType === 'api') {
|
||||
throw new TaskMasterError(
|
||||
`${operation} is not supported with API storage. Use the web interface at Hamster Studio.`,
|
||||
ERROR_CODES.NOT_IMPLEMENTED,
|
||||
{ storageType: 'api', operation }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tag
|
||||
* For API storage: throws error (client should redirect to web UI)
|
||||
* For file storage: creates tag with optional task copying
|
||||
*/
|
||||
async createTag(
|
||||
name: string,
|
||||
options: CreateTagOptions = {}
|
||||
): Promise<TagInfo> {
|
||||
// Validate tag name
|
||||
this.validateTagName(name);
|
||||
|
||||
// Check if tag already exists
|
||||
const allTags = await this.storage.getAllTags();
|
||||
if (allTags.includes(name)) {
|
||||
throw new TaskMasterError(
|
||||
`Tag "${name}" already exists`,
|
||||
ERROR_CODES.VALIDATION_ERROR,
|
||||
{ tagName: name }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate copyFromTag if provided
|
||||
if (options.copyFromTag && !allTags.includes(options.copyFromTag)) {
|
||||
throw new TaskMasterError(
|
||||
`Cannot copy from missing tag "${options.copyFromTag}"`,
|
||||
ERROR_CODES.NOT_FOUND,
|
||||
{ tagName: options.copyFromTag }
|
||||
);
|
||||
}
|
||||
|
||||
// For API storage, we can't create tags via CLI
|
||||
// The client (CLI/bridge) should handle redirecting to web UI
|
||||
this.checkTagMutationSupport('Tag creation');
|
||||
|
||||
// Determine which tag to copy from
|
||||
let copyFrom: string | undefined;
|
||||
if (options.copyFromTag) {
|
||||
copyFrom = options.copyFromTag;
|
||||
} else if (options.copyFromCurrent) {
|
||||
const result = await this.storage.getTagsWithStats();
|
||||
copyFrom = result.currentTag || undefined;
|
||||
}
|
||||
|
||||
// Delegate to storage layer
|
||||
await this.storage.createTag(name, {
|
||||
copyFrom,
|
||||
description: options.description
|
||||
});
|
||||
|
||||
// Return tag info
|
||||
const tagInfo: TagInfo = {
|
||||
name,
|
||||
taskCount: 0,
|
||||
completedTasks: 0,
|
||||
isCurrent: false,
|
||||
statusBreakdown: {},
|
||||
description: options.description || `Tag created on ${new Date().toLocaleDateString()}`
|
||||
};
|
||||
|
||||
return tagInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing tag
|
||||
* Cannot delete master tag
|
||||
* For API storage: throws error (client should redirect to web UI)
|
||||
*/
|
||||
async deleteTag(
|
||||
name: string,
|
||||
_options: DeleteTagOptions = {}
|
||||
): Promise<void> {
|
||||
// Validate tag name
|
||||
this.validateTagName(name);
|
||||
|
||||
// Cannot delete master tag
|
||||
if (name === 'master') {
|
||||
throw new TaskMasterError(
|
||||
'Cannot delete the "master" tag',
|
||||
ERROR_CODES.VALIDATION_ERROR,
|
||||
{ tagName: name, protected: true }
|
||||
);
|
||||
}
|
||||
|
||||
// For API storage, we can't delete tags via CLI
|
||||
this.checkTagMutationSupport('Tag deletion');
|
||||
|
||||
// Check if tag exists
|
||||
const allTags = await this.storage.getAllTags();
|
||||
if (!allTags.includes(name)) {
|
||||
throw new TaskMasterError(
|
||||
`Tag "${name}" does not exist`,
|
||||
ERROR_CODES.NOT_FOUND,
|
||||
{ tagName: name }
|
||||
);
|
||||
}
|
||||
|
||||
// Delegate to storage
|
||||
await this.storage.deleteTag(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename an existing tag
|
||||
* Cannot rename master tag
|
||||
* For API storage: throws error (client should redirect to web UI)
|
||||
*/
|
||||
async renameTag(oldName: string, newName: string): Promise<void> {
|
||||
// Validate both names
|
||||
this.validateTagName(oldName, 'Old tag name');
|
||||
this.validateTagName(newName, 'New tag name');
|
||||
|
||||
// Cannot rename master tag
|
||||
if (oldName === 'master') {
|
||||
throw new TaskMasterError(
|
||||
'Cannot rename the "master" tag',
|
||||
ERROR_CODES.VALIDATION_ERROR,
|
||||
{ tagName: oldName, protected: true }
|
||||
);
|
||||
}
|
||||
|
||||
// For API storage, we can't rename tags via CLI
|
||||
this.checkTagMutationSupport('Tag renaming');
|
||||
|
||||
// Check if old tag exists
|
||||
const allTags = await this.storage.getAllTags();
|
||||
if (!allTags.includes(oldName)) {
|
||||
throw new TaskMasterError(
|
||||
`Tag "${oldName}" does not exist`,
|
||||
ERROR_CODES.NOT_FOUND,
|
||||
{ tagName: oldName }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if new name already exists
|
||||
if (allTags.includes(newName)) {
|
||||
throw new TaskMasterError(
|
||||
`Tag "${newName}" already exists`,
|
||||
ERROR_CODES.VALIDATION_ERROR,
|
||||
{ tagName: newName }
|
||||
);
|
||||
}
|
||||
|
||||
// Delegate to storage
|
||||
await this.storage.renameTag(oldName, newName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an existing tag to create a new tag with the same tasks
|
||||
* For API storage: throws error (client should show alternative)
|
||||
*/
|
||||
async copyTag(
|
||||
sourceName: string,
|
||||
targetName: string,
|
||||
_options: CopyTagOptions = {}
|
||||
): Promise<void> {
|
||||
// Validate both names
|
||||
this.validateTagName(sourceName, 'Source tag name');
|
||||
this.validateTagName(targetName, 'Target tag name');
|
||||
|
||||
// For API storage, we can't copy tags via CLI
|
||||
this.checkTagMutationSupport('Tag copying');
|
||||
|
||||
// Check if source tag exists
|
||||
const allTags = await this.storage.getAllTags();
|
||||
if (!allTags.includes(sourceName)) {
|
||||
throw new TaskMasterError(
|
||||
`Source tag "${sourceName}" does not exist`,
|
||||
ERROR_CODES.NOT_FOUND,
|
||||
{ tagName: sourceName }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if target name already exists
|
||||
if (allTags.includes(targetName)) {
|
||||
throw new TaskMasterError(
|
||||
`Target tag "${targetName}" already exists`,
|
||||
ERROR_CODES.VALIDATION_ERROR,
|
||||
{ tagName: targetName }
|
||||
);
|
||||
}
|
||||
|
||||
// Delegate to storage
|
||||
await this.storage.copyTag(sourceName, targetName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags with statistics
|
||||
* Works with both file and API storage
|
||||
*/
|
||||
async getTagsWithStats() {
|
||||
return await this.storage.getTagsWithStats();
|
||||
}
|
||||
}
|
||||
@@ -500,6 +500,14 @@ export class TaskService {
|
||||
return this.storage.getStorageType();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage instance
|
||||
* Internal use only - used by other services in the tasks module
|
||||
*/
|
||||
getStorage(): IStorage {
|
||||
return this.storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active tag
|
||||
*/
|
||||
@@ -758,4 +766,45 @@ export class TaskService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags with detailed statistics including task counts
|
||||
* Delegates to storage layer which handles file vs API implementation
|
||||
*/
|
||||
async getTagsWithStats() {
|
||||
// Ensure we have storage
|
||||
if (!this.storage) {
|
||||
throw new TaskMasterError(
|
||||
'Storage not initialized',
|
||||
ERROR_CODES.STORAGE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-initialize if needed
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.storage.getTagsWithStats();
|
||||
} catch (error) {
|
||||
// If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it
|
||||
if (
|
||||
error instanceof TaskMasterError &&
|
||||
error.is(ERROR_CODES.NO_BRIEF_SELECTED)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new TaskMasterError(
|
||||
'Failed to get tags with stats',
|
||||
ERROR_CODES.STORAGE_ERROR,
|
||||
{
|
||||
operation: 'getTagsWithStats',
|
||||
resource: 'tags'
|
||||
},
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,18 @@
|
||||
*/
|
||||
|
||||
import type { ConfigManager } from '../config/managers/config-manager.js';
|
||||
import type { AuthDomain } from '../auth/auth-domain.js';
|
||||
import { BriefsDomain } from '../briefs/briefs-domain.js';
|
||||
import { TaskService } from './services/task-service.js';
|
||||
import { TaskExecutionService } from './services/task-execution-service.js';
|
||||
import { TaskLoaderService } from './services/task-loader.service.js';
|
||||
import { PreflightChecker } from './services/preflight-checker.service.js';
|
||||
import { TagService } from './services/tag.service.js';
|
||||
import type {
|
||||
CreateTagOptions,
|
||||
DeleteTagOptions,
|
||||
CopyTagOptions
|
||||
} from './services/tag.service.js';
|
||||
|
||||
import type { Subtask, Task, TaskStatus } from '../../common/types/index.js';
|
||||
import type {
|
||||
@@ -32,16 +40,22 @@ export class TasksDomain {
|
||||
private executionService: TaskExecutionService;
|
||||
private loaderService: TaskLoaderService;
|
||||
private preflightChecker: PreflightChecker;
|
||||
private briefsDomain: BriefsDomain;
|
||||
private tagService!: TagService;
|
||||
|
||||
constructor(configManager: ConfigManager) {
|
||||
constructor(configManager: ConfigManager, _authDomain?: AuthDomain) {
|
||||
this.taskService = new TaskService(configManager);
|
||||
this.executionService = new TaskExecutionService(this.taskService);
|
||||
this.loaderService = new TaskLoaderService(this.taskService);
|
||||
this.preflightChecker = new PreflightChecker(configManager.getProjectRoot());
|
||||
this.briefsDomain = new BriefsDomain();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.taskService.initialize();
|
||||
|
||||
// TagService needs storage - get it from TaskService AFTER initialization
|
||||
this.tagService = new TagService(this.taskService.getStorage());
|
||||
}
|
||||
|
||||
// ========== Task Retrieval ==========
|
||||
@@ -183,6 +197,40 @@ export class TasksDomain {
|
||||
return this.taskService.setActiveTag(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a brief by ID, name, or partial match without switching
|
||||
* Returns the full brief object
|
||||
*
|
||||
* Supports:
|
||||
* - Full UUID
|
||||
* - Last 8 characters of UUID
|
||||
* - Brief name (exact or partial match)
|
||||
*
|
||||
* Only works with API storage (briefs).
|
||||
*
|
||||
* @param briefIdOrName - Brief identifier
|
||||
* @param orgId - Optional organization ID
|
||||
* @returns The resolved brief object
|
||||
*/
|
||||
async resolveBrief(briefIdOrName: string, orgId?: string): Promise<any> {
|
||||
return this.briefsDomain.resolveBrief(briefIdOrName, orgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different tag/brief context
|
||||
* For file storage: updates active tag in state
|
||||
* For API storage: looks up brief by name and updates auth context
|
||||
*/
|
||||
async switchTag(tagName: string): Promise<void> {
|
||||
const storageType = this.taskService.getStorageType();
|
||||
|
||||
if (storageType === 'file') {
|
||||
await this.setActiveTag(tagName);
|
||||
} else {
|
||||
await this.briefsDomain.switchBrief(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Task Execution ==========
|
||||
|
||||
/**
|
||||
@@ -266,6 +314,55 @@ export class TasksDomain {
|
||||
return this.preflightChecker.detectDefaultBranch();
|
||||
}
|
||||
|
||||
// ========== Tag Management ==========
|
||||
|
||||
/**
|
||||
* Create a new tag
|
||||
* For file storage: creates tag locally with optional task copying
|
||||
* For API storage: throws error (client should redirect to web UI)
|
||||
*/
|
||||
async createTag(name: string, options?: CreateTagOptions) {
|
||||
return this.tagService.createTag(name, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing tag
|
||||
* Cannot delete master tag
|
||||
* For file storage: deletes tag locally
|
||||
* For API storage: throws error (client should redirect to web UI)
|
||||
*/
|
||||
async deleteTag(name: string, options?: DeleteTagOptions) {
|
||||
return this.tagService.deleteTag(name, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename an existing tag
|
||||
* Cannot rename master tag
|
||||
* For file storage: renames tag locally
|
||||
* For API storage: throws error (client should redirect to web UI)
|
||||
*/
|
||||
async renameTag(oldName: string, newName: string) {
|
||||
return this.tagService.renameTag(oldName, newName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an existing tag to create a new tag with the same tasks
|
||||
* For file storage: copies tag locally
|
||||
* For API storage: throws error (client should show alternative)
|
||||
*/
|
||||
async copyTag(source: string, target: string, options?: CopyTagOptions) {
|
||||
return this.tagService.copyTag(source, target, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags with detailed statistics including task counts
|
||||
* For API storage, returns briefs with task counts
|
||||
* For file storage, returns tags from tasks.json with counts
|
||||
*/
|
||||
async getTagsWithStats() {
|
||||
return this.tagService.getTagsWithStats();
|
||||
}
|
||||
|
||||
// ========== Storage Information ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* @fileoverview Tests for WorkflowStateManager path sanitization
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { WorkflowStateManager } from './workflow-state-manager.js';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WorkflowStateManager } from './workflow-state-manager.js';
|
||||
|
||||
describe('WorkflowStateManager', () => {
|
||||
describe('getProjectIdentifier', () => {
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Writer } from 'steno';
|
||||
import type { WorkflowState } from '../types.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import type { WorkflowState } from '../types.js';
|
||||
|
||||
export interface WorkflowStateBackup {
|
||||
timestamp: string;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js';
|
||||
import type {
|
||||
WorkflowContext,
|
||||
WorkflowPhase,
|
||||
WorkflowEventData,
|
||||
WorkflowError
|
||||
} from '../types.js';
|
||||
import { TestResultValidator } from '../services/test-result-validator.js';
|
||||
import type { TestResult } from '../services/test-result-validator.types.js';
|
||||
import type {
|
||||
WorkflowContext,
|
||||
WorkflowError,
|
||||
WorkflowEventData,
|
||||
WorkflowPhase
|
||||
} from '../types.js';
|
||||
|
||||
describe('WorkflowOrchestrator - State Machine Structure', () => {
|
||||
let orchestrator: WorkflowOrchestrator;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { TestResultValidator } from '../services/test-result-validator.js';
|
||||
import type {
|
||||
WorkflowPhase,
|
||||
StateTransition,
|
||||
SubtaskInfo,
|
||||
TDDPhase,
|
||||
WorkflowContext,
|
||||
WorkflowError,
|
||||
WorkflowEvent,
|
||||
WorkflowState,
|
||||
StateTransition,
|
||||
WorkflowEventType,
|
||||
WorkflowEventData,
|
||||
WorkflowEventListener,
|
||||
SubtaskInfo,
|
||||
WorkflowError
|
||||
WorkflowEventType,
|
||||
WorkflowPhase,
|
||||
WorkflowState
|
||||
} from '../types.js';
|
||||
import type { TestResultValidator } from '../services/test-result-validator.js';
|
||||
|
||||
/**
|
||||
* Lightweight state machine for TDD workflow orchestration
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TestResultValidator } from './test-result-validator.js';
|
||||
import type {
|
||||
TestPhase,
|
||||
TestResult,
|
||||
ValidationResult,
|
||||
TestPhase
|
||||
ValidationResult
|
||||
} from './test-result-validator.types.js';
|
||||
|
||||
describe('TestResultValidator - Input Validation', () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
TestResult,
|
||||
ValidationResult,
|
||||
CoverageThresholds,
|
||||
PhaseValidationOptions
|
||||
PhaseValidationOptions,
|
||||
TestResult,
|
||||
ValidationResult
|
||||
} from './test-result-validator.types.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
* for debugging, auditing, and workflow analysis.
|
||||
*/
|
||||
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import {
|
||||
type ActivityEvent,
|
||||
logActivity
|
||||
} from '../../storage/adapters/activity-logger.js';
|
||||
import type { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js';
|
||||
import type { WorkflowEventData, WorkflowEventType } from '../types.js';
|
||||
import {
|
||||
logActivity,
|
||||
type ActivityEvent
|
||||
} from '../../storage/adapters/activity-logger.js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
|
||||
/**
|
||||
* All workflow event types that should be logged
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
* Provides a simplified API for MCP tools while delegating to WorkflowOrchestrator
|
||||
*/
|
||||
|
||||
import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js';
|
||||
import { GitAdapter } from '../../git/adapters/git-adapter.js';
|
||||
import { WorkflowStateManager } from '../managers/workflow-state-manager.js';
|
||||
import { WorkflowActivityLogger } from './workflow-activity-logger.js';
|
||||
import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js';
|
||||
import type {
|
||||
WorkflowContext,
|
||||
SubtaskInfo,
|
||||
TestResult,
|
||||
WorkflowPhase,
|
||||
TDDPhase,
|
||||
TestResult,
|
||||
WorkflowContext,
|
||||
WorkflowPhase,
|
||||
WorkflowState
|
||||
} from '../types.js';
|
||||
import { GitAdapter } from '../../git/adapters/git-adapter.js';
|
||||
import { WorkflowActivityLogger } from './workflow-activity-logger.js';
|
||||
|
||||
/**
|
||||
* Options for starting a new workflow
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
import type { ConfigManager } from '../config/managers/config-manager.js';
|
||||
import { WorkflowService } from './services/workflow.service.js';
|
||||
import type {
|
||||
NextAction,
|
||||
StartWorkflowOptions,
|
||||
WorkflowStatus,
|
||||
NextAction
|
||||
WorkflowStatus
|
||||
} from './services/workflow.service.js';
|
||||
import type { TestResult, WorkflowContext } from './types.js';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* This demonstrates how consumers can use granular imports for better tree-shaking
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Subpath Exports', () => {
|
||||
it('should allow importing from auth subpath', async () => {
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { ConfigManager } from './modules/config/managers/config-manager.js';
|
||||
import { TasksDomain } from './modules/tasks/tasks-domain.js';
|
||||
import { AuthDomain } from './modules/auth/auth-domain.js';
|
||||
import { WorkflowDomain } from './modules/workflow/workflow-domain.js';
|
||||
import { GitDomain } from './modules/git/git-domain.js';
|
||||
import { ConfigDomain } from './modules/config/config-domain.js';
|
||||
import { ConfigManager } from './modules/config/managers/config-manager.js';
|
||||
import { GitDomain } from './modules/git/git-domain.js';
|
||||
import { IntegrationDomain } from './modules/integration/integration-domain.js';
|
||||
import { TasksDomain } from './modules/tasks/tasks-domain.js';
|
||||
import { WorkflowDomain } from './modules/workflow/workflow-domain.js';
|
||||
|
||||
import {
|
||||
ERROR_CODES,
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
} from './common/errors/task-master-error.js';
|
||||
import type { IConfiguration } from './common/interfaces/configuration.interface.js';
|
||||
import {
|
||||
createLogger,
|
||||
type Logger,
|
||||
type LoggerConfig,
|
||||
type Logger
|
||||
createLogger
|
||||
} from './common/logger/index.js';
|
||||
|
||||
/**
|
||||
@@ -170,8 +170,8 @@ export class TmCore {
|
||||
}
|
||||
|
||||
// Initialize domain facades
|
||||
this._tasks = new TasksDomain(this._configManager);
|
||||
this._auth = new AuthDomain();
|
||||
this._tasks = new TasksDomain(this._configManager, this._auth);
|
||||
this._workflow = new WorkflowDomain(this._configManager);
|
||||
this._git = new GitDomain(this._projectPath);
|
||||
this._config = new ConfigDomain(this._configManager);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthManager } from '../../src/auth/auth-manager';
|
||||
import { CredentialStore } from '../../src/auth/credential-store';
|
||||
import type { AuthCredentials } from '../../src/auth/types';
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
* when making API calls through AuthManager.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthManager } from '../../src/modules/auth/managers/auth-manager.js';
|
||||
import { CredentialStore } from '../../src/modules/auth/services/credential-store.js';
|
||||
import type { AuthCredentials } from '../../src/modules/auth/types.js';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
filterActivityLog,
|
||||
logActivity,
|
||||
readActivityLog,
|
||||
filterActivityLog
|
||||
readActivityLog
|
||||
} from '../../../src/storage/activity-logger.js';
|
||||
|
||||
describe('Activity Logger', () => {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* Tests for executor functionality
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ExecutorFactory,
|
||||
ClaudeExecutor,
|
||||
ExecutorFactory,
|
||||
type ExecutorOptions
|
||||
} from '../../src/executors/index.js';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user