Compare commits

..

7 Commits

Author SHA1 Message Date
github-actions[bot]
9acb900153 Version Packages (#1303)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
2025-10-14 11:47:13 +02:00
Ralph Khreish
c4f5d89e72 Merge pull request #1297 from eyaltoledano/next 2025-10-14 10:08:08 +02:00
Ralph Khreish
e308cf4f46 chore: exit pre mode 2025-10-13 22:46:24 +02:00
Ralph Khreish
11b7354010 fix: export url (#1288) 2025-10-13 21:51:19 +02:00
Ralph Khreish
4c1ef2ca94 fix: auth refresh token (#1299)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-13 21:50:22 +02:00
Ralph Khreish
663aa2dfe9 chore: fix CI 2025-10-12 17:10:24 +02:00
Ralph Khreish
8f60a0561e chore: add hiring banner 2025-10-12 16:52:58 +02:00
38 changed files with 1239 additions and 279 deletions

View File

@@ -1,7 +0,0 @@
---
"task-master-ai": minor
---
Add changelog highlights to auto-update notifications
When the CLI auto-updates to a new version, it now displays a "What's New" section.

View File

@@ -11,6 +11,7 @@
"access": "public",
"baseBranch": "main",
"ignore": [
"docs"
"docs",
"@tm/claude-code-plugin"
]
}

View File

@@ -1,47 +0,0 @@
---
"task-master-ai": minor
---
Add Claude Code plugin with marketplace distribution
This release introduces official Claude Code plugin support, marking the evolution from legacy `.claude` directory copying to a modern plugin-based architecture.
## 🎉 New: Claude Code Plugin
Task Master AI commands and agents are now distributed as a proper Claude Code plugin:
- **49 slash commands** with clean naming (`/taskmaster:command-name`)
- **3 specialized AI agents** (task-orchestrator, task-executor, task-checker)
- **MCP server integration** for deep Claude Code integration
**Installation:**
```bash
/plugin marketplace add eyaltoledano/claude-task-master
/plugin install taskmaster@taskmaster
```
### The `rules add claude` command no longer copies commands and agents to `.claude/commands/` and `.claude/agents/`. Instead, it now
- Shows plugin installation instructions
- Only manages CLAUDE.md imports for agent instructions
- Directs users to install the official plugin
**Migration for Existing Users:**
If you previously used `rules add claude`:
1. The old commands in `.claude/commands/` will continue to work but won't receive updates
2. Install the plugin for the latest features: `/plugin install taskmaster@taskmaster`
3. remove old `.claude/commands/` and `.claude/agents/` directories
**Why This Change?**
Claude Code plugins provide:
- ✅ Automatic updates when we release new features
- ✅ Better command organization and naming
- ✅ Seamless integration with Claude Code
- ✅ No manual file copying or management
The plugin system is the future of Task Master AI integration with Claude Code!

View File

@@ -1,17 +0,0 @@
---
"task-master-ai": minor
---
Add RPG (Repository Planning Graph) method template for structured PRD creation. The new `example_prd_rpg.txt` template teaches AI agents and developers the RPG methodology through embedded instructions, inline good/bad examples, and XML-style tags for structure. This template enables creation of dependency-aware PRDs that automatically generate topologically-ordered task graphs when parsed with Task Master.
Key features:
- Method-as-template: teaches RPG principles (dual-semantics, explicit dependencies, topological order) while being used
- Inline instructions at decision points guide AI through each section
- Good/bad examples for immediate pattern matching
- Flexible plain-text format with XML-style tags for parseability
- Critical dependency-graph section ensures correct task ordering
- Automatic inclusion during `task-master init`
- Comprehensive documentation at [docs.task-master.dev/capabilities/rpg-method](https://docs.task-master.dev/capabilities/rpg-method)
- Tool recommendations for code-context-aware PRD creation (Claude Code, Cursor, Gemini CLI, Codex/Grok)
The RPG template complements the existing `example_prd.txt` and provides a more structured approach for complex projects requiring clear module boundaries and dependency chains.

View File

@@ -1,7 +0,0 @@
---
"task-master-ai": patch
---
Fix cross-level task dependencies not being saved
Fixes an issue where adding dependencies between subtasks and top-level tasks (e.g., `task-master add-dependency --id=2.2 --depends-on=11`) would report success but fail to persist the changes. Dependencies can now be created in both directions between any task levels.

View File

@@ -1,21 +0,0 @@
{
"mode": "exit",
"tag": "rc",
"initialVersions": {
"task-master-ai": "0.28.0",
"@tm/cli": "",
"docs": "0.0.5",
"extension": "0.25.5",
"@tm/ai-sdk-provider-grok-cli": "",
"@tm/build-config": "",
"@tm/claude-code-plugin": "0.0.1",
"@tm/core": ""
},
"changesets": [
"auto-update-changelog-highlights",
"mean-planes-wave",
"nice-ways-hope",
"plain-falcons-serve",
"smart-owls-relax"
]
}

View File

@@ -1,16 +0,0 @@
---
"task-master-ai": minor
---
Enhance `expand_all` to intelligently use complexity analysis recommendations when expanding tasks.
The expand-all operation now automatically leverages recommendations from `analyze-complexity` to determine optimal subtask counts for each task, resulting in more accurate and context-aware task breakdowns.
Key improvements:
- Automatic integration with complexity analysis reports
- Tag-aware complexity report path resolution
- Intelligent subtask count determination based on task complexity
- Falls back to defaults when complexity analysis is unavailable
- Enhanced logging for better visibility into expansion decisions
When you run `task-master expand --all` after `task-master analyze-complexity`, Task Master now uses the recommended subtask counts from the complexity analysis instead of applying uniform defaults, ensuring each task is broken down according to its actual complexity.

View File

@@ -1,5 +1,94 @@
# task-master-ai
## 0.29.0
### Minor Changes
- [#1286](https://github.com/eyaltoledano/claude-task-master/pull/1286) [`f12a16d`](https://github.com/eyaltoledano/claude-task-master/commit/f12a16d09649f62148515f11f616157c7d0bd2d5) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Add changelog highlights to auto-update notifications
When the CLI auto-updates to a new version, it now displays a "What's New" section.
- [#1293](https://github.com/eyaltoledano/claude-task-master/pull/1293) [`3010b90`](https://github.com/eyaltoledano/claude-task-master/commit/3010b90d98f3a7d8636caa92fc33d6ee69d4bed0) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Add Claude Code plugin with marketplace distribution
This release introduces official Claude Code plugin support, marking the evolution from legacy `.claude` directory copying to a modern plugin-based architecture.
## 🎉 New: Claude Code Plugin
Task Master AI commands and agents are now distributed as a proper Claude Code plugin:
- **49 slash commands** with clean naming (`/taskmaster:command-name`)
- **3 specialized AI agents** (task-orchestrator, task-executor, task-checker)
- **MCP server integration** for deep Claude Code integration
**Installation:**
```bash
/plugin marketplace add eyaltoledano/claude-task-master
/plugin install taskmaster@taskmaster
```
### The `rules add claude` command no longer copies commands and agents to `.claude/commands/` and `.claude/agents/`. Instead, it now
- Shows plugin installation instructions
- Only manages CLAUDE.md imports for agent instructions
- Directs users to install the official plugin
**Migration for Existing Users:**
If you previously used `rules add claude`:
1. The old commands in `.claude/commands/` will continue to work but won't receive updates
2. Install the plugin for the latest features: `/plugin install taskmaster@taskmaster`
3. remove old `.claude/commands/` and `.claude/agents/` directories
**Why This Change?**
Claude Code plugins provide:
- ✅ Automatic updates when we release new features
- ✅ Better command organization and naming
- ✅ Seamless integration with Claude Code
- ✅ No manual file copying or management
The plugin system is the future of Task Master AI integration with Claude Code!
- [#1285](https://github.com/eyaltoledano/claude-task-master/pull/1285) [`2a910a4`](https://github.com/eyaltoledano/claude-task-master/commit/2a910a40bac375f9f61d797bf55597303d556b48) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Add RPG (Repository Planning Graph) method template for structured PRD creation. The new `example_prd_rpg.txt` template teaches AI agents and developers the RPG methodology through embedded instructions, inline good/bad examples, and XML-style tags for structure. This template enables creation of dependency-aware PRDs that automatically generate topologically-ordered task graphs when parsed with Task Master.
Key features:
- Method-as-template: teaches RPG principles (dual-semantics, explicit dependencies, topological order) while being used
- Inline instructions at decision points guide AI through each section
- Good/bad examples for immediate pattern matching
- Flexible plain-text format with XML-style tags for parseability
- Critical dependency-graph section ensures correct task ordering
- Automatic inclusion during `task-master init`
- Comprehensive documentation at [docs.task-master.dev/capabilities/rpg-method](https://docs.task-master.dev/capabilities/rpg-method)
- Tool recommendations for code-context-aware PRD creation (Claude Code, Cursor, Gemini CLI, Codex/Grok)
The RPG template complements the existing `example_prd.txt` and provides a more structured approach for complex projects requiring clear module boundaries and dependency chains.
- [#1287](https://github.com/eyaltoledano/claude-task-master/pull/1287) [`90e6bdc`](https://github.com/eyaltoledano/claude-task-master/commit/90e6bdcf1c59f65ad27fcdfe3b13b9dca7e77654) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Enhance `expand_all` to intelligently use complexity analysis recommendations when expanding tasks.
The expand-all operation now automatically leverages recommendations from `analyze-complexity` to determine optimal subtask counts for each task, resulting in more accurate and context-aware task breakdowns.
Key improvements:
- Automatic integration with complexity analysis reports
- Tag-aware complexity report path resolution
- Intelligent subtask count determination based on task complexity
- Falls back to defaults when complexity analysis is unavailable
- Enhanced logging for better visibility into expansion decisions
When you run `task-master expand --all` after `task-master analyze-complexity`, Task Master now uses the recommended subtask counts from the complexity analysis instead of applying uniform defaults, ensuring each task is broken down according to its actual complexity.
### Patch Changes
- [#1191](https://github.com/eyaltoledano/claude-task-master/pull/1191) [`aaf903f`](https://github.com/eyaltoledano/claude-task-master/commit/aaf903ff2f606c779a22e9a4b240ab57b3683815) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Fix cross-level task dependencies not being saved
Fixes an issue where adding dependencies between subtasks and top-level tasks (e.g., `task-master add-dependency --id=2.2 --depends-on=11`) would report success but fail to persist the changes. Dependencies can now be created in both directions between any task levels.
- [#1299](https://github.com/eyaltoledano/claude-task-master/pull/1299) [`4c1ef2c`](https://github.com/eyaltoledano/claude-task-master/commit/4c1ef2ca94411c53bcd2a78ec710b06c500236dd) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Improve refresh token when authenticating
## 0.29.0-rc.1
### Patch Changes
- [#1299](https://github.com/eyaltoledano/claude-task-master/pull/1299) [`a6c5152`](https://github.com/eyaltoledano/claude-task-master/commit/a6c5152f20edd8717cf1aea34e7c178b1261aa99) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Improve refresh token when authenticating
## 0.29.0-rc.0
### Minor Changes

View File

@@ -310,6 +310,12 @@ cd claude-task-master
node scripts/init.js
```
## Join Our Team
<a href="https://tryhamster.com" target="_blank">
<img src="./images/hamster-hiring.png" alt="Join Hamster's founding team" />
</a>
## Contributors
<a href="https://github.com/eyaltoledano/claude-task-master/graphs/contributors">

View File

@@ -11,6 +11,13 @@
### Patch Changes
- Updated dependencies []:
- @tm/core@null
## null
### Patch Changes
- Updated dependencies []:
- @tm/core@null

View File

@@ -143,7 +143,7 @@ export class AuthCommand extends Command {
*/
private async executeStatus(): Promise<void> {
try {
const result = this.displayStatus();
const result = await this.displayStatus();
this.setLastResult(result);
} catch (error: any) {
this.handleError(error);
@@ -171,8 +171,8 @@ export class AuthCommand extends Command {
/**
* Display authentication status
*/
private displayStatus(): AuthResult {
const credentials = this.authManager.getCredentials();
private async displayStatus(): Promise<AuthResult> {
const credentials = await this.authManager.getCredentials();
console.log(chalk.cyan('\n🔐 Authentication Status\n'));
@@ -187,19 +187,29 @@ export class AuthCommand extends Command {
if (credentials.expiresAt) {
const expiresAt = new Date(credentials.expiresAt);
const now = new Date();
const hoursRemaining = Math.floor(
(expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60)
);
const timeRemaining = expiresAt.getTime() - now.getTime();
const hoursRemaining = Math.floor(timeRemaining / (1000 * 60 * 60));
const minutesRemaining = Math.floor(timeRemaining / (1000 * 60));
if (hoursRemaining > 0) {
console.log(
chalk.gray(
` Expires: ${expiresAt.toLocaleString()} (${hoursRemaining} hours remaining)`
)
);
if (timeRemaining > 0) {
// Token is still valid
if (hoursRemaining > 0) {
console.log(
chalk.gray(
` Expires at: ${expiresAt.toLocaleString()} (${hoursRemaining} hours remaining)`
)
);
} else {
console.log(
chalk.gray(
` Expires at: ${expiresAt.toLocaleString()} (${minutesRemaining} minutes remaining)`
)
);
}
} else {
// Token has expired
console.log(
chalk.yellow(` Token expired at: ${expiresAt.toLocaleString()}`)
chalk.yellow(` Expired at: ${expiresAt.toLocaleString()}`)
);
}
} else {
@@ -315,7 +325,7 @@ export class AuthCommand extends Command {
]);
if (!continueAuth) {
const credentials = this.authManager.getCredentials();
const credentials = await this.authManager.getCredentials();
ui.displaySuccess('Using existing authentication');
if (credentials) {
@@ -480,7 +490,7 @@ export class AuthCommand extends Command {
/**
* Get current credentials (for programmatic usage)
*/
getCredentials(): AuthCredentials | null {
getCredentials(): Promise<AuthCredentials | null> {
return this.authManager.getCredentials();
}

View File

@@ -115,7 +115,7 @@ export class ContextCommand extends Command {
*/
private async executeShow(): Promise<void> {
try {
const result = this.displayContext();
const result = await this.displayContext();
this.setLastResult(result);
} catch (error: any) {
this.handleError(error);
@@ -126,7 +126,7 @@ export class ContextCommand extends Command {
/**
* Display current context
*/
private displayContext(): ContextResult {
private async displayContext(): Promise<ContextResult> {
// Check authentication first
if (!this.authManager.isAuthenticated()) {
console.log(chalk.yellow('✗ Not authenticated'));
@@ -139,7 +139,7 @@ export class ContextCommand extends Command {
};
}
const context = this.authManager.getContext();
const context = await this.authManager.getContext();
console.log(chalk.cyan('\n🌍 Workspace Context\n'));
@@ -263,7 +263,7 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'select-org',
context: this.authManager.getContext() || undefined,
context: (await this.authManager.getContext()) || undefined,
message: `Selected organization: ${selectedOrg.name}`
};
} catch (error) {
@@ -284,7 +284,7 @@ export class ContextCommand extends Command {
}
// Check if org is selected
const context = this.authManager.getContext();
const context = await this.authManager.getContext();
if (!context?.orgId) {
ui.displayError(
'No organization selected. Run "tm context org" first.'
@@ -353,7 +353,7 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'select-brief',
context: this.authManager.getContext() || undefined,
context: (await this.authManager.getContext()) || undefined,
message: `Selected brief: ${selectedBrief.name}`
};
} else {
@@ -368,7 +368,7 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'select-brief',
context: this.authManager.getContext() || undefined,
context: (await this.authManager.getContext()) || undefined,
message: 'Cleared brief selection'
};
}
@@ -508,7 +508,7 @@ export class ContextCommand extends Command {
this.setLastResult({
success: true,
action: 'set',
context: this.authManager.getContext() || undefined,
context: (await this.authManager.getContext()) || undefined,
message: 'Context set from brief'
});
} catch (error: any) {
@@ -631,7 +631,7 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'set',
context: this.authManager.getContext() || undefined,
context: (await this.authManager.getContext()) || undefined,
message: 'Context updated'
};
} catch (error) {
@@ -682,7 +682,7 @@ export class ContextCommand extends Command {
/**
* Get current context (for programmatic usage)
*/
getContext(): UserContext | null {
getContext(): Promise<UserContext | null> {
return this.authManager.getContext();
}

View File

@@ -103,7 +103,7 @@ export class ExportCommand extends Command {
await this.initializeServices();
// Get current context
const context = this.authManager.getContext();
const context = await this.authManager.getContext();
// Determine org and brief IDs
let orgId = options?.org || context?.orgId;

View File

@@ -1,5 +1,7 @@
# docs
## 0.0.6
## 0.0.5
## 0.0.4

View File

@@ -31,23 +31,9 @@ cursor://anysphere.cursor-deeplink/mcp/install?name=taskmaster-ai&config=eyJjb21
> **Note:** After clicking the link, you'll still need to add your API keys to the configuration. The link installs the MCP server with placeholder keys that you'll need to replace with your actual API keys.
### Claude Code Plugin Install (Recommended)
### Claude Code Quick Install
For Claude Code users, install via the plugin marketplace:
```bash
/plugin marketplace add eyaltoledano/claude-task-master
/plugin install taskmaster@taskmaster
```
This provides:
- **49 slash commands** with clean naming (`/taskmaster:command-name`)
- **3 specialized AI agents** (task-orchestrator, task-executor, task-checker)
- **Automatic updates** when new features are released
### Claude Code MCP Alternative
You can also use MCP directly:
For Claude Code users:
```bash
claude mcp add taskmaster-ai -- npx -y task-master-ai

View File

@@ -1,6 +1,6 @@
{
"name": "docs",
"version": "0.0.5",
"version": "0.0.6",
"private": true,
"description": "Task Master documentation powered by Mintlify",
"scripts": {

View File

@@ -3,44 +3,4 @@ title: "What's New"
sidebarTitle: "What's New"
---
## 🎉 New: Claude Code Plugin Support
Task Master AI now supports Claude Code plugins with modern marketplace distribution!
### What's New
- **49 slash commands** with clean naming (`/taskmaster:command-name`)
- **3 specialized AI agents** (task-orchestrator, task-executor, task-checker)
- **MCP server integration** for deep Claude Code integration
### Installation
```bash
/plugin marketplace add eyaltoledano/claude-task-master
/plugin install taskmaster@taskmaster
```
### Migration for Existing Users
The `rules add claude` command no longer copies files to `.claude/` directories. Instead:
- Shows plugin installation instructions
- Only manages CLAUDE.md imports for agent instructions
- Directs users to install the official plugin
If you previously used `rules add claude`:
1. Old commands in `.claude/commands/` will continue working but won't receive updates
2. Install the plugin for latest features: `/plugin install taskmaster@taskmaster`
3. Remove old `.claude/commands/` and `.claude/agents/` directories
### Why This Change?
Claude Code plugins provide:
- ✅ Automatic updates when we release new features
- ✅ Better command organization and naming
- ✅ Seamless integration with Claude Code
- ✅ No manual file copying or management
The plugin system is the future of Task Master AI integration with Claude Code!
An easy way to see the latest releases

View File

@@ -1,5 +1,7 @@
# Change Log
## 0.25.6
## 0.25.6-rc.0
### Patch Changes

View File

@@ -3,7 +3,7 @@
"private": true,
"displayName": "TaskMaster",
"description": "A visual Kanban board interface for TaskMaster projects in VS Code",
"version": "0.25.6-rc.0",
"version": "0.25.6",
"publisher": "Hamster",
"icon": "assets/icon.png",
"engines": {
@@ -239,9 +239,6 @@
"watch:css": "npx @tailwindcss/cli -i ./src/webview/index.css -o ./dist/index.css --watch",
"check-types": "tsc --noEmit"
},
"dependencies": {
"task-master-ai": "0.29.0-rc.0"
},
"devDependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -277,7 +274,8 @@
"tailwind-merge": "^3.3.1",
"tailwindcss": "4.1.11",
"typescript": "^5.9.2",
"@tm/core": "*"
"@tm/core": "*",
"task-master-ai": "*"
},
"overrides": {
"glob@<8": "^10.4.5",

BIN
images/hamster-hiring.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

File diff suppressed because one or more lines are too long

143
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "task-master-ai",
"version": "npm:task-master-ai@0.29.0-rc.0",
"version": "0.29.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "task-master-ai",
"version": "0.29.0-rc.0",
"version": "0.29.0",
"license": "MIT WITH Commons-Clause",
"workspaces": [
"apps/*",
@@ -125,13 +125,58 @@
}
},
"apps/docs": {
"version": "0.0.5",
"version": "0.0.6",
"devDependencies": {
"mintlify": "^4.2.111"
}
},
"apps/extension": {
"version": "0.25.6",
"devDependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@modelcontextprotocol/sdk": "1.13.3",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-portal": "^1.1.9",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.83.0",
"@tm/core": "*",
"@types/mocha": "^10.0.10",
"@types/node": "^22.10.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/vscode": "^1.101.0",
"@vscode/test-cli": "^0.0.11",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^2.32.0",
"autoprefixer": "10.4.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"esbuild": "^0.25.3",
"esbuild-postcss": "^0.0.4",
"fs-extra": "^11.3.0",
"lucide-react": "^0.525.0",
"npm-run-all": "^4.1.5",
"postcss": "8.5.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "4.1.11",
"task-master-ai": "*",
"typescript": "^5.9.2"
},
"engines": {
"vscode": "^1.93.0"
}
},
"apps/extension/node_modules/@ai-sdk/amazon-bedrock": {
"version": "2.2.12",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -149,6 +194,7 @@
},
"apps/extension/node_modules/@ai-sdk/anthropic": {
"version": "1.2.12",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -163,6 +209,7 @@
},
"apps/extension/node_modules/@ai-sdk/azure": {
"version": "1.3.25",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/openai": "1.3.24",
@@ -178,6 +225,7 @@
},
"apps/extension/node_modules/@ai-sdk/google": {
"version": "1.2.22",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -192,6 +240,7 @@
},
"apps/extension/node_modules/@ai-sdk/google-vertex": {
"version": "2.2.27",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "1.2.12",
@@ -209,6 +258,7 @@
},
"apps/extension/node_modules/@ai-sdk/groq": {
"version": "1.2.9",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -223,6 +273,7 @@
},
"apps/extension/node_modules/@ai-sdk/mistral": {
"version": "1.2.8",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -237,6 +288,7 @@
},
"apps/extension/node_modules/@ai-sdk/openai": {
"version": "1.3.24",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -251,6 +303,7 @@
},
"apps/extension/node_modules/@ai-sdk/openai-compatible": {
"version": "0.2.16",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -265,6 +318,7 @@
},
"apps/extension/node_modules/@ai-sdk/perplexity": {
"version": "1.1.9",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -279,6 +333,7 @@
},
"apps/extension/node_modules/@ai-sdk/provider": {
"version": "1.1.3",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
@@ -289,6 +344,7 @@
},
"apps/extension/node_modules/@ai-sdk/provider-utils": {
"version": "2.2.8",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -304,6 +360,7 @@
},
"apps/extension/node_modules/@ai-sdk/react": {
"version": "1.2.12",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "2.2.8",
@@ -326,6 +383,7 @@
},
"apps/extension/node_modules/@ai-sdk/ui-utils": {
"version": "1.2.11",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -341,6 +399,7 @@
},
"apps/extension/node_modules/@ai-sdk/xai": {
"version": "1.2.18",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/openai-compatible": "0.2.16",
@@ -356,6 +415,7 @@
},
"apps/extension/node_modules/@openrouter/ai-sdk-provider": {
"version": "0.4.6",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.0.9",
@@ -370,6 +430,7 @@
},
"apps/extension/node_modules/@openrouter/ai-sdk-provider/node_modules/@ai-sdk/provider": {
"version": "1.0.9",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
@@ -380,6 +441,7 @@
},
"apps/extension/node_modules/@openrouter/ai-sdk-provider/node_modules/@ai-sdk/provider-utils": {
"version": "2.1.10",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.0.9",
@@ -401,6 +463,7 @@
},
"apps/extension/node_modules/ai": {
"version": "4.3.19",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -425,6 +488,7 @@
},
"apps/extension/node_modules/ai-sdk-provider-gemini-cli": {
"version": "0.1.3",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -458,6 +522,7 @@
},
"apps/extension/node_modules/ollama-ai-provider": {
"version": "1.2.0",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "^1.0.0",
@@ -478,6 +543,7 @@
},
"apps/extension/node_modules/openai": {
"version": "4.104.0",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
@@ -506,6 +572,7 @@
},
"apps/extension/node_modules/openai/node_modules/@types/node": {
"version": "18.19.127",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
@@ -513,10 +580,12 @@
},
"apps/extension/node_modules/openai/node_modules/undici-types": {
"version": "5.26.5",
"dev": true,
"license": "MIT"
},
"apps/extension/node_modules/task-master-ai": {
"version": "0.27.1",
"dev": true,
"license": "MIT WITH Commons-Clause",
"workspaces": [
"apps/*",
@@ -588,6 +657,7 @@
},
"apps/extension/node_modules/zod": {
"version": "3.25.76",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -595,6 +665,7 @@
},
"apps/extension/node_modules/zod-to-json-schema": {
"version": "3.24.6",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
@@ -883,6 +954,7 @@
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.39.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
@@ -896,6 +968,7 @@
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.127",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
@@ -903,6 +976,7 @@
},
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
"version": "5.26.5",
"dev": true,
"license": "MIT"
},
"node_modules/@ark/schema": {
@@ -8362,6 +8436,7 @@
},
"node_modules/@types/diff-match-patch": {
"version": "1.0.36",
"dev": true,
"license": "MIT"
},
"node_modules/@types/es-aggregate-error": {
@@ -8532,6 +8607,7 @@
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -8977,6 +9053,7 @@
},
"node_modules/abort-controller": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
@@ -9038,6 +9115,7 @@
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"humanize-ms": "^1.2.1"
@@ -9620,6 +9698,7 @@
},
"node_modules/asynckit": {
"version": "0.4.0",
"dev": true,
"license": "MIT"
},
"node_modules/auto-bind": {
@@ -11397,6 +11476,7 @@
},
"node_modules/combined-stream": {
"version": "1.0.8",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -12035,6 +12115,7 @@
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -12057,6 +12138,7 @@
},
"node_modules/dequal": {
"version": "2.0.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -12176,6 +12258,7 @@
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/diff-sequences": {
@@ -12675,6 +12758,7 @@
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -12963,6 +13047,7 @@
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -14001,6 +14086,7 @@
},
"node_modules/form-data": {
"version": "4.0.4",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -14015,6 +14101,7 @@
},
"node_modules/form-data-encoder": {
"version": "1.7.2",
"dev": true,
"license": "MIT"
},
"node_modules/format": {
@@ -14026,6 +14113,7 @@
},
"node_modules/formdata-node": {
"version": "4.4.1",
"dev": true,
"license": "MIT",
"dependencies": {
"node-domexception": "1.0.0",
@@ -14686,6 +14774,7 @@
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -15260,6 +15349,7 @@
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.0.0"
@@ -18046,6 +18136,7 @@
},
"node_modules/jsondiffpatch": {
"version": "0.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/diff-match-patch": "^1.0.36",
@@ -18309,6 +18400,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -20226,6 +20318,7 @@
},
"node_modules/nanoid": {
"version": "3.3.11",
"devOptional": true,
"funding": [
{
"type": "github",
@@ -20334,6 +20427,7 @@
},
"node_modules/node-domexception": {
"version": "1.0.0",
"dev": true,
"funding": [
{
"type": "github",
@@ -21198,6 +21292,7 @@
},
"node_modules/partial-json": {
"version": "0.1.7",
"dev": true,
"license": "MIT"
},
"node_modules/patch-console": {
@@ -21970,6 +22065,7 @@
},
"node_modules/react": {
"version": "19.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -23096,6 +23192,7 @@
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/selderee": {
@@ -24143,6 +24240,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/strnum": {
"version": "2.1.1",
"funding": [
@@ -24320,6 +24437,7 @@
},
"node_modules/swr": {
"version": "2.3.6",
"dev": true,
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
@@ -24512,6 +24630,7 @@
},
"node_modules/throttleit": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -25614,6 +25733,7 @@
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"dev": true,
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -25866,6 +25986,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -26021,6 +26142,7 @@
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -27014,19 +27136,9 @@
},
"packages/claude-code-plugin": {
"name": "@tm/claude-code-plugin",
"version": "0.0.1",
"version": "0.0.2",
"license": "MIT WITH Commons-Clause"
},
"packages/claude-code-plugin/node_modules/@types/node": {
"version": "20.19.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.20.tgz",
"integrity": "sha512-2Q7WS25j4pS1cS8yw3d6buNCVJukOTeQ39bAnwR6sOJbaxvyCGebzTMypDFN82CxBLnl+lSWVdCCWbRY6y9yZQ==",
"extraneous": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"packages/tm-core": {
"name": "@tm/core",
"license": "MIT",
@@ -27037,6 +27149,7 @@
"devDependencies": {
"@types/node": "^22.10.5",
"@vitest/coverage-v8": "^3.2.4",
"strip-literal": "3.1.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
@@ -27346,6 +27459,8 @@
},
"packages/tm-core/node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "task-master-ai",
"version": "0.29.0-rc.0",
"version": "0.29.0",
"description": "A task management system for ambitious AI-driven development that doesn't overwhelm and confuse Cursor.",
"main": "index.js",
"type": "module",

View File

@@ -1,3 +1,5 @@
# @tm/ai-sdk-provider-grok-cli
## null
## null

View File

@@ -4,4 +4,6 @@
## null
## null
## 1.0.1

View File

@@ -0,0 +1,3 @@
# @tm/claude-code-plugin
## 0.0.2

View File

@@ -1,6 +1,6 @@
{
"name": "@tm/claude-code-plugin",
"version": "0.0.1",
"version": "0.0.2",
"description": "Task Master AI plugin for Claude Code - AI-powered task management with commands, agents, and MCP integration",
"type": "module",
"private": true,

View File

@@ -4,6 +4,8 @@
## null
## null
## 0.26.1
All notable changes to the @task-master/tm-core package will be documented in this file.

View File

@@ -37,7 +37,8 @@
"@types/node": "^22.10.5",
"@vitest/coverage-v8": "^3.2.4",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"strip-literal": "3.1.0"
},
"files": ["src", "README.md", "CHANGELOG.md"],
"keywords": ["task-management", "typescript", "ai", "prd", "parser"],

View File

@@ -21,11 +21,16 @@ const CredentialStoreSpy = vi.fn();
vi.mock('./credential-store.js', () => {
return {
CredentialStore: class {
static getInstance(config?: any) {
return new (this as any)(config);
}
static resetInstance() {
// Mock reset instance method
}
constructor(config: any) {
CredentialStoreSpy(config);
this.getCredentials = vi.fn(() => null);
}
getCredentials() {
getCredentials(_options?: any) {
return null;
}
saveCredentials() {}
@@ -85,7 +90,7 @@ describe('AuthManager Singleton', () => {
expect(instance1).toBe(instance2);
});
it('should use config on first call', () => {
it('should use config on first call', async () => {
const config = {
baseUrl: 'https://test.auth.com',
configDir: '/test/config',
@@ -101,7 +106,7 @@ describe('AuthManager Singleton', () => {
// Verify the config is passed to internal components through observable behavior
// getCredentials would look in the configured file path
const credentials = instance.getCredentials();
const credentials = await instance.getCredentials();
expect(credentials).toBeNull(); // File doesn't exist, but config was propagated correctly
});

View File

@@ -29,6 +29,8 @@ export class AuthManager {
private oauthService: OAuthService;
private supabaseClient: SupabaseAuthClient;
private organizationService?: OrganizationService;
private logger = getLogger('AuthManager');
private refreshPromise: Promise<AuthCredentials> | null = null;
private constructor(config?: Partial<AuthConfig>) {
this.credentialStore = CredentialStore.getInstance(config);
@@ -36,7 +38,10 @@ export class AuthManager {
this.oauthService = new OAuthService(this.credentialStore, config);
// Initialize Supabase client with session restoration
this.initializeSupabaseSession();
// Fire-and-forget with catch handler to prevent unhandled rejections
this.initializeSupabaseSession().catch(() => {
// Errors are already logged in initializeSupabaseSession
});
}
/**
@@ -78,8 +83,60 @@ export class AuthManager {
/**
* Get stored authentication credentials
* Automatically refreshes the token if expired
*/
getCredentials(): AuthCredentials | null {
async getCredentials(): Promise<AuthCredentials | null> {
const credentials = this.credentialStore.getCredentials({
allowExpired: true
});
if (!credentials) {
return null;
}
// Check if credentials are expired (with 30-second clock skew buffer)
const CLOCK_SKEW_MS = 30_000;
const isExpired = credentials.expiresAt
? new Date(credentials.expiresAt).getTime() <= Date.now() + CLOCK_SKEW_MS
: false;
// If expired and we have a refresh token, attempt refresh
if (isExpired && credentials.refreshToken) {
// Return existing refresh promise if one is in progress
if (this.refreshPromise) {
try {
return await this.refreshPromise;
} catch {
return null;
}
}
try {
this.logger.info('Token expired, attempting automatic refresh...');
this.refreshPromise = this.refreshToken();
const result = await this.refreshPromise;
return result;
} catch (error) {
this.logger.warn('Automatic token refresh failed:', error);
return null;
} finally {
this.refreshPromise = null;
}
}
// Return null if expired and no refresh token
if (isExpired) {
return null;
}
return credentials;
}
/**
* Get stored authentication credentials (synchronous version)
* Does not attempt automatic refresh
*/
getCredentialsSync(): AuthCredentials | null {
return this.credentialStore.getCredentials();
}
@@ -171,8 +228,8 @@ export class AuthManager {
/**
* Get the current user context (org/brief selection)
*/
getContext(): UserContext | null {
const credentials = this.getCredentials();
async getContext(): Promise<UserContext | null> {
const credentials = await this.getCredentials();
return credentials?.selectedContext || null;
}
@@ -180,7 +237,7 @@ export class AuthManager {
* Update the user context (org/brief selection)
*/
async updateContext(context: Partial<UserContext>): Promise<void> {
const credentials = this.getCredentials();
const credentials = await this.getCredentials();
if (!credentials) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
}
@@ -206,7 +263,7 @@ export class AuthManager {
* Clear the user context
*/
async clearContext(): Promise<void> {
const credentials = this.getCredentials();
const credentials = await this.getCredentials();
if (!credentials) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
}
@@ -223,7 +280,7 @@ export class AuthManager {
private async getOrganizationService(): Promise<OrganizationService> {
if (!this.organizationService) {
// First check if we have credentials with a token
const credentials = this.getCredentials();
const credentials = await this.getCredentials();
if (!credentials || !credentials.token) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
}

View File

@@ -0,0 +1,289 @@
/**
* @fileoverview Unit tests for CredentialStore token expiration handling
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { CredentialStore } from './credential-store';
import type { AuthCredentials } from './types';
describe('CredentialStore - Token Expiration', () => {
let credentialStore: CredentialStore;
let tmpDir: string;
let authFile: string;
beforeEach(() => {
// Create temp directory for test credentials
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-cred-test-'));
authFile = path.join(tmpDir, 'auth.json');
// Create instance with test config
CredentialStore.resetInstance();
credentialStore = CredentialStore.getInstance({
configDir: tmpDir,
configFile: authFile
});
});
afterEach(() => {
// Clean up
try {
if (fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors
}
CredentialStore.resetInstance();
});
describe('Expiration Detection', () => {
it('should return null for expired token', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(), // 1 minute ago
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
const retrieved = credentialStore.getCredentials();
expect(retrieved).toBeNull();
});
it('should return credentials for valid token', () => {
const validCredentials: AuthCredentials = {
token: 'valid-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(validCredentials);
const retrieved = credentialStore.getCredentials();
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('valid-token');
});
it('should return expired token when allowExpired is true', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
const retrieved = credentialStore.getCredentials({ allowExpired: true });
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('expired-token');
});
});
describe('Clock Skew Tolerance', () => {
it('should reject token expiring within 30-second buffer', () => {
// Token expires in 15 seconds (within 30-second buffer)
const almostExpiredCredentials: AuthCredentials = {
token: 'almost-expired-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 15000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(almostExpiredCredentials);
const retrieved = credentialStore.getCredentials();
expect(retrieved).toBeNull();
});
it('should accept token expiring outside 30-second buffer', () => {
// Token expires in 60 seconds (outside 30-second buffer)
const validCredentials: AuthCredentials = {
token: 'valid-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(validCredentials);
const retrieved = credentialStore.getCredentials();
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('valid-token');
});
});
describe('Timestamp Format Handling', () => {
it('should handle ISO string timestamps', () => {
const credentials: AuthCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials();
expect(retrieved).not.toBeNull();
expect(typeof retrieved?.expiresAt).toBe('number'); // Normalized to number
});
it('should handle numeric timestamps', () => {
const credentials: AuthCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: Date.now() + 3600000,
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials();
expect(retrieved).not.toBeNull();
expect(typeof retrieved?.expiresAt).toBe('number');
});
it('should return null for invalid timestamp format', () => {
// Manually write invalid timestamp to file
const invalidCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: 'invalid-date',
savedAt: new Date().toISOString()
};
fs.writeFileSync(authFile, JSON.stringify(invalidCredentials), {
mode: 0o600
});
const retrieved = credentialStore.getCredentials();
expect(retrieved).toBeNull();
});
it('should return null for missing expiresAt', () => {
const credentialsWithoutExpiry = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
savedAt: new Date().toISOString()
};
fs.writeFileSync(authFile, JSON.stringify(credentialsWithoutExpiry), {
mode: 0o600
});
const retrieved = credentialStore.getCredentials();
expect(retrieved).toBeNull();
});
});
describe('Storage Persistence', () => {
it('should persist expiresAt as ISO string', () => {
const expiryTime = Date.now() + 3600000;
const credentials: AuthCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: expiryTime,
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(credentials);
// Read raw file to verify format
const fileContent = fs.readFileSync(authFile, 'utf-8');
const parsed = JSON.parse(fileContent);
// Should be stored as ISO string
expect(typeof parsed.expiresAt).toBe('string');
expect(parsed.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO format
});
it('should normalize timestamp on retrieval', () => {
const credentials: AuthCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials();
// Should be normalized to number for runtime use
expect(typeof retrieved?.expiresAt).toBe('number');
});
});
describe('hasValidCredentials', () => {
it('should return false for expired credentials', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
expect(credentialStore.hasValidCredentials()).toBe(false);
});
it('should return true for valid credentials', () => {
const validCredentials: AuthCredentials = {
token: 'valid-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(validCredentials);
expect(credentialStore.hasValidCredentials()).toBe(true);
});
it('should return false when no credentials exist', () => {
expect(credentialStore.hasValidCredentials()).toBe(false);
});
});
});

View File

@@ -47,8 +47,8 @@ export class SupabaseTaskRepository {
* Gets the current brief ID from auth context
* @throws {Error} If no brief is selected
*/
private getBriefIdOrThrow(): string {
const context = this.authManager.getContext();
private async getBriefIdOrThrow(): Promise<string> {
const context = await this.authManager.getContext();
if (!context?.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief'
@@ -61,7 +61,7 @@ export class SupabaseTaskRepository {
_projectId?: string,
options?: LoadTasksOptions
): Promise<Task[]> {
const briefId = this.getBriefIdOrThrow();
const briefId = await this.getBriefIdOrThrow();
// Build query with filters
let query = this.supabase
@@ -114,7 +114,7 @@ export class SupabaseTaskRepository {
}
async getTask(_projectId: string, taskId: string): Promise<Task | null> {
const briefId = this.getBriefIdOrThrow();
const briefId = await this.getBriefIdOrThrow();
const { data, error } = await this.supabase
.from('tasks')
@@ -157,7 +157,7 @@ export class SupabaseTaskRepository {
taskId: string,
updates: Partial<Task>
): Promise<Task> {
const briefId = this.getBriefIdOrThrow();
const briefId = await this.getBriefIdOrThrow();
// Validate updates using Zod schema
try {

View File

@@ -105,7 +105,7 @@ export class ExportService {
}
// Get current context
const context = this.authManager.getContext();
const context = await this.authManager.getContext();
// Determine org and brief IDs
let orgId = options.orgId || context?.orgId;
@@ -232,7 +232,7 @@ export class ExportService {
hasBrief: boolean;
context: UserContext | null;
}> {
const context = this.authManager.getContext();
const context = await this.authManager.getContext();
return {
hasOrg: !!context?.orgId,
@@ -362,7 +362,7 @@ export class ExportService {
if (useAPIEndpoint) {
// Use the new bulk import API endpoint
const apiUrl = `${process.env.TM_PUBLIC_BASE_DOMAIN}/ai/api/v1/briefs/${briefId}/tasks/bulk`;
const apiUrl = `${process.env.TM_PUBLIC_BASE_DOMAIN}/ai/api/v1/briefs/${briefId}/tasks`;
// Transform tasks to flat structure for API
const flatTasks = this.transformTasksForBulkImport(tasks);
@@ -370,16 +370,16 @@ export class ExportService {
// Prepare request body
const requestBody = {
source: 'task-master-cli',
accountId: orgId,
options: {
dryRun: false,
stopOnError: false
},
accountId: orgId,
tasks: flatTasks
};
// Get auth token
const credentials = this.authManager.getCredentials();
const credentials = await this.authManager.getCredentials();
if (!credentials || !credentials.token) {
throw new Error('Not authenticated');
}

View File

@@ -119,7 +119,7 @@ export class ApiStorage implements IStorage {
private async loadTagsIntoCache(): Promise<void> {
try {
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
const context = await authManager.getContext();
// If we have a selected brief, create a virtual "tag" for it
if (context?.briefId) {
@@ -152,7 +152,7 @@ export class ApiStorage implements IStorage {
try {
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
const context = await authManager.getContext();
// If no brief is selected in context, throw an error
if (!context?.briefId) {
@@ -318,7 +318,7 @@ export class ApiStorage implements IStorage {
try {
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
const context = await authManager.getContext();
// In our API-based system, we only have one "tag" at a time - the current brief
if (context?.briefId) {

View File

@@ -72,8 +72,8 @@ export class StorageFactory {
{ storageType: 'api', missing }
);
}
// Use auth token from AuthManager
const credentials = authManager.getCredentials();
// Use auth token from AuthManager (synchronous - no auto-refresh here)
const credentials = authManager.getCredentialsSync();
if (credentials) {
// Merge with existing storage config, ensuring required fields
const nextStorage: StorageSettings = {
@@ -103,7 +103,7 @@ export class StorageFactory {
// Then check if authenticated via AuthManager
if (authManager.isAuthenticated()) {
const credentials = authManager.getCredentials();
const credentials = authManager.getCredentialsSync();
if (credentials) {
// Configure API storage with auth credentials
const nextStorage: StorageSettings = {

View File

@@ -0,0 +1,176 @@
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 { AuthManager } from '../../src/auth/auth-manager';
import { CredentialStore } from '../../src/auth/credential-store';
import type { AuthCredentials } from '../../src/auth/types';
describe('AuthManager Token Refresh', () => {
let authManager: AuthManager;
let credentialStore: CredentialStore;
let tmpDir: string;
let authFile: string;
beforeEach(() => {
// Reset singletons
AuthManager.resetInstance();
CredentialStore.resetInstance();
// Create temporary directory for test isolation
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-auth-refresh-'));
authFile = path.join(tmpDir, 'auth.json');
// Initialize AuthManager with test config (this will create CredentialStore internally)
authManager = AuthManager.getInstance({
configDir: tmpDir,
configFile: authFile
});
// Get the CredentialStore instance that AuthManager created
credentialStore = CredentialStore.getInstance();
credentialStore.clearCredentials();
});
afterEach(() => {
// Clean up
try {
credentialStore.clearCredentials();
} catch {
// Ignore cleanup errors
}
AuthManager.resetInstance();
CredentialStore.resetInstance();
vi.restoreAllMocks();
// Remove temporary directory
if (tmpDir && fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('should not make concurrent refresh requests', async () => {
// Set up expired credentials with refresh token
const expiredCredentials: AuthCredentials = {
token: 'expired_access_token',
refreshToken: 'valid_refresh_token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 1000).toISOString(), // Expired 1 second ago
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
// Mock the refreshToken method to track calls
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
const mockSession: Session = {
access_token: 'new_access_token',
refresh_token: 'new_refresh_token',
expires_at: Math.floor(Date.now() / 1000) + 3600,
user: {
id: 'test-user-id',
email: 'test@example.com',
app_metadata: {},
user_metadata: {},
aud: 'authenticated',
created_at: new Date().toISOString()
}
};
refreshTokenSpy.mockResolvedValue({
token: mockSession.access_token,
refreshToken: mockSession.refresh_token,
userId: mockSession.user.id,
email: mockSession.user.email,
expiresAt: new Date(mockSession.expires_at! * 1000).toISOString(),
savedAt: new Date().toISOString()
});
// Make multiple concurrent calls to getCredentials
const promises = [
authManager.getCredentials(),
authManager.getCredentials(),
authManager.getCredentials()
];
const results = await Promise.all(promises);
// Verify all calls returned the same new credentials
expect(results[0]?.token).toBe('new_access_token');
expect(results[1]?.token).toBe('new_access_token');
expect(results[2]?.token).toBe('new_access_token');
// Verify refreshToken was only called once, not three times
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
});
it('should return valid credentials without attempting refresh', async () => {
// Set up valid (non-expired) credentials
const validCredentials: AuthCredentials = {
token: 'valid_access_token',
refreshToken: 'valid_refresh_token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 3600000).toISOString(), // Expires in 1 hour
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(validCredentials);
// Spy on refreshToken to ensure it's not called
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
const credentials = await authManager.getCredentials();
expect(credentials?.token).toBe('valid_access_token');
expect(refreshTokenSpy).not.toHaveBeenCalled();
});
it('should return null if credentials are expired with no refresh token', async () => {
// Set up expired credentials WITHOUT refresh token
const expiredCredentials: AuthCredentials = {
token: 'expired_access_token',
refreshToken: undefined,
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 1000).toISOString(), // Expired 1 second ago
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
const credentials = await authManager.getCredentials();
expect(credentials).toBeNull();
});
it('should return null if no credentials exist', async () => {
const credentials = await authManager.getCredentials();
expect(credentials).toBeNull();
});
it('should handle refresh failures gracefully', async () => {
// Set up expired credentials with refresh token
const expiredCredentials: AuthCredentials = {
token: 'expired_access_token',
refreshToken: 'invalid_refresh_token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 1000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
// Mock refreshToken to throw an error
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
refreshTokenSpy.mockRejectedValue(new Error('Refresh failed'));
const credentials = await authManager.getCredentials();
expect(credentials).toBeNull();
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,392 @@
/**
* @fileoverview Integration tests for JWT token auto-refresh functionality
*
* These tests verify that expired tokens are automatically refreshed
* 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 { AuthManager } from '../../src/auth/auth-manager';
import { CredentialStore } from '../../src/auth/credential-store';
import type { AuthCredentials } from '../../src/auth/types';
describe('AuthManager - Token Auto-Refresh Integration', () => {
let authManager: AuthManager;
let credentialStore: CredentialStore;
let tmpDir: string;
let authFile: string;
// Mock Supabase session that will be returned on refresh
const mockRefreshedSession: Session = {
access_token: 'new-access-token-xyz',
refresh_token: 'new-refresh-token-xyz',
token_type: 'bearer',
expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
expires_in: 3600,
user: {
id: 'test-user-id',
email: 'test@example.com',
aud: 'authenticated',
role: 'authenticated',
app_metadata: {},
user_metadata: {},
created_at: new Date().toISOString()
}
};
beforeEach(() => {
// Reset singletons
AuthManager.resetInstance();
CredentialStore.resetInstance();
// Create temporary directory for test isolation
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-auth-integration-'));
authFile = path.join(tmpDir, 'auth.json');
// Initialize AuthManager with test config (this will create CredentialStore internally)
authManager = AuthManager.getInstance({
configDir: tmpDir,
configFile: authFile
});
// Get the CredentialStore instance that AuthManager created
credentialStore = CredentialStore.getInstance();
credentialStore.clearCredentials();
});
afterEach(() => {
// Clean up
try {
credentialStore.clearCredentials();
} catch {
// Ignore cleanup errors
}
AuthManager.resetInstance();
CredentialStore.resetInstance();
vi.restoreAllMocks();
// Remove temporary directory
if (tmpDir && fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
describe('Expired Token Detection', () => {
it('should detect expired token', async () => {
// Set up expired credentials
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'valid-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(), // 1 minute ago
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
authManager = AuthManager.getInstance();
// Mock the Supabase refreshSession to return new tokens
const mockRefreshSession = vi
.fn()
.mockResolvedValue(mockRefreshedSession);
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
// Get credentials should trigger refresh
const credentials = await authManager.getCredentials();
expect(mockRefreshSession).toHaveBeenCalledTimes(1);
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('new-access-token-xyz');
});
it('should not refresh valid token', async () => {
// Set up valid credentials
const validCredentials: AuthCredentials = {
token: 'valid-token',
refreshToken: 'valid-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(validCredentials);
authManager = AuthManager.getInstance();
// Mock refresh to ensure it's not called
const mockRefreshSession = vi.fn();
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
const credentials = await authManager.getCredentials();
expect(mockRefreshSession).not.toHaveBeenCalled();
expect(credentials?.token).toBe('valid-token');
});
});
describe('Token Refresh Flow', () => {
it('should refresh expired token and save new credentials', async () => {
const expiredCredentials: AuthCredentials = {
token: 'old-token',
refreshToken: 'old-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date(Date.now() - 3600000).toISOString(),
selectedContext: {
orgId: 'test-org',
briefId: 'test-brief',
updatedAt: new Date().toISOString()
}
};
credentialStore.saveCredentials(expiredCredentials);
authManager = AuthManager.getInstance();
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockResolvedValue(mockRefreshedSession);
const refreshedCredentials = await authManager.getCredentials();
expect(refreshedCredentials).not.toBeNull();
expect(refreshedCredentials?.token).toBe('new-access-token-xyz');
expect(refreshedCredentials?.refreshToken).toBe('new-refresh-token-xyz');
// Verify context was preserved
expect(refreshedCredentials?.selectedContext?.orgId).toBe('test-org');
expect(refreshedCredentials?.selectedContext?.briefId).toBe('test-brief');
// Verify new expiration is in the future
const newExpiry = new Date(refreshedCredentials!.expiresAt!).getTime();
const now = Date.now();
expect(newExpiry).toBeGreaterThan(now);
});
it('should return null if refresh fails', async () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'invalid-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
authManager = AuthManager.getInstance();
// Mock refresh to fail
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockRejectedValue(new Error('Refresh token expired'));
const credentials = await authManager.getCredentials();
expect(credentials).toBeNull();
});
it('should return null if no refresh token available', async () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
// No refresh token
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
authManager = AuthManager.getInstance();
const credentials = await authManager.getCredentials();
expect(credentials).toBeNull();
});
it('should return null if credentials missing expiresAt', async () => {
const credentialsWithoutExpiry: AuthCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
// Missing expiresAt
savedAt: new Date().toISOString()
} as any;
credentialStore.saveCredentials(credentialsWithoutExpiry);
authManager = AuthManager.getInstance();
const credentials = await authManager.getCredentials();
// Should return null because no valid expiration
expect(credentials).toBeNull();
});
});
describe('Clock Skew Tolerance', () => {
it('should refresh token within 30-second expiry window', async () => {
// Token expires in 15 seconds (within 30-second buffer)
const almostExpiredCredentials: AuthCredentials = {
token: 'almost-expired-token',
refreshToken: 'valid-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 15000).toISOString(), // 15 seconds from now
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(almostExpiredCredentials);
authManager = AuthManager.getInstance();
const mockRefreshSession = vi
.fn()
.mockResolvedValue(mockRefreshedSession);
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
const credentials = await authManager.getCredentials();
// Should trigger refresh due to 30-second buffer
expect(mockRefreshSession).toHaveBeenCalledTimes(1);
expect(credentials?.token).toBe('new-access-token-xyz');
});
it('should not refresh token well before expiry', async () => {
// Token expires in 5 minutes (well outside 30-second buffer)
const validCredentials: AuthCredentials = {
token: 'valid-token',
refreshToken: 'valid-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 300000).toISOString(), // 5 minutes
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(validCredentials);
authManager = AuthManager.getInstance();
const mockRefreshSession = vi.fn();
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
const credentials = await authManager.getCredentials();
expect(mockRefreshSession).not.toHaveBeenCalled();
expect(credentials?.token).toBe('valid-token');
});
});
describe('Synchronous vs Async Methods', () => {
it('getCredentialsSync should not trigger refresh', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'valid-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
authManager = AuthManager.getInstance();
// Synchronous call should return null without refresh
const credentials = authManager.getCredentialsSync();
expect(credentials).toBeNull();
});
it('getCredentials async should trigger refresh', async () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'valid-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
authManager = AuthManager.getInstance();
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockResolvedValue(mockRefreshedSession);
const credentials = await authManager.getCredentials();
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('new-access-token-xyz');
});
});
describe('Multiple Concurrent Calls', () => {
it('should handle concurrent getCredentials calls gracefully', async () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'valid-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
authManager = AuthManager.getInstance();
const mockRefreshSession = vi
.fn()
.mockResolvedValue(mockRefreshedSession);
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
// Make multiple concurrent calls
const [creds1, creds2, creds3] = await Promise.all([
authManager.getCredentials(),
authManager.getCredentials(),
authManager.getCredentials()
]);
// All should get the refreshed token
expect(creds1?.token).toBe('new-access-token-xyz');
expect(creds2?.token).toBe('new-access-token-xyz');
expect(creds3?.token).toBe('new-access-token-xyz');
// Refresh might be called multiple times, but that's okay
// (ideally we'd debounce, but this is acceptable behavior)
expect(mockRefreshSession).toHaveBeenCalled();
});
});
});