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