fix(gateway/auth): Implement proper auth/init flow with automatic background userId generation

- Fix getUserId() to use placeholder that triggers auth/init if the auth/init endpoint is down for whatever reason
- Add silent auth/init attempt in AI services
- Improve hosted mode error handling
- Remove fake userId/email generation from init.js
This commit is contained in:
Eyal Toledano
2025-05-31 19:47:18 -04:00
parent 769275b3bc
commit 9b87dd23de
11 changed files with 4699 additions and 4558 deletions

View File

@@ -29,9 +29,9 @@
"azureBaseURL": "https://your-endpoint.azure.com/" "azureBaseURL": "https://your-endpoint.azure.com/"
}, },
"account": { "account": {
"userId": "277779c9-1ee2-4ef8-aa3a-2176745b71a9", "userId": "1234567890",
"userEmail": "user_1748640077834@taskmaster.dev", "userEmail": "",
"mode": "hosted", "mode": "byok",
"telemetryEnabled": true "telemetryEnabled": true
} }
} }

134
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"ollama-ai-provider": "^1.2.0", "ollama-ai-provider": "^1.2.0",
"open": "^10.1.2",
"openai": "^4.89.0", "openai": "^4.89.0",
"ora": "^8.2.0", "ora": "^8.2.0",
"task-master-ai": "^0.15.0", "task-master-ai": "^0.15.0",
@@ -5423,6 +5424,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
"license": "MIT",
"dependencies": {
"run-applescript": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -6192,6 +6208,46 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/default-browser": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
"integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==",
"license": "MIT",
"dependencies": {
"bundle-name": "^4.1.0",
"default-browser-id": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/default-browser-id": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz",
"integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/define-lazy-prop": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -8030,6 +8086,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -8088,6 +8159,24 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-inside-container": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
"license": "MIT",
"dependencies": {
"is-docker": "^3.0.0"
},
"bin": {
"is-inside-container": "cli.js"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-interactive": { "node_modules/is-interactive": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
@@ -8176,6 +8265,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-wsl": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
"integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
"license": "MIT",
"dependencies": {
"is-inside-container": "^1.0.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -9933,6 +10037,24 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/open": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz",
"integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==",
"license": "MIT",
"dependencies": {
"default-browser": "^5.2.1",
"define-lazy-prop": "^3.0.0",
"is-inside-container": "^1.0.0",
"is-wsl": "^3.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openai": { "node_modules/openai": {
"version": "4.89.0", "version": "4.89.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.89.0.tgz", "resolved": "https://registry.npmjs.org/openai/-/openai-4.89.0.tgz",
@@ -10706,6 +10828,18 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/run-applescript": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
"integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-async": { "node_modules/run-async": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz",

View File

@@ -70,6 +70,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"ollama-ai-provider": "^1.2.0", "ollama-ai-provider": "^1.2.0",
"open": "^10.1.2",
"openai": "^4.89.0", "openai": "^4.89.0",
"ora": "^8.2.0", "ora": "^8.2.0",
"task-master-ai": "^0.15.0", "task-master-ai": "^0.15.0",

View File

@@ -22,10 +22,16 @@ import chalk from "chalk";
import figlet from "figlet"; import figlet from "figlet";
import boxen from "boxen"; import boxen from "boxen";
import gradient from "gradient-string"; import gradient from "gradient-string";
import inquirer from "inquirer";
import open from "open";
import express from "express";
import { isSilentMode } from "./modules/utils.js"; import { isSilentMode } from "./modules/utils.js";
import { convertAllCursorRulesToRooRules } from "./modules/rule-transformer.js"; import { convertAllCursorRulesToRooRules } from "./modules/rule-transformer.js";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { registerUserWithGateway } from "./modules/telemetry-submission.js"; import {
initializeUser,
registerUserWithGateway,
} from "./modules/user-management.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -379,46 +385,51 @@ async function initializeProject(options = {}) {
}; };
} }
// STEP 1: Create/find userId first (MCP/non-interactive mode) // NON-INTERACTIVE MODE - Use proper auth/init flow
let userId = null; let userSetupResult;
let gatewayRegistration = null;
try { try {
// Try to get existing userId from config if it exists // Check if existing config has userId
const existingConfigPath = path.join(process.cwd(), ".taskmasterconfig"); const existingConfigPath = path.join(process.cwd(), ".taskmasterconfig");
let existingUserId = null;
if (fs.existsSync(existingConfigPath)) { if (fs.existsSync(existingConfigPath)) {
const existingConfig = JSON.parse( const existingConfig = JSON.parse(
fs.readFileSync(existingConfigPath, "utf8") fs.readFileSync(existingConfigPath, "utf8")
); );
userId = existingConfig.account?.userId; existingUserId = existingConfig.account?.userId;
const existingUserEmail = existingConfig.account?.userEmail; }
// Pass existing data to gateway for validation/lookup if (existingUserId) {
gatewayRegistration = await registerUserWithGateway( // Validate existing userId through auth/init
existingUserEmail || tempEmail, userSetupResult = await registerUserWithGateway(null, process.cwd());
userId if (!userSetupResult.success) {
); throw new Error(
`Failed to validate existing user: ${userSetupResult.error}`
if (gatewayRegistration.success) { );
userId = gatewayRegistration.userId; }
} else { } else {
// Generate fallback userId if gateway unavailable // Create new user through auth/init
userId = `tm_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; userSetupResult = await initializeUser(process.cwd());
if (!userSetupResult.success) {
throw new Error(
`Failed to initialize user: ${userSetupResult.error}`
);
} }
} }
} catch (error) { } catch (error) {
// Generate fallback userId on any error log("error", `User initialization failed: ${error.message}`);
userId = `tm_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; throw error; // Don't fall back to random userId!
} }
// For non-interactive mode, default to BYOK mode with proper userId // Create project structure with properly authenticated userId
createProjectStructure( createProjectStructure(
addAliases, addAliases,
dryRun, dryRun,
gatewayRegistration, userSetupResult, // Pass the full auth result
"byok", "byok", // or determine from result
null, null,
userId userSetupResult.userId || null
); );
} else { } else {
// Interactive logic - NEW FLOW STARTS HERE // Interactive logic - NEW FLOW STARTS HERE
@@ -444,127 +455,124 @@ async function initializeProject(options = {}) {
) )
); );
// Generate or retrieve userId from gateway // INTERACTIVE MODE - Also use proper auth/init flow
let userId = null; // STEP 1: Proper user setup
let gatewayRegistration = null; let userSetupResult;
try { try {
// Try to get existing userId from config if it exists // Same logic as non-interactive mode
const existingConfigPath = path.join( const existingConfigPath = path.join(
process.cwd(), process.cwd(),
".taskmasterconfig" ".taskmasterconfig"
); );
let existingUserId = null;
if (fs.existsSync(existingConfigPath)) { if (fs.existsSync(existingConfigPath)) {
const existingConfig = JSON.parse( const existingConfig = JSON.parse(
fs.readFileSync(existingConfigPath, "utf8") fs.readFileSync(existingConfigPath, "utf8")
); );
userId = existingConfig.account?.userId; existingUserId = existingConfig.account?.userId;
const existingUserEmail = existingConfig.account?.userEmail; }
// Pass existing data to gateway for validation/lookup if (existingUserId) {
gatewayRegistration = await registerUserWithGateway( userSetupResult = await registerUserWithGateway(null, process.cwd());
existingUserEmail || tempEmail, if (!userSetupResult.success) {
userId throw new Error(
); `Failed to validate existing user: ${userSetupResult.error}`
);
if (gatewayRegistration.success) { }
userId = gatewayRegistration.userId; } else {
} else { userSetupResult = await initializeUser(process.cwd());
// Generate fallback userId if gateway unavailable if (!userSetupResult.success) {
userId = `tm_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; throw new Error(
`Failed to initialize user: ${userSetupResult.error}`
);
} }
} }
} catch (error) { } catch (error) {
// Generate fallback userId on any error log("error", `User initialization failed: ${error.message}`);
userId = `tm_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; // Don't fall back to random userId - exit or prompt user
throw error;
} }
// STEP 2: Choose AI access method (MAIN DECISION) // STEP 2: Choose AI access method using inquirer
const modeResponse = await inquirer.prompt([
{
type: "list",
name: "accessMode",
message: "Choose Your AI Access Method:",
choices: [
{
name: "🔑 BYOK - Bring Your Own API Keys (You manage API keys & billing)",
value: "byok",
},
{
name: "🎯 Hosted API Gateway - All models, no keys needed (Recommended)",
value: "hosted",
},
],
default: "hosted",
},
]);
const selectedMode = modeResponse.accessMode;
console.log( console.log(
boxen( boxen(
chalk.white.bold("Choose Your AI Access Method") + selectedMode === "byok"
"\n\n" + ? chalk.blue.bold("🔑 BYOK Mode Selected") +
chalk.cyan.bold("(1) BYOK - Bring Your Own API Keys") +
"\n" +
chalk.white(
" → You manage API keys & billing with AI providers"
) +
"\n" +
chalk.white(" → Pay provider directly based on token usage") +
"\n" +
chalk.white(
" → Requires setup with each provider individually"
) +
"\n\n" +
chalk.green.bold("(2) Hosted API Gateway") +
" " +
chalk.yellow.bold("(Recommended)") +
"\n" +
chalk.white(" → Use any model, zero API keys needed") +
"\n" +
chalk.white(" → Flat, credit-based pricing with no surprises") +
"\n" +
chalk.white(" → Support the development of Taskmaster"),
{
padding: 1,
margin: { top: 1, bottom: 1 },
borderStyle: "round",
borderColor: "cyan",
title: "🎯 AI Access Setup",
titleAlignment: "center",
}
)
);
let choice;
while (true) {
choice = await promptQuestion(
rl,
chalk.cyan.bold("Your choice (1 or 2): ")
);
if (choice === "1" || choice.toLowerCase() === "byok") {
console.log(
boxen(
chalk.blue.bold("🔑 BYOK Mode Selected") +
"\n\n" + "\n\n" +
chalk.white("You'll manage your own API keys and billing.") + chalk.white("You'll manage your own API keys and billing.") +
"\n" + "\n" +
chalk.white("After setup, add your API keys to ") + chalk.white("After setup, add your API keys to ") +
chalk.cyan(".env") + chalk.cyan(".env") +
chalk.white(" file."), chalk.white(" file.")
{ : chalk.green.bold("🎯 Hosted API Gateway Selected") +
padding: 1,
margin: { top: 1, bottom: 1 },
borderStyle: "round",
borderColor: "blue",
}
)
);
return "byok";
} else if (choice === "2" || choice.toLowerCase() === "hosted") {
console.log(
boxen(
chalk.green.bold("🎯 Hosted API Gateway Selected") +
"\n\n" + "\n\n" +
chalk.white( chalk.white(
"All AI models available instantly - no API keys needed!" "All AI models available instantly - no API keys needed!"
) + ) +
"\n" + "\n" +
chalk.dim("Let's set up your subscription plan..."), chalk.dim("Let's set up your subscription plan..."),
{ {
padding: 0.5, padding: 1,
margin: { top: 0.5, bottom: 0.5 }, margin: { top: 1, bottom: 1 },
borderStyle: "round", borderStyle: "round",
borderColor: "green", borderColor: selectedMode === "byok" ? "blue" : "green",
} }
) )
); );
return "hosted";
} else { // STEP 3: If hosted mode, handle subscription plan with Stripe simulation
console.log(chalk.red("Please enter 1 or 2")); let selectedPlan = null;
} if (selectedMode === "hosted") {
selectedPlan = await handleHostedSubscription();
} }
// STEP 4: Continue with aliases (this fixes the hanging issue)
const aliasResponse = await inquirer.prompt([
{
type: "confirm",
name: "addAliases",
message: "Add shell aliases (tm, taskmaster) for easier access?",
default: true,
},
]);
const addAliases = aliasResponse.addAliases;
const dryRun = options.dryRun || false;
// STEP 5: Show overview and continue with project creation
rl.close();
createProjectStructure(
addAliases,
dryRun,
userSetupResult,
selectedMode,
selectedPlan,
userSetupResult.userId
);
} catch (error) { } catch (error) {
rl.close(); rl.close();
log("error", `Error during initialization process: ${error.message}`); log("error", `Error during initialization process: ${error.message}`);
@@ -1137,251 +1145,132 @@ function setupMCPConfiguration(targetDir) {
log("info", "MCP server will use the installed task-master-ai package"); log("info", "MCP server will use the installed task-master-ai package");
} }
// Function to let user choose between BYOK and Hosted API Gateway // Function to handle hosted subscription with browser pattern
async function selectAccessMode() { async function handleHostedSubscription() {
const planResponse = await inquirer.prompt([
{
type: "list",
name: "plan",
message: "Select Your Monthly AI Credit Pack:",
choices: [
{
name: "50 credits - $5/mo [$0.10 per credit] - Perfect for personal projects",
value: { name: "Starter", credits: 50, price: "$5/mo", value: 1 },
},
{
name: "120 credits - $10/mo [$0.083 per credit] - Popular choice",
value: { name: "Popular", credits: 120, price: "$10/mo", value: 2 },
},
{
name: "250 credits - $20/mo [$0.08 per credit] - Great value",
value: { name: "Pro", credits: 250, price: "$20/mo", value: 3 },
},
{
name: "550 credits - $40/mo [$0.073 per credit] - Best value",
value: {
name: "Enterprise",
credits: 550,
price: "$40/mo",
value: 4,
},
},
],
default: 1, // Popular plan
},
]);
const selectedPlan = planResponse.plan;
console.log( console.log(
boxen( boxen(
chalk.cyan.bold("🚀 Choose Your AI Access Method") + chalk.green.bold(`✅ Selected: ${selectedPlan.name} Plan`) +
"\n\n" + "\n\n" +
chalk.white("TaskMaster supports two ways to access AI models:") + chalk.white(
`${selectedPlan.credits} credits/month for ${selectedPlan.price}`
) +
"\n\n" + "\n\n" +
chalk.yellow.bold("(1) BYOK - Bring Your Own API Keys") + chalk.yellow("🔄 Opening browser for Stripe checkout...") +
"\n" + "\n" +
chalk.white(" ✓ Use your existing provider accounts") + chalk.dim("Complete your subscription setup in the browser."),
"\n" +
chalk.white(" ✓ Pay providers directly") +
"\n" +
chalk.white(" ✓ Full control over billing & usage") +
"\n" +
chalk.dim(" → Best for: Teams with existing AI accounts") +
"\n\n" +
chalk.green.bold("(2) Hosted API Gateway") +
chalk.yellow(" (Recommended)") +
"\n" +
chalk.white(" ✓ No API keys required") +
"\n" +
chalk.white(" ✓ Access all supported models instantly") +
"\n" +
chalk.white(" ✓ Simple credit-based billing") +
"\n" +
chalk.white(" ✓ Better rates through volume pricing") +
"\n" +
chalk.dim(" → Best for: Getting started quickly"),
{ {
padding: 1, padding: 1,
margin: { top: 1, bottom: 1 }, margin: { top: 0.5, bottom: 0.5 },
borderStyle: "round",
borderColor: "cyan",
title: "🎯 AI Access Configuration",
titleAlignment: "center",
}
)
);
let choice;
while (true) {
choice = await promptQuestion(
rl,
chalk.cyan("Your choice") +
chalk.gray(" (1 for BYOK, 2 for Hosted)") +
": "
);
if (choice === "1" || choice.toLowerCase() === "byok") {
console.log(
boxen(
chalk.blue.bold("🔑 BYOK Mode Selected") +
"\n\n" +
chalk.white("You'll configure your own AI provider API keys.") +
"\n" +
chalk.dim("The setup will guide you through model configuration."),
{
padding: 0.5,
margin: { top: 0.5, bottom: 0.5 },
borderStyle: "round",
borderColor: "blue",
}
)
);
return "byok";
} else if (choice === "2" || choice.toLowerCase() === "hosted") {
console.log(
boxen(
chalk.green.bold("🎯 Hosted API Gateway Selected") +
"\n\n" +
chalk.white(
"All AI models available instantly - no API keys needed!"
) +
"\n" +
chalk.dim("Let's set up your subscription plan..."),
{
padding: 0.5,
margin: { top: 0.5, bottom: 0.5 },
borderStyle: "round",
borderColor: "green",
}
)
);
return "hosted";
} else {
console.log(chalk.red("Please enter 1 or 2"));
}
}
}
// Function to let user select a subscription plan
async function selectSubscriptionPlan() {
console.log(
boxen(
chalk.cyan.bold("💳 Select Your Monthly AI Credit Pack") +
"\n\n" +
chalk.white("Choose the plan that fits your usage:") +
"\n\n" +
chalk.white("(1) ") +
chalk.yellow.bold("50 credits") +
chalk.white(" - ") +
chalk.green("$5/mo") +
chalk.gray(" [$0.10 per credit]") +
"\n" +
chalk.dim(" → Perfect for: Personal projects, light usage") +
"\n\n" +
chalk.white("(2) ") +
chalk.yellow.bold("120 credits") +
chalk.white(" - ") +
chalk.green("$10/mo") +
chalk.gray(" [$0.083 per credit]") +
chalk.cyan.bold(" ← Popular") +
"\n" +
chalk.dim(" → Perfect for: Active development, small teams") +
"\n\n" +
chalk.white("(3) ") +
chalk.yellow.bold("250 credits") +
chalk.white(" - ") +
chalk.green("$20/mo") +
chalk.gray(" [$0.08 per credit]") +
chalk.blue.bold(" ← Great Value") +
"\n" +
chalk.dim(" → Perfect for: Professional development, medium teams") +
"\n\n" +
chalk.white("(4) ") +
chalk.yellow.bold("550 credits") +
chalk.white(" - ") +
chalk.green("$40/mo") +
chalk.gray(" [$0.073 per credit]") +
chalk.magenta.bold(" ← Best Value") +
"\n" +
chalk.dim(" → Perfect for: Heavy usage, large teams, enterprises") +
"\n\n" +
chalk.blue("💡 ") +
chalk.white("Credits roll over month-to-month. Cancel anytime."),
{
padding: 1,
margin: { top: 1, bottom: 1 },
borderStyle: "round", borderStyle: "round",
borderColor: "green", borderColor: "green",
title: "💳 Subscription Plans",
titleAlignment: "center",
} }
) )
); );
const plans = [ // Stripe simulation with browser opening pattern (like Shopify CLI)
{ await simulateStripeCheckout(selectedPlan);
name: "Starter",
credits: 50,
price: "$5/mo",
perCredit: "$0.10",
value: 1,
},
{
name: "Popular",
credits: 120,
price: "$10/mo",
perCredit: "$0.083",
value: 2,
},
{
name: "Pro",
credits: 250,
price: "$20/mo",
perCredit: "$0.08",
value: 3,
},
{
name: "Enterprise",
credits: 550,
price: "$40/mo",
perCredit: "$0.073",
value: 4,
},
];
let choice; return selectedPlan;
while (true) {
choice = await promptQuestion(
rl,
chalk.cyan("Your choice") + chalk.gray(" (1-4)") + ": "
);
const planIndex = parseInt(choice) - 1;
if (planIndex >= 0 && planIndex < plans.length) {
const selectedPlan = plans[planIndex];
console.log(
boxen(
chalk.green.bold(`✅ Selected: ${selectedPlan.name} Plan`) +
"\n\n" +
chalk.white(
`${selectedPlan.credits} credits/month for ${selectedPlan.price}`
) +
"\n" +
chalk.gray(`(${selectedPlan.perCredit} per credit)`) +
"\n\n" +
chalk.yellow("🔄 Opening Stripe checkout...") +
"\n" +
chalk.dim("Complete your subscription setup in the browser."),
{
padding: 1,
margin: { top: 0.5, bottom: 0.5 },
borderStyle: "round",
borderColor: "green",
}
)
);
// TODO: Integrate with actual Stripe checkout
// For now, simulate the process
console.log(chalk.yellow("\n⏳ Simulating Stripe checkout process..."));
console.log(chalk.green("✅ Subscription setup complete! (Simulated)"));
return selectedPlan;
} else {
console.log(chalk.red("Please enter a number from 1 to 4"));
}
}
} }
// Function to create or retrieve user ID // Stripe checkout simulation with browser pattern
async function getOrCreateUserId() { async function simulateStripeCheckout(plan) {
// Try to find existing userId first console.log(chalk.yellow("\n⏳ Starting Stripe checkout process..."));
const existingConfig = path.join(process.cwd(), ".taskmasterconfig");
if (fs.existsSync(existingConfig)) { // Start a simple HTTP server to handle the callback
try { const app = express();
const config = JSON.parse(fs.readFileSync(existingConfig, "utf8")); let server;
if (config.userId) { let checkoutComplete = false;
log("info", `Using existing user ID: ${config.userId}`);
return config.userId; // For demo/testing, we'll use a simple success simulation
} const checkoutUrl = `https://example-stripe-simulation.com/checkout?plan=${plan.value}&return_url=http://localhost:3333/success`;
} catch (error) {
log("warn", "Could not read existing config, creating new user ID"); app.get("/success", (req, res) => {
} checkoutComplete = true;
res.send(`
<html>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1 style="color: #28a745;">✅ Subscription Complete!</h1>
<p>Your ${plan.name} plan (${plan.credits} credits/month) is now active.</p>
<p style="color: #666; margin-top: 30px;">You can close this window and return to your terminal.</p>
</body>
</html>
`);
setTimeout(() => {
server.close();
}, 1000);
});
// Start the callback server
server = app.listen(3333, () => {
console.log(chalk.blue("📡 Started local callback server on port 3333"));
});
// Prompt user before opening browser
await inquirer.prompt([
{
type: "input",
name: "ready",
message: chalk.cyan(
"Press Enter to open your browser for Stripe checkout..."
),
},
]);
// Open the browser (for demo, we'll simulate immediate success)
console.log(chalk.blue("🌐 Opening browser..."));
// For demo purposes, simulate immediate success instead of opening real browser
// In real implementation: await open(checkoutUrl);
console.log(chalk.gray(`Demo URL: ${checkoutUrl}`));
// Simulate the checkout completion after 2 seconds
setTimeout(() => {
console.log(chalk.green("✅ Subscription setup complete! (Simulated)"));
checkoutComplete = true;
server.close();
}, 2000);
// Wait for checkout completion
while (!checkoutComplete) {
await new Promise((resolve) => setTimeout(resolve, 500));
} }
// Generate new user ID console.log(chalk.green("🎉 Payment successful! Continuing setup..."));
const { v4: uuidv4 } = require("uuid");
const newUserId = uuidv4();
log("info", `Generated new user ID: ${newUserId}`);
return newUserId;
} }
// Ensure necessary functions are exported // Ensure necessary functions are exported

View File

@@ -278,6 +278,7 @@ async function _attemptProviderCallWithRetries(
* @param {string} commandName - Command name for tracking * @param {string} commandName - Command name for tracking
* @param {string} outputType - Output type (cli, mcp) * @param {string} outputType - Output type (cli, mcp)
* @param {string} projectRoot - Project root path * @param {string} projectRoot - Project root path
* @param {string} initialRole - The initial client role
* @returns {Promise<object>} AI response with usage data * @returns {Promise<object>} AI response with usage data
*/ */
async function _callGatewayAI( async function _callGatewayAI(
@@ -288,36 +289,62 @@ async function _callGatewayAI(
userId, userId,
commandName, commandName,
outputType, outputType,
projectRoot projectRoot,
initialRole
) { ) {
const gatewayUrl = // Hard-code service-level constants
process.env.TASKMASTER_GATEWAY_URL || "http://localhost:4444"; const gatewayUrl = "http://localhost:4444"; // or your production URL
const endpoint = `${gatewayUrl}/api/v1/ai/${serviceType}`; const serviceApiKey = "339a81c9-5b9c-4d60-92d8-cba2ee2a8cc3"; // Hardcoded service key -- if you change this, the Hosted Gateway will not work
// Get API key from env // Get user auth info for headers
const apiKey = resolveEnvVariable("TASKMASTER_API_KEY", null, projectRoot); const userMgmt = require("./user-management.js");
if (!apiKey) { const userToken = await userMgmt.getUserToken(projectRoot);
throw new Error("TASKMASTER_API_KEY not found for hosted mode"); const userEmail = await userMgmt.getUserEmail(projectRoot);
if (!userToken) {
throw new Error(
"User token not found. Run 'task-master init' to register with gateway."
);
} }
// need to make sure the user is authenticated and has a valid paid user token + enough credits for this call const endpoint = `${gatewayUrl}/api/v1/ai/${serviceType}`;
// Extract messages from callParams and convert to gateway format
const systemPrompt =
callParams.messages?.find((m) => m.role === "system")?.content || "";
const prompt =
callParams.messages?.find((m) => m.role === "user")?.content || "";
const requestBody = { const requestBody = {
provider: providerName, role: initialRole,
model: modelId, messages: callParams.messages,
serviceType, modelId,
userId,
commandName, commandName,
outputType, outputType,
...callParams, roleParams: {
maxTokens: callParams.maxTokens,
temperature: callParams.temperature,
},
...(serviceType === "generateObject" && {
schema: callParams.schema,
objectName: callParams.objectName,
}),
}; };
const headers = {
"Content-Type": "application/json",
"X-TaskMaster-API-Key": serviceApiKey, // Service-level auth (hardcoded)
Authorization: `Bearer ${userToken}`, // User-level auth
};
// Add user email header if available
if (userEmail) {
headers["X-User-Email"] = userEmail;
}
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method: "POST", method: "POST",
headers: { headers,
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
}); });
@@ -383,14 +410,45 @@ async function _unifiedServiceRunner(serviceType, params) {
const effectiveProjectRoot = projectRoot || findProjectRoot(); const effectiveProjectRoot = projectRoot || findProjectRoot();
const userId = getUserId(effectiveProjectRoot); const userId = getUserId(effectiveProjectRoot);
// Check if user is in hosted mode // If userId is the placeholder, try to initialize user silently
if (userId === "1234567890") {
try {
// Dynamic import to avoid circular dependency
const userMgmt = await import("./user-management.js");
const initResult = await userMgmt.initializeUser(effectiveProjectRoot);
if (initResult.success) {
// Update the config with the new userId
const { writeConfig, getConfig } = await import("./config-manager.js");
const config = getConfig(effectiveProjectRoot);
config.account.userId = initResult.userId;
writeConfig(config, effectiveProjectRoot);
log("info", "User successfully authenticated with gateway");
} else {
log("warn", `Silent auth/init failed: ${initResult.error}`);
}
} catch (error) {
log("warn", `Silent auth/init attempt failed: ${error.message}`);
}
}
// Add hosted mode check here
const hostedMode = isHostedMode(effectiveProjectRoot); const hostedMode = isHostedMode(effectiveProjectRoot);
if (hostedMode) { if (hostedMode) {
// For hosted mode, route through gateway // Route through gateway - use your existing implementation
log("info", "Routing AI call through TaskMaster gateway (hosted mode)"); log("info", "Routing AI call through TaskMaster gateway (hosted mode)");
try { try {
// Check if we have a valid userId (not placeholder)
const finalUserId = getUserId(effectiveProjectRoot); // Re-check after potential auth
if (finalUserId === "1234567890" || !finalUserId) {
throw new Error(
"Hosted mode requires user authentication. Please run 'task-master init' to register with the gateway, or switch to BYOK mode if the gateway service is unavailable."
);
}
// Get the role configuration for provider/model selection // Get the role configuration for provider/model selection
let providerName, modelId; let providerName, modelId;
if (initialRole === "main") { if (initialRole === "main") {
@@ -442,10 +500,11 @@ async function _unifiedServiceRunner(serviceType, params) {
callParams, callParams,
providerName, providerName,
modelId, modelId,
userId, finalUserId,
commandName, commandName,
outputType, outputType,
effectiveProjectRoot effectiveProjectRoot,
initialRole
); );
// For hosted mode, we don't need to submit telemetry separately // For hosted mode, we don't need to submit telemetry separately
@@ -455,7 +514,7 @@ async function _unifiedServiceRunner(serviceType, params) {
// Convert gateway account info to telemetry format for UI display // Convert gateway account info to telemetry format for UI display
telemetryData = { telemetryData = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
userId, userId: finalUserId,
commandName, commandName,
modelUsed: modelId, modelUsed: modelId,
providerName, providerName,

File diff suppressed because it is too large Load Diff

View File

@@ -65,7 +65,7 @@ const defaultConfig = {
}, },
}, },
account: { account: {
userId: null, userId: "1234567890", // Placeholder that triggers auth/init
userEmail: "", userEmail: "",
mode: "byok", mode: "byok",
telemetryEnabled: false, telemetryEnabled: false,
@@ -710,9 +710,9 @@ function isConfigFilePresent(explicitRoot = null) {
/** /**
* Gets the user ID from the configuration. * Gets the user ID from the configuration.
* Sets a default value if none exists and saves the config. * Returns a placeholder that triggers auth/init if no real userId exists.
* @param {string|null} explicitRoot - Optional explicit path to the project root. * @param {string|null} explicitRoot - Optional explicit path to the project root.
* @returns {string} The user ID (never null). * @returns {string|null} The user ID or placeholder, or null if auth unavailable.
*/ */
function getUserId(explicitRoot = null) { function getUserId(explicitRoot = null) {
const config = getConfig(explicitRoot); const config = getConfig(explicitRoot);
@@ -722,25 +722,14 @@ function getUserId(explicitRoot = null) {
config.account = { ...defaultConfig.account }; config.account = { ...defaultConfig.account };
} }
// If userId exists, return it // If userId exists and is not the placeholder, return it
if (config.account.userId) { if (config.account.userId && config.account.userId !== "1234567890") {
return config.account.userId; return config.account.userId;
} }
// Set default userId if none exists // If userId is null or the placeholder, return the placeholder
const defaultUserId = "1234567890"; // This signals to other code that auth/init needs to be attempted
config.account.userId = defaultUserId; return "1234567890";
// Save the updated config
const success = writeConfig(config, explicitRoot);
if (!success) {
log(
"warn",
"Failed to write updated configuration with new userId. Please let the developers know."
);
}
return defaultUserId;
} }
/** /**

File diff suppressed because it is too large Load Diff

View File

@@ -68,7 +68,7 @@ async function registerUserWithGateway(email = null, explicitRoot = null) {
/** /**
* Updates the user configuration with gateway registration results * Updates the user configuration with gateway registration results
* @param {string} userId - User ID from gateway * @param {string} userId - User ID from gateway
* @param {string} token - API token from gateway * @param {string} token - User authentication token from gateway (stored in .env)
* @param {string} mode - User mode ('byok' or 'hosted') * @param {string} mode - User mode ('byok' or 'hosted')
* @param {string|null} explicitRoot - Optional explicit project root path * @param {string|null} explicitRoot - Optional explicit project root path
* @returns {boolean} Success status * @returns {boolean} Success status
@@ -86,7 +86,7 @@ function updateUserConfig(userId, token, mode, explicitRoot = null) {
config.account.userId = userId; config.account.userId = userId;
config.account.mode = mode; // 'byok' or 'hosted' config.account.mode = mode; // 'byok' or 'hosted'
// Write API token to .env file (not config) // Write user authentication token to .env file (not config)
if (token) { if (token) {
writeApiKeyToEnv(token, explicitRoot); writeApiKeyToEnv(token, explicitRoot);
} }
@@ -107,8 +107,9 @@ function updateUserConfig(userId, token, mode, explicitRoot = null) {
} }
/** /**
* Writes the API token to the .env file * Writes the user authentication token to the .env file
* @param {string} token - API token to write * This token is used as Bearer auth for gateway API calls
* @param {string} token - Authentication token to write
* @param {string|null} explicitRoot - Optional explicit project root path * @param {string|null} explicitRoot - Optional explicit project root path
*/ */
function writeApiKeyToEnv(token, explicitRoot = null) { function writeApiKeyToEnv(token, explicitRoot = null) {
@@ -155,9 +156,9 @@ function writeApiKeyToEnv(token, explicitRoot = null) {
// Write updated content // Write updated content
fs.writeFileSync(envPath, envContent); fs.writeFileSync(envPath, envContent);
log("info", "API key written to .env file"); log("info", "User authentication token written to .env file");
} catch (error) { } catch (error) {
log("error", `Failed to write API key to .env: ${error.message}`); log("error", `Failed to write user token to .env: ${error.message}`);
} }
} }
@@ -303,6 +304,60 @@ async function initializeUser(explicitRoot = null) {
} }
} }
/**
* Gets the current user authentication token from .env file
* This is the Bearer token used for gateway API calls
* @param {string|null} explicitRoot - Optional explicit project root path
* @returns {string|null} User authentication token or null if not found
*/
function getUserToken(explicitRoot = null) {
try {
// Determine project root
let rootPath = explicitRoot;
if (!rootPath) {
rootPath = findProjectRoot();
if (!rootPath) {
log("error", "Could not determine project root for .env file");
return null;
}
}
const envPath = path.join(rootPath, ".env");
if (!fs.existsSync(envPath)) {
return null;
}
const envContent = fs.readFileSync(envPath, "utf8");
const lines = envContent.split("\n");
for (const line of lines) {
if (line.startsWith("TASKMASTER_API_KEY=")) {
return line.substring("TASKMASTER_API_KEY=".length).trim();
}
}
return null;
} catch (error) {
log("error", `Error getting user token from .env: ${error.message}`);
return null;
}
}
/**
* Gets the current user email from configuration
* @param {string|null} explicitRoot - Optional explicit project root path
* @returns {string|null} User email or null if not found
*/
function getUserEmail(explicitRoot = null) {
try {
const config = getConfig(explicitRoot);
return config?.global?.email || null;
} catch (error) {
log("error", `Error getting user email: ${error.message}`);
return null;
}
}
export { export {
registerUserWithGateway, registerUserWithGateway,
updateUserConfig, updateUserConfig,
@@ -312,4 +367,6 @@ export {
isByokMode, isByokMode,
setupUser, setupUser,
initializeUser, initializeUser,
getUserToken,
getUserEmail,
}; };

View File

@@ -6278,6 +6278,19 @@
], ],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": []
},
{
"id": 94,
"title": "Enhance Models Command with Mode Selection",
"description": "Improve the `task-master models --setup` command to allow users to choose between BYOK (Bring Your Own Key) mode and hosted mode, not just configure AI models. Currently, users can only change modes during initial project setup, but they should be able to switch between modes at any time.",
"details": "**Current State:**\n- Mode selection (BYOK vs hosted) only available during `task-master init` process\n- `task-master models --setup` only configures AI model selections within current mode\n- Mode is stored in `.taskmasterconfig` under `config.account.mode`\n- Error messages suggest using `task-master models --setup` to switch to BYOK mode, but this doesn't actually work\n\n**Required Implementation:**\n\n1. **Update `runInteractiveSetup()` function in `scripts/modules/commands.js`:**\n - Add mode selection as first step before model configuration\n - Show current mode and allow switching\n - Use same UI pattern as `scripts/init.js` `selectAccessMode()` function\n - Option to keep current mode or change to the other mode\n\n2. **Add mode switching logic:**\n - If switching from hosted to BYOK: Clear hosted tokens, show API key setup instructions\n - If switching from BYOK to hosted: Register with gateway, get user token\n - Update `config.account.mode` in `.taskmasterconfig`\n - Update `config.account.telemetryEnabled` (true for hosted, false for BYOK)\n\n3. **Add non-interactive mode switching flags:**\n - Add `--switch-to-byok` flag to models command\n - Add `--switch-to-hosted` flag to models command \n - Allow direct mode switching without interactive prompts\n\n4. **Update command help and examples:**\n - Add examples showing mode switching\n - Update description to mention mode selection capability\n\n**Technical Details:**\n\n- Import needed functions from `user-management.js`: `updateUserConfig`, `getUserMode`, `registerUserWithGateway`\n- Use existing `selectAccessMode()` logic from `scripts/init.js` as reference\n- Ensure mode changes are reflected immediately in subsequent AI calls\n- Handle gateway registration gracefully with proper error messages\n- Show clear confirmation of mode changes with next steps\n\n**User Experience Flow:**\n```\n$ task-master models --setup\n✓ Current Mode: BYOK\n? Do you want to:\n > Keep current mode (BYOK) and configure models\n Switch to Hosted mode\n Cancel\n\n[If switching to hosted]\n🎯 Hosted API Gateway Selected\n→ Registering with gateway...\n✅ Successfully switched to hosted mode\n→ All AI models are now available through the gateway\n→ No API keys needed in .env file\n\n[Then continue with model selection as normal]\n```\n\n**Files to Modify:**\n- `scripts/modules/commands.js` (main implementation)\n- `scripts/modules/task-manager/models.js` (if needed for MCP support)\n- Tests in `tests/integration/cli/` for new functionality\n\n**Success Criteria:**\n- Users can switch between BYOK and hosted mode via `task-master models --setup`\n- Non-interactive flags work: `--switch-to-byok` and `--switch-to-hosted`\n- Mode changes persist in `.taskmasterconfig`\n- Error handling for gateway connectivity issues\n- Clear user feedback about mode changes and next steps",
"testStrategy": "**Testing Approach:**\n\n1. **Integration Tests:**\n - Test mode switching from BYOK to hosted and vice versa\n - Verify `.taskmasterconfig` updates correctly with new mode\n - Test non-interactive flags `--switch-to-byok` and `--switch-to-hosted`\n - Test error handling when gateway is unavailable\n\n2. **Manual Testing:**\n - Run `task-master models --setup` in both modes\n - Verify UI flow and user experience\n - Test AI calls work correctly after mode switches\n - Verify API key requirements/removal after mode changes\n\n3. **Unit Tests:**\n - Test mode detection and switching logic\n - Test configuration updates\n - Test error scenarios (gateway down, invalid config)\n\n4. **End-to-End Testing:**\n - Full workflow: init in one mode → switch mode → configure models → test AI calls\n - Verify MCP tool equivalents work correctly after mode changes",
"status": "pending",
"dependencies": [
16
],
"priority": "high",
"subtasks": []
} }
] ]
} }

View File

@@ -25,6 +25,7 @@ jest.unstable_mockModule(
getAzureBaseURL: jest.fn(), getAzureBaseURL: jest.fn(),
getVertexProjectId: jest.fn(), getVertexProjectId: jest.fn(),
getVertexLocation: jest.fn(), getVertexLocation: jest.fn(),
writeConfig: jest.fn(() => true),
MODEL_MAP: { MODEL_MAP: {
openai: [ openai: [
{ {