Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
533420a17e docs: auto-update documentation based on changes in next branch
This PR was automatically generated to update documentation based on recent changes.

  Original commit: chore: cleanup changelog and pre exit\n\n

  Co-authored-by: Claude <claude-assistant@anthropic.com>
2025-10-11 19:27:31 +00:00
25 changed files with 160 additions and 1138 deletions

View File

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

View File

@@ -1,11 +1,11 @@
{
"mode": "pre",
"mode": "exit",
"tag": "rc",
"initialVersions": {
"task-master-ai": "0.29.0-rc.0",
"task-master-ai": "0.28.0",
"@tm/cli": "",
"docs": "0.0.5",
"extension": "0.25.6-rc.0",
"extension": "0.25.5",
"@tm/ai-sdk-provider-grok-cli": "",
"@tm/build-config": "",
"@tm/claude-code-plugin": "0.0.1",
@@ -16,7 +16,6 @@
"mean-planes-wave",
"nice-ways-hope",
"plain-falcons-serve",
"silent-bushes-grow",
"smart-owls-relax"
]
}

View File

@@ -1,5 +0,0 @@
---
"task-master-ai": patch
---
Improve refresh token when authenticating

View File

@@ -1,11 +1,5 @@
# task-master-ai
## 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,12 +310,6 @@ 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

@@ -143,7 +143,7 @@ export class AuthCommand extends Command {
*/
private async executeStatus(): Promise<void> {
try {
const result = await this.displayStatus();
const result = this.displayStatus();
this.setLastResult(result);
} catch (error: any) {
this.handleError(error);
@@ -171,8 +171,8 @@ export class AuthCommand extends Command {
/**
* Display authentication status
*/
private async displayStatus(): Promise<AuthResult> {
const credentials = await this.authManager.getCredentials();
private displayStatus(): AuthResult {
const credentials = this.authManager.getCredentials();
console.log(chalk.cyan('\n🔐 Authentication Status\n'));
@@ -187,29 +187,19 @@ export class AuthCommand extends Command {
if (credentials.expiresAt) {
const expiresAt = new Date(credentials.expiresAt);
const now = new Date();
const timeRemaining = expiresAt.getTime() - now.getTime();
const hoursRemaining = Math.floor(timeRemaining / (1000 * 60 * 60));
const minutesRemaining = Math.floor(timeRemaining / (1000 * 60));
const hoursRemaining = Math.floor(
(expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60)
);
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
if (hoursRemaining > 0) {
console.log(
chalk.yellow(` Expired at: ${expiresAt.toLocaleString()}`)
chalk.gray(
` Expires: ${expiresAt.toLocaleString()} (${hoursRemaining} hours remaining)`
)
);
} else {
console.log(
chalk.yellow(` Token expired at: ${expiresAt.toLocaleString()}`)
);
}
} else {
@@ -325,7 +315,7 @@ export class AuthCommand extends Command {
]);
if (!continueAuth) {
const credentials = await this.authManager.getCredentials();
const credentials = this.authManager.getCredentials();
ui.displaySuccess('Using existing authentication');
if (credentials) {
@@ -490,7 +480,7 @@ export class AuthCommand extends Command {
/**
* Get current credentials (for programmatic usage)
*/
getCredentials(): Promise<AuthCredentials | null> {
getCredentials(): 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 = await this.displayContext();
const result = this.displayContext();
this.setLastResult(result);
} catch (error: any) {
this.handleError(error);
@@ -126,7 +126,7 @@ export class ContextCommand extends Command {
/**
* Display current context
*/
private async displayContext(): Promise<ContextResult> {
private displayContext(): ContextResult {
// Check authentication first
if (!this.authManager.isAuthenticated()) {
console.log(chalk.yellow('✗ Not authenticated'));
@@ -139,7 +139,7 @@ export class ContextCommand extends Command {
};
}
const context = await this.authManager.getContext();
const context = this.authManager.getContext();
console.log(chalk.cyan('\n🌍 Workspace Context\n'));
@@ -263,7 +263,7 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'select-org',
context: (await this.authManager.getContext()) || undefined,
context: this.authManager.getContext() || undefined,
message: `Selected organization: ${selectedOrg.name}`
};
} catch (error) {
@@ -284,7 +284,7 @@ export class ContextCommand extends Command {
}
// Check if org is selected
const context = await this.authManager.getContext();
const context = this.authManager.getContext();
if (!context?.orgId) {
ui.displayError(
'No organization selected. Run "tm context org" first.'
@@ -353,7 +353,7 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'select-brief',
context: (await this.authManager.getContext()) || undefined,
context: 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: (await this.authManager.getContext()) || undefined,
context: this.authManager.getContext() || undefined,
message: 'Cleared brief selection'
};
}
@@ -508,7 +508,7 @@ export class ContextCommand extends Command {
this.setLastResult({
success: true,
action: 'set',
context: (await this.authManager.getContext()) || undefined,
context: this.authManager.getContext() || undefined,
message: 'Context set from brief'
});
} catch (error: any) {
@@ -631,7 +631,7 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'set',
context: (await this.authManager.getContext()) || undefined,
context: this.authManager.getContext() || undefined,
message: 'Context updated'
};
} catch (error) {
@@ -682,7 +682,7 @@ export class ContextCommand extends Command {
/**
* Get current context (for programmatic usage)
*/
getContext(): Promise<UserContext | null> {
getContext(): 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 = await this.authManager.getContext();
const context = this.authManager.getContext();
// Determine org and brief IDs
let orgId = options?.org || context?.orgId;

View File

@@ -31,9 +31,23 @@ 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 Quick Install
### Claude Code Plugin Install (Recommended)
For Claude Code users:
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:
```bash
claude mcp add taskmaster-ai -- npx -y task-master-ai

View File

@@ -3,4 +3,44 @@ title: "What's New"
sidebarTitle: "What's New"
---
An easy way to see the latest releases
## 🎉 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!

View File

@@ -239,6 +239,9 @@
"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",
@@ -274,8 +277,7 @@
"tailwind-merge": "^3.3.1",
"tailwindcss": "4.1.11",
"typescript": "^5.9.2",
"@tm/core": "*",
"task-master-ai": "*"
"@tm/core": "*"
},
"overrides": {
"glob@<8": "^10.4.5",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

30
output.txt Normal file

File diff suppressed because one or more lines are too long

137
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "task-master-ai",
"version": "0.29.0-rc.0",
"version": "npm:task-master-ai@0.29.0-rc.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -130,53 +130,8 @@
"mintlify": "^4.2.111"
}
},
"apps/extension": {
"version": "0.25.6-rc.0",
"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",
@@ -194,7 +149,6 @@
},
"apps/extension/node_modules/@ai-sdk/anthropic": {
"version": "1.2.12",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -209,7 +163,6 @@
},
"apps/extension/node_modules/@ai-sdk/azure": {
"version": "1.3.25",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/openai": "1.3.24",
@@ -225,7 +178,6 @@
},
"apps/extension/node_modules/@ai-sdk/google": {
"version": "1.2.22",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -240,7 +192,6 @@
},
"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",
@@ -258,7 +209,6 @@
},
"apps/extension/node_modules/@ai-sdk/groq": {
"version": "1.2.9",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -273,7 +223,6 @@
},
"apps/extension/node_modules/@ai-sdk/mistral": {
"version": "1.2.8",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -288,7 +237,6 @@
},
"apps/extension/node_modules/@ai-sdk/openai": {
"version": "1.3.24",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -303,7 +251,6 @@
},
"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",
@@ -318,7 +265,6 @@
},
"apps/extension/node_modules/@ai-sdk/perplexity": {
"version": "1.1.9",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -333,7 +279,6 @@
},
"apps/extension/node_modules/@ai-sdk/provider": {
"version": "1.1.3",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
@@ -344,7 +289,6 @@
},
"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",
@@ -360,7 +304,6 @@
},
"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",
@@ -383,7 +326,6 @@
},
"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",
@@ -399,7 +341,6 @@
},
"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",
@@ -415,7 +356,6 @@
},
"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",
@@ -430,7 +370,6 @@
},
"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"
@@ -441,7 +380,6 @@
},
"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",
@@ -463,7 +401,6 @@
},
"apps/extension/node_modules/ai": {
"version": "4.3.19",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
@@ -488,7 +425,6 @@
},
"apps/extension/node_modules/ai-sdk-provider-gemini-cli": {
"version": "0.1.3",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -522,7 +458,6 @@
},
"apps/extension/node_modules/ollama-ai-provider": {
"version": "1.2.0",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "^1.0.0",
@@ -543,7 +478,6 @@
},
"apps/extension/node_modules/openai": {
"version": "4.104.0",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
@@ -572,7 +506,6 @@
},
"apps/extension/node_modules/openai/node_modules/@types/node": {
"version": "18.19.127",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
@@ -580,12 +513,10 @@
},
"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/*",
@@ -657,7 +588,6 @@
},
"apps/extension/node_modules/zod": {
"version": "3.25.76",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -665,7 +595,6 @@
},
"apps/extension/node_modules/zod-to-json-schema": {
"version": "3.24.6",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
@@ -954,7 +883,6 @@
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.39.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
@@ -968,7 +896,6 @@
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.127",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
@@ -976,7 +903,6 @@
},
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
"version": "5.26.5",
"dev": true,
"license": "MIT"
},
"node_modules/@ark/schema": {
@@ -8436,7 +8362,6 @@
},
"node_modules/@types/diff-match-patch": {
"version": "1.0.36",
"dev": true,
"license": "MIT"
},
"node_modules/@types/es-aggregate-error": {
@@ -8607,7 +8532,6 @@
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -9053,7 +8977,6 @@
},
"node_modules/abort-controller": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
@@ -9115,7 +9038,6 @@
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"humanize-ms": "^1.2.1"
@@ -9698,7 +9620,6 @@
},
"node_modules/asynckit": {
"version": "0.4.0",
"dev": true,
"license": "MIT"
},
"node_modules/auto-bind": {
@@ -11476,7 +11397,6 @@
},
"node_modules/combined-stream": {
"version": "1.0.8",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -12115,7 +12035,6 @@
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -12138,7 +12057,6 @@
},
"node_modules/dequal": {
"version": "2.0.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -12258,7 +12176,6 @@
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/diff-sequences": {
@@ -12758,7 +12675,6 @@
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -13047,7 +12963,6 @@
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -14086,7 +14001,6 @@
},
"node_modules/form-data": {
"version": "4.0.4",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -14101,7 +14015,6 @@
},
"node_modules/form-data-encoder": {
"version": "1.7.2",
"dev": true,
"license": "MIT"
},
"node_modules/format": {
@@ -14113,7 +14026,6 @@
},
"node_modules/formdata-node": {
"version": "4.4.1",
"dev": true,
"license": "MIT",
"dependencies": {
"node-domexception": "1.0.0",
@@ -14774,7 +14686,6 @@
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -15349,7 +15260,6 @@
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.0.0"
@@ -18136,7 +18046,6 @@
},
"node_modules/jsondiffpatch": {
"version": "0.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/diff-match-patch": "^1.0.36",
@@ -18400,7 +18309,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -20318,7 +20226,6 @@
},
"node_modules/nanoid": {
"version": "3.3.11",
"devOptional": true,
"funding": [
{
"type": "github",
@@ -20427,7 +20334,6 @@
},
"node_modules/node-domexception": {
"version": "1.0.0",
"dev": true,
"funding": [
{
"type": "github",
@@ -21292,7 +21198,6 @@
},
"node_modules/partial-json": {
"version": "0.1.7",
"dev": true,
"license": "MIT"
},
"node_modules/patch-console": {
@@ -22065,7 +21970,6 @@
},
"node_modules/react": {
"version": "19.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -23192,7 +23096,6 @@
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/selderee": {
@@ -24240,26 +24143,6 @@
"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": [
@@ -24437,7 +24320,6 @@
},
"node_modules/swr": {
"version": "2.3.6",
"dev": true,
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
@@ -24630,7 +24512,6 @@
},
"node_modules/throttleit": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -25733,7 +25614,6 @@
},
"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"
@@ -25986,7 +25866,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -26142,7 +26021,6 @@
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -27139,6 +27017,16 @@
"version": "0.0.1",
"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",
@@ -27149,7 +27037,6 @@
"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"
}
@@ -27459,8 +27346,6 @@
},
"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.1",
"version": "0.29.0-rc.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

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

View File

@@ -21,16 +21,11 @@ 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(_options?: any) {
getCredentials() {
return null;
}
saveCredentials() {}
@@ -90,7 +85,7 @@ describe('AuthManager Singleton', () => {
expect(instance1).toBe(instance2);
});
it('should use config on first call', async () => {
it('should use config on first call', () => {
const config = {
baseUrl: 'https://test.auth.com',
configDir: '/test/config',
@@ -106,7 +101,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 = await instance.getCredentials();
const credentials = instance.getCredentials();
expect(credentials).toBeNull(); // File doesn't exist, but config was propagated correctly
});

View File

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

View File

@@ -1,289 +0,0 @@
/**
* @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 async getBriefIdOrThrow(): Promise<string> {
const context = await this.authManager.getContext();
private getBriefIdOrThrow(): string {
const context = this.authManager.getContext();
if (!context?.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief'
@@ -61,7 +61,7 @@ export class SupabaseTaskRepository {
_projectId?: string,
options?: LoadTasksOptions
): Promise<Task[]> {
const briefId = await this.getBriefIdOrThrow();
const briefId = 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 = await this.getBriefIdOrThrow();
const briefId = 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 = await this.getBriefIdOrThrow();
const briefId = this.getBriefIdOrThrow();
// Validate updates using Zod schema
try {

View File

@@ -105,7 +105,7 @@ export class ExportService {
}
// Get current context
const context = await this.authManager.getContext();
const context = 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 = await this.authManager.getContext();
const context = this.authManager.getContext();
return {
hasOrg: !!context?.orgId,
@@ -379,7 +379,7 @@ export class ExportService {
};
// Get auth token
const credentials = await this.authManager.getCredentials();
const credentials = 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 = await authManager.getContext();
const context = 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 = await authManager.getContext();
const context = 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 = await authManager.getContext();
const context = authManager.getContext();
// In our API-based system, we only have one "tag" at a time - the current brief
if (context?.briefId) {

View File

@@ -72,8 +72,8 @@ export class StorageFactory {
{ storageType: 'api', missing }
);
}
// Use auth token from AuthManager (synchronous - no auto-refresh here)
const credentials = authManager.getCredentialsSync();
// Use auth token from AuthManager
const credentials = authManager.getCredentials();
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.getCredentialsSync();
const credentials = authManager.getCredentials();
if (credentials) {
// Configure API storage with auth credentials
const nextStorage: StorageSettings = {

View File

@@ -1,176 +0,0 @@
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

@@ -1,392 +0,0 @@
/**
* @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();
});
});
});