aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
1,353 lines (1,183 loc) • 40.9 kB
JavaScript
/**
* Pro Installation Wizard with License Gate
*
* 3-step wizard: (1) License Gate, (2) Install/Scaffold, (3) Verify
* Supports interactive mode, CI mode (AIOS_PRO_KEY/AIOS_PRO_EMAIL env vars), and lazy import.
*
* License Gate supports two activation methods:
* - Email + Password authentication (recommended, PRO-11)
* - License key (legacy, PRO-6)
*
* @module wizard/pro-setup
* @story INS-3.2 — Implement Pro Installation Wizard with License Gate
* @story PRO-11 — Email Authentication & Buyer-Based Pro Activation
*/
'use strict';
const { createSpinner, showSuccess, showError, showWarning, showInfo } = require('./feedback');
const { colors, status } = require('../utils/aios-colors');
const { t, tf } = require('./i18n');
/**
* Gold color for Pro branding.
* Falls back gracefully if chalk hex is unavailable.
*/
let gold;
try {
const chalk = require('chalk');
gold = chalk.hex('#FFD700').bold;
} catch {
gold = (text) => text;
}
/**
* License server base URL (same source of truth as license-api.js CONFIG.BASE_URL).
*/
const LICENSE_SERVER_URL = process.env.AIOS_LICENSE_API_URL || 'https://aios-license-server.vercel.app';
/**
* License key format: PRO-XXXX-XXXX-XXXX-XXXX
*/
const LICENSE_KEY_PATTERN = /^PRO-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
/**
* Maximum retry attempts for license validation.
*/
const MAX_RETRIES = 3;
/**
* Email verification polling interval in milliseconds.
*/
const VERIFY_POLL_INTERVAL_MS = 5000;
/**
* Email verification polling timeout in milliseconds (10 minutes).
*/
const VERIFY_POLL_TIMEOUT_MS = 10 * 60 * 1000;
/**
* Minimum password length.
*/
const MIN_PASSWORD_LENGTH = 8;
/**
* Email format regex.
*/
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Detect CI environment.
*
* @returns {boolean} true if running in CI or non-interactive terminal
*/
function isCIEnvironment() {
return process.env.CI === 'true' || !process.stdout.isTTY;
}
/**
* Mask a license key for safe display.
* Shows first and last segments, masks middle two.
* Example: PRO-ABCD-****-****-WXYZ
*
* @param {string} key - License key
* @returns {string} Masked key
*/
function maskLicenseKey(key) {
if (!key || typeof key !== 'string') {
return '****';
}
const trimmed = key.trim().toUpperCase();
if (!LICENSE_KEY_PATTERN.test(trimmed)) {
return '****';
}
const parts = trimmed.split('-');
return `${parts[0]}-${parts[1]}-****-****-${parts[4]}`;
}
/**
* Validate license key format before sending to API.
*
* @param {string} key - License key
* @returns {boolean} true if format is valid
*/
function validateKeyFormat(key) {
if (!key || typeof key !== 'string') {
return false;
}
return LICENSE_KEY_PATTERN.test(key.trim().toUpperCase());
}
/**
* Show the Pro branding header.
*/
function showProHeader() {
const title = t('proWizardTitle');
const subtitle = t('proWizardSubtitle');
const maxLen = Math.max(title.length, subtitle.length) + 10;
const pad = (str) => {
const totalPad = maxLen - str.length;
const left = Math.floor(totalPad / 2);
const right = totalPad - left;
return ' '.repeat(left) + str + ' '.repeat(right);
};
console.log('');
console.log(gold(' ╔' + '═'.repeat(maxLen) + '╗'));
console.log(gold(' ║' + pad(title) + '║'));
console.log(gold(' ║' + pad(subtitle) + '║'));
console.log(gold(' ╚' + '═'.repeat(maxLen) + '╝'));
console.log('');
}
/**
* Show step indicator.
*
* @param {number} current - Current step (1-based)
* @param {number} total - Total steps
* @param {string} label - Step label
*/
function showStep(current, total, label) {
const progress = `[${current}/${total}]`;
console.log(gold(`\n ${progress} ${label}`));
console.log(colors.dim(' ' + '─'.repeat(44)));
}
/**
* Try to load a pro license module via multiple resolution paths.
*
* Resolution order:
* 1. Relative path (framework-dev mode: ../../../../pro/license/{name})
* 2. @aios-fullstack/pro package (brownfield: node_modules/@aios-fullstack/pro/license/{name})
* 3. Absolute path via aios-core in node_modules (brownfield upgrade)
* 4. Absolute path via @aios-fullstack/pro in user project (npx context)
*
* Path 4 is critical for npx execution: when running `npx aios-core install`,
* require() resolves from the npx temp directory, not process.cwd(). After
* bootstrap installs @aios-fullstack/pro in the user's project, only an
* absolute path to process.cwd()/node_modules/@aios-fullstack/pro/... works.
*
* @param {string} moduleName - Module filename without extension (e.g., 'license-api')
* @returns {Object|null} Loaded module or null
*/
function loadProModule(moduleName) {
const path = require('path');
// 1. Framework-dev mode (cloned repo with pro/ submodule)
try {
return require(`../../../../pro/license/${moduleName}`);
} catch { /* not available */ }
// 2. @aios-fullstack/pro package (works when aios-core is a local dependency)
try {
return require(`@aios-fullstack/pro/license/${moduleName}`);
} catch { /* not available */ }
// 3. aios-core in node_modules (brownfield upgrade from >= v4.2.15)
try {
const absPath = path.join(process.cwd(), 'node_modules', 'aios-core', 'pro', 'license', moduleName);
return require(absPath);
} catch { /* not available */ }
// 4. @aios-fullstack/pro in user project (npx context — require resolves from
// temp dir, so we need absolute path to where bootstrap installed the package)
try {
const absPath = path.join(process.cwd(), 'node_modules', '@aios-fullstack', 'pro', 'license', moduleName);
return require(absPath);
} catch { /* not available */ }
return null;
}
/**
* Try to load the license API client via lazy import.
* Attempts multiple resolution paths for framework-dev, greenfield, and brownfield.
*
* @returns {{ LicenseApiClient: Function, licenseApi: Object }|null} License API or null
*/
function loadLicenseApi() {
return loadProModule('license-api');
}
/**
* Try to load the feature gate via lazy import.
* Attempts multiple resolution paths for framework-dev, greenfield, and brownfield.
*
* @returns {{ featureGate: Object }|null} Feature gate or null
*/
function loadFeatureGate() {
return loadProModule('feature-gate');
}
/**
* Try to load the pro scaffolder via lazy import.
*
* @returns {{ scaffoldProContent: Function }|null} Scaffolder or null
*/
function loadProScaffolder() {
try {
return require('../pro/pro-scaffolder');
} catch {
return null;
}
}
/**
* Step 1: License Gate — authenticate and validate license.
*
* Supports two activation methods:
* 1. Email + Password authentication (recommended, PRO-11)
* 2. License key (legacy, PRO-6)
*
* In CI mode, reads from AIOS_PRO_EMAIL + AIOS_PRO_PASSWORD or AIOS_PRO_KEY env vars.
* In interactive mode, prompts user to choose method.
*
* @param {Object} [options={}] - Options
* @param {string} [options.key] - Pre-provided key (from CLI args or env)
* @param {string} [options.email] - Pre-provided email (from CLI args or env)
* @param {string} [options.password] - Pre-provided password (from CLI args or env)
* @returns {Promise<Object>} Result with { success, key, activationResult }
*/
async function stepLicenseGate(options = {}) {
showStep(1, 3, t('proLicenseActivation'));
const isCI = isCIEnvironment();
// CI mode: check env vars
if (isCI) {
return stepLicenseGateCI(options);
}
// Pre-provided key (from CLI args)
if (options.key) {
return stepLicenseGateWithKey(options.key);
}
// Pre-provided email credentials (from CLI args)
if (options.email && options.password) {
return authenticateWithEmail(options.email, options.password);
}
// Interactive mode: prompt for method
const inquirer = require('inquirer');
const { method } = await inquirer.prompt([
{
type: 'list',
name: 'method',
message: colors.primary(t('proHowActivate')),
choices: [
{
name: t('proLoginOrCreate'),
value: 'email',
},
{
name: t('proEnterKey'),
value: 'key',
},
],
},
]);
if (method === 'email') {
return stepLicenseGateWithEmail();
}
return stepLicenseGateWithKeyInteractive();
}
/**
* CI mode license gate — reads from env vars.
*
* Priority: AIOS_PRO_EMAIL + AIOS_PRO_PASSWORD > AIOS_PRO_KEY
*
* @param {Object} options - Options with possible pre-provided credentials
* @returns {Promise<Object>} Result with { success, key, activationResult }
*/
async function stepLicenseGateCI(options) {
const email = options.email || process.env.AIOS_PRO_EMAIL;
const password = options.password || process.env.AIOS_PRO_PASSWORD;
const key = options.key || process.env.AIOS_PRO_KEY;
// Prefer email auth over key
if (email && password) {
return authenticateWithEmail(email, password);
}
if (key) {
return stepLicenseGateWithKey(key);
}
return {
success: false,
error: t('proCISetEnv'),
};
}
/**
* Interactive email/password license gate flow.
*
* New flow (PRO-11 v2):
* 1. Email → checkEmail API → { isBuyer, hasAccount }
* 2. NOT buyer → "No Pro access found" → STOP
* 3. IS buyer + HAS account → Password → Login (with retry) → Activate
* 4. IS buyer + NO account → Password + Confirm → Signup → Verify email → Login → Activate
*
* @returns {Promise<Object>} Result with { success, key, activationResult }
*/
async function stepLicenseGateWithEmail() {
const inquirer = require('inquirer');
// Step 1: Get email
const { email } = await inquirer.prompt([
{
type: 'input',
name: 'email',
message: colors.primary(t('proEmailLabel')),
validate: (input) => {
if (!input || !input.trim()) {
return t('proEmailRequired');
}
if (!EMAIL_PATTERN.test(input.trim())) {
return t('proEmailInvalid');
}
return true;
},
},
]);
const trimmedEmail = email.trim();
// Step 2: Check buyer status + account existence
const loader = module.exports._testing ? module.exports._testing.loadLicenseApi : loadLicenseApi;
const licenseModule = loader();
if (!licenseModule) {
return {
success: false,
error: t('proModuleNotAvailable'),
};
}
const { LicenseApiClient } = licenseModule;
const client = new LicenseApiClient();
// Check connectivity
const online = await client.isOnline();
if (!online) {
return {
success: false,
error: t('proServerUnreachable'),
};
}
const checkSpinner = createSpinner(t('proVerifyingAccess'));
checkSpinner.start();
let checkResult;
try {
checkResult = await client.checkEmail(trimmedEmail);
} catch (checkError) {
checkSpinner.fail(tf('proVerificationFailed', { message: checkError.message }));
return { success: false, error: checkError.message };
}
// Step 2a: NOT a buyer → stop
if (!checkResult.isBuyer) {
checkSpinner.fail(t('proNoAccess'));
console.log('');
showInfo(t('proContactSupport'));
showInfo(' Issues: https://github.com/SynkraAI/aios-core/issues');
showInfo(' ' + t('proPurchase'));
return { success: false, error: t('proEmailNotBuyer') };
}
// Step 2b: IS a buyer
if (checkResult.hasAccount) {
checkSpinner.succeed(t('proAccessConfirmedAccount'));
// Flow 3: Existing account → Login with password (retry loop)
return loginWithRetry(client, trimmedEmail);
}
checkSpinner.succeed(t('proAccessConfirmedCreate'));
// Flow 4: New account → Create account flow
return createAccountFlow(client, trimmedEmail);
}
/**
* Login flow with password retry (max 3 attempts).
*
* @param {object} client - LicenseApiClient instance
* @param {string} email - Verified buyer email
* @returns {Promise<Object>} Result with { success, key, activationResult }
*/
async function loginWithRetry(client, email) {
const inquirer = require('inquirer');
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const { password } = await inquirer.prompt([
{
type: 'password',
name: 'password',
message: colors.primary(t('proPasswordLabel')),
mask: '*',
validate: (input) => {
if (!input || input.length < MIN_PASSWORD_LENGTH) {
return tf('proPasswordMin', { min: MIN_PASSWORD_LENGTH });
}
return true;
},
},
]);
const spinner = createSpinner(t('proAuthenticating'));
spinner.start();
try {
const loginResult = await client.login(email, password);
spinner.succeed(t('proAuthSuccess'));
// Wait for email verification if needed
if (!loginResult.emailVerified) {
const verifyResult = await waitForEmailVerification(client, loginResult.sessionToken, email);
if (!verifyResult.success) {
return verifyResult;
}
}
// Activate Pro
return activateProByAuth(client, loginResult.sessionToken);
} catch (loginError) {
if (loginError.code === 'EMAIL_NOT_VERIFIED') {
// Email not verified — poll by retrying login until verified
spinner.info(t('proEmailNotVerified'));
console.log(colors.dim(' ' + t('proCheckingEvery')));
const startTime = Date.now();
while (Date.now() - startTime < VERIFY_POLL_TIMEOUT_MS) {
await new Promise((resolve) => setTimeout(resolve, VERIFY_POLL_INTERVAL_MS));
try {
const retryLogin = await client.login(email, password);
showSuccess(t('proEmailVerified'));
if (!retryLogin.emailVerified) {
const verifyResult = await waitForEmailVerification(client, retryLogin.sessionToken, email);
if (!verifyResult.success) return verifyResult;
}
return activateProByAuth(client, retryLogin.sessionToken);
} catch (retryError) {
if (retryError.code !== 'EMAIL_NOT_VERIFIED') {
return { success: false, error: retryError.message };
}
// Still not verified, continue polling
}
}
showError(t('proVerificationTimeout'));
showInfo(t('proRunAgain'));
return { success: false, error: t('proVerificationTimeout') };
} else if (loginError.code === 'INVALID_CREDENTIALS') {
const remaining = MAX_RETRIES - attempt;
if (remaining > 0) {
spinner.fail(`Incorrect password. ${remaining} attempt${remaining > 1 ? 's' : ''} remaining.`);
showInfo('Forgot your password? Visit https://aios-license-server.vercel.app/reset-password');
} else {
spinner.fail('Maximum login attempts reached.');
showInfo('Forgot your password? Visit https://aios-license-server.vercel.app/reset-password');
showInfo('Or open an issue: https://github.com/SynkraAI/aios-core/issues');
return { success: false, error: 'Maximum login attempts reached.' };
}
} else if (loginError.code === 'AUTH_RATE_LIMITED') {
spinner.fail(loginError.message);
return { success: false, error: loginError.message };
} else {
spinner.fail(tf('proAuthFailed', { message: loginError.message }));
return { success: false, error: loginError.message };
}
}
}
return { success: false, error: t('proMaxAttempts') };
}
/**
* Create account flow for new buyers.
*
* Asks for password, creates account, waits for email verification.
*
* @param {object} client - LicenseApiClient instance
* @param {string} email - Verified buyer email
* @returns {Promise<Object>} Result with { success, key, activationResult }
*/
async function createAccountFlow(client, email) {
const inquirer = require('inquirer');
console.log('');
showInfo(t('proCreateAccount'));
// Ask for password with confirmation
const { newPassword } = await inquirer.prompt([
{
type: 'password',
name: 'newPassword',
message: colors.primary(t('proChoosePassword')),
mask: '*',
validate: (input) => {
if (!input || input.length < MIN_PASSWORD_LENGTH) {
return tf('proPasswordMin', { min: MIN_PASSWORD_LENGTH });
}
return true;
},
},
]);
const { confirmPassword } = await inquirer.prompt([
{
type: 'password',
name: 'confirmPassword',
message: colors.primary(t('proConfirmPassword')),
mask: '*',
validate: (input) => {
if (input !== newPassword) {
return t('proPasswordsNoMatch');
}
return true;
},
},
]);
// Create account
const spinner = createSpinner(t('proCreatingAccount'));
spinner.start();
let sessionToken;
try {
await client.signup(email, confirmPassword);
spinner.succeed(t('proAccountCreated'));
} catch (signupError) {
if (signupError.code === 'EMAIL_ALREADY_REGISTERED') {
spinner.info(t('proAccountExists'));
return loginWithRetry(client, email);
}
spinner.fail(tf('proAccountFailed', { message: signupError.message }));
return { success: false, error: signupError.message };
}
// Wait for email verification
console.log('');
showInfo(t('proCheckEmail'));
// Login after signup to get session token
try {
const loginResult = await client.login(email, confirmPassword);
sessionToken = loginResult.sessionToken;
} catch {
// Login might fail if email not verified yet — that's OK, we'll poll
}
if (sessionToken) {
const verifyResult = await waitForEmailVerification(client, sessionToken, email);
if (!verifyResult.success) {
return verifyResult;
}
} else {
// Need to wait for verification then login
showInfo(t('proWaitingVerification'));
showInfo(t('proAfterVerifying'));
// Poll by trying to login periodically
const startTime = Date.now();
while (Date.now() - startTime < VERIFY_POLL_TIMEOUT_MS) {
await new Promise((resolve) => setTimeout(resolve, VERIFY_POLL_INTERVAL_MS));
try {
const loginResult = await client.login(email, confirmPassword);
sessionToken = loginResult.sessionToken;
if (loginResult.emailVerified) {
showSuccess('Email verified!');
break;
}
// Got session but not verified yet — use the verification polling
const verifyResult = await waitForEmailVerification(client, sessionToken, email);
if (!verifyResult.success) {
return verifyResult;
}
break;
} catch {
// Still waiting for verification
}
}
if (!sessionToken) {
showError(t('proVerificationTimeout'));
showInfo(t('proRunAgain'));
return { success: false, error: t('proVerificationTimeout') };
}
}
// Activate Pro
return activateProByAuth(client, sessionToken);
}
/**
* Authenticate with email and password (CI mode / pre-provided credentials).
*
* For interactive mode, use stepLicenseGateWithEmail() instead (buyer-first flow).
* This function is used when credentials are pre-provided (CI, CLI args).
*
* @param {string} email - User email
* @param {string} password - User password
* @returns {Promise<Object>} Result with { success, key, activationResult }
*/
async function authenticateWithEmail(email, password) {
const loader = module.exports._testing ? module.exports._testing.loadLicenseApi : loadLicenseApi;
const licenseModule = loader();
if (!licenseModule) {
return {
success: false,
error: t('proModuleNotAvailable'),
};
}
const { LicenseApiClient } = licenseModule;
const client = new LicenseApiClient();
// Check connectivity
const online = await client.isOnline();
if (!online) {
return {
success: false,
error: t('proServerUnreachable'),
};
}
// CI mode: check buyer first, then try login or auto-signup
const checkSpinner = createSpinner(t('proVerifyingAccessShort'));
checkSpinner.start();
try {
const checkResult = await client.checkEmail(email);
if (!checkResult.isBuyer) {
checkSpinner.fail(t('proNoAccess'));
return { success: false, error: t('proEmailNotBuyer') };
}
checkSpinner.succeed(t('proAccessConfirmed'));
} catch {
checkSpinner.info(t('proBuyerCheckUnavailable'));
}
// Try login
const spinner = createSpinner(t('proAuthenticating'));
spinner.start();
let sessionToken;
let emailVerified;
try {
const loginResult = await client.login(email, password);
sessionToken = loginResult.sessionToken;
emailVerified = loginResult.emailVerified;
spinner.succeed(t('proAuthSuccess'));
} catch (loginError) {
if (loginError.code === 'INVALID_CREDENTIALS') {
spinner.info(t('proLoginFailedSignup'));
try {
await client.signup(email, password);
showSuccess(t('proAccountCreatedVerify'));
emailVerified = false;
const loginAfterSignup = await client.login(email, password);
sessionToken = loginAfterSignup.sessionToken;
} catch (signupError) {
if (signupError.code === 'EMAIL_ALREADY_REGISTERED') {
showError(t('proAccountExistsWrongPw'));
return { success: false, error: t('proAccountExistsWrongPw') };
}
return { success: false, error: signupError.message };
}
} else {
spinner.fail(tf('proAuthFailed', { message: loginError.message }));
return { success: false, error: loginError.message };
}
}
if (!sessionToken) {
return { success: false, error: t('proAuthFailedShort') };
}
// Wait for email verification if needed
if (!emailVerified) {
const verifyResult = await waitForEmailVerification(client, sessionToken, email);
if (!verifyResult.success) {
return verifyResult;
}
}
// Activate Pro
return activateProByAuth(client, sessionToken);
}
/**
* Wait for email verification with polling.
*
* Polls the server every 5 seconds for up to 10 minutes.
* User can press R to resend verification email.
*
* @param {object} client - LicenseApiClient instance
* @param {string} sessionToken - Session token (accessToken)
* @param {string} email - User email for resend functionality
* @returns {Promise<Object>} Result with { success }
*/
async function waitForEmailVerification(client, sessionToken, email) {
console.log('');
showInfo(t('proWaitingVerification'));
showInfo(t('proCheckEmail'));
console.log(colors.dim(' ' + t('proCheckingEvery')));
if (!isCIEnvironment()) {
console.log(colors.dim(' ' + t('proPressResend')));
}
const startTime = Date.now();
let resendHint = false;
// Set up keyboard listener for resend (non-CI only)
let keyListener;
if (!isCIEnvironment() && process.stdin.setRawMode) {
process.stdin.setRawMode(true);
process.stdin.resume();
keyListener = (key) => {
if (key.toString().toLowerCase() === 'r') {
resendHint = true;
}
// Ctrl+C
if (key.toString() === '\u0003') {
cleanupKeyListener();
process.exit(0);
}
};
process.stdin.on('data', keyListener);
}
function cleanupKeyListener() {
if (keyListener) {
process.stdin.removeListener('data', keyListener);
if (process.stdin.setRawMode) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
}
}
try {
while (Date.now() - startTime < VERIFY_POLL_TIMEOUT_MS) {
// Handle resend request
if (resendHint) {
resendHint = false;
try {
await client.resendVerification(email);
showInfo(t('proVerificationResent'));
} catch (error) {
showWarning(tf('proCouldNotResend', { message: error.message }));
}
}
// Poll verification status
try {
const status = await client.checkEmailVerified(sessionToken);
if (status.verified) {
showSuccess(t('proEmailVerified'));
return { success: true };
}
} catch {
// Polling failure is non-fatal, continue
}
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, VERIFY_POLL_INTERVAL_MS));
}
// Timeout
showError(t('proVerificationTimeout'));
showInfo(t('proRunAgainRetry'));
return { success: false, error: t('proVerificationTimeout') };
} finally {
cleanupKeyListener();
}
}
/**
* Activate Pro using an authenticated session.
*
* @param {object} client - LicenseApiClient instance
* @param {string} sessionToken - Authenticated session token
* @returns {Promise<Object>} Result with { success, key, activationResult }
*/
async function activateProByAuth(client, sessionToken) {
const spinner = createSpinner(t('proValidatingSubscription'));
spinner.start();
try {
// Generate machine fingerprint
const os = require('os');
const crypto = require('crypto');
const machineId = crypto
.createHash('sha256')
.update(`${os.hostname()}-${os.platform()}-${os.arch()}`)
.digest('hex')
.substring(0, 32);
// Read aios-core version
let aiosCoreVersion = 'unknown';
try {
const path = require('path');
const fs = require('fs');
const pkgPath = path.join(__dirname, '..', '..', '..', '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
aiosCoreVersion = pkg.version || 'unknown';
} catch {
// Keep 'unknown'
}
const activationResult = await client.activateByAuth(sessionToken, machineId, aiosCoreVersion);
spinner.succeed(tf('proSubscriptionConfirmed', { key: maskLicenseKey(activationResult.key) }));
return { success: true, key: activationResult.key, activationResult };
} catch (error) {
if (error.code === 'NOT_A_BUYER') {
spinner.fail(t('proNoSubscription'));
showInfo(t('proPurchaseAt'));
return { success: false, error: error.message };
}
if (error.code === 'SEAT_LIMIT_EXCEEDED') {
spinner.fail(error.message);
showInfo(t('proSeatLimit'));
return { success: false, error: error.message };
}
if (error.code === 'ALREADY_ACTIVATED') {
// License already exists — treat as success (re-install scenario)
spinner.succeed(t('proAlreadyActivated'));
return { success: true, key: 'existing', activationResult: { reactivation: true } };
}
spinner.fail(tf('proActivationFailed', { message: error.message }));
return { success: false, error: error.message };
}
}
/**
* Interactive license key gate (legacy flow).
*
* @returns {Promise<Object>} Result with { success, key, activationResult }
*/
async function stepLicenseGateWithKeyInteractive() {
const inquirer = require('inquirer');
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const { licenseKey } = await inquirer.prompt([
{
type: 'password',
name: 'licenseKey',
message: colors.primary(t('proEnterKeyPrompt')),
mask: '*',
validate: (input) => {
if (!input || !input.trim()) {
return t('proKeyRequired');
}
if (!validateKeyFormat(input)) {
return t('proKeyInvalid');
}
return true;
},
},
]);
const key = licenseKey.trim().toUpperCase();
const result = await validateKeyWithApi(key);
if (result.success) {
showSuccess(tf('proKeyValidated', { key: maskLicenseKey(key) }));
return { success: true, key, activationResult: result.data };
}
const remaining = MAX_RETRIES - attempt;
if (remaining > 0) {
showError(`${result.error} (${remaining} attempt${remaining > 1 ? 's' : ''} remaining)`);
} else {
showError(`${result.error} — no attempts remaining.`);
return { success: false, error: result.error };
}
}
return { success: false, error: 'Maximum attempts reached.' };
}
/**
* Validate with pre-provided license key (CI or CLI arg).
*
* @param {string} key - License key
* @returns {Promise<Object>} Result with { success, key, activationResult }
*/
async function stepLicenseGateWithKey(key) {
if (!validateKeyFormat(key)) {
return {
success: false,
error: tf('proInvalidKeyFormat', { key: maskLicenseKey(key) }),
};
}
const spinner = createSpinner(tf('proValidatingKey', { key: maskLicenseKey(key) }));
spinner.start();
const result = await validateKeyWithApi(key);
if (result.success) {
spinner.succeed(tf('proKeyValidated', { key: maskLicenseKey(key) }));
return { success: true, key, activationResult: result.data };
}
spinner.fail(result.error);
return { success: false, error: result.error };
}
/**
* Validate a key against the license API.
*
* @param {string} key - License key
* @returns {Promise<Object>} Result with { success, data?, error? }
*/
async function validateKeyWithApi(key) {
// Use exports._testing for testability (allows mock injection)
const loader = module.exports._testing ? module.exports._testing.loadLicenseApi : loadLicenseApi;
const licenseModule = loader();
if (!licenseModule) {
return {
success: false,
error: t('proModuleNotAvailable'),
};
}
const { LicenseApiClient } = licenseModule;
const client = new LicenseApiClient();
try {
// Check if API is reachable
const online = await client.isOnline();
if (!online) {
return {
success: false,
error: t('proServerUnreachable'),
};
}
// Generate a simple machine fingerprint
const os = require('os');
const crypto = require('crypto');
const machineId = crypto
.createHash('sha256')
.update(`${os.hostname()}-${os.platform()}-${os.arch()}`)
.digest('hex')
.substring(0, 32);
// Read aios-core version
let aiosCoreVersion = 'unknown';
try {
const path = require('path');
const fs = require('fs');
const pkgPath = path.join(__dirname, '..', '..', '..', '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
aiosCoreVersion = pkg.version || 'unknown';
} catch {
// Keep 'unknown'
}
const activationResult = await client.activate(key, machineId, aiosCoreVersion);
return { success: true, data: activationResult };
} catch (error) {
// Handle specific error codes from license-api
if (error.code === 'INVALID_KEY') {
return { success: false, error: t('proInvalidKey') };
}
if (error.code === 'EXPIRED_KEY') {
return { success: false, error: t('proExpiredKey') };
}
if (error.code === 'SEAT_LIMIT_EXCEEDED') {
return { success: false, error: t('proMaxActivations') };
}
if (error.code === 'RATE_LIMITED') {
return { success: false, error: t('proRateLimited') };
}
if (error.code === 'NETWORK_ERROR') {
return {
success: false,
error: t('proServerUnreachable'),
};
}
return {
success: false,
error: tf('proValidationFailed', { message: error.message || 'Unknown error' }),
};
}
}
/**
* Step 2: Install/Scaffold — copy pro content into the project.
*
* @param {string} targetDir - Project root directory
* @param {Object} [options={}] - Options
* @returns {Promise<Object>} Result with { success, scaffoldResult }
*/
async function stepInstallScaffold(targetDir, options = {}) {
showStep(2, 3, t('proContentInstallation'));
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const proSourceDir = path.join(targetDir, 'node_modules', '@aios-fullstack', 'pro');
// Step 2a: Ensure package.json exists (greenfield projects)
const packageJsonPath = path.join(targetDir, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
const initSpinner = createSpinner(t('proInitPackageJson'));
initSpinner.start();
try {
execSync('npm init -y', { cwd: targetDir, stdio: 'pipe' });
initSpinner.succeed(t('proPackageJsonCreated'));
} catch (err) {
initSpinner.fail(t('proPackageJsonFailed'));
return { success: false, error: tf('proNpmInitFailed', { message: err.message }) };
}
}
// Step 2b: Install @aios-fullstack/pro if not present
if (!fs.existsSync(proSourceDir)) {
const installSpinner = createSpinner(t('proInstallingPackage'));
installSpinner.start();
try {
execSync('npm install @aios-fullstack/pro', {
cwd: targetDir,
stdio: 'pipe',
timeout: 120000,
});
installSpinner.succeed(t('proPackageInstalled'));
} catch (err) {
installSpinner.fail(t('proPackageInstallFailed'));
return {
success: false,
error: tf('proNpmInstallFailed', { message: err.message }),
};
}
// Validate installation
if (!fs.existsSync(proSourceDir)) {
return {
success: false,
error: t('proPackageNotFound'),
};
}
}
// Step 2c: Scaffold pro content
const scaffolderModule = loadProScaffolder();
if (!scaffolderModule) {
showWarning(t('proScaffolderNotAvailable'));
return { success: false, error: t('proScaffolderNotFound') };
}
const { scaffoldProContent } = scaffolderModule;
const spinner = createSpinner(t('proScaffolding'));
spinner.start();
try {
const scaffoldResult = await scaffoldProContent(targetDir, proSourceDir, {
onProgress: (progress) => {
spinner.text = tf('proScaffoldingProgress', { message: progress.message });
},
force: options.force || false,
});
if (scaffoldResult.success) {
spinner.succeed(tf('proContentInstalled', { count: scaffoldResult.copiedFiles.length }));
if (scaffoldResult.warnings.length > 0) {
for (const warning of scaffoldResult.warnings) {
showWarning(warning);
}
}
return { success: true, scaffoldResult };
}
spinner.fail(t('proScaffoldFailed'));
for (const error of scaffoldResult.errors) {
showError(error);
}
return { success: false, error: scaffoldResult.errors.join('; '), scaffoldResult };
} catch (error) {
spinner.fail(tf('proScaffoldError', { message: error.message }));
return { success: false, error: error.message };
}
}
/**
* Step 3: Verify — check installed pro content and list features.
*
* @param {Object} [scaffoldResult] - Result from step 2
* @returns {Promise<Object>} Verification result
*/
async function stepVerify(scaffoldResult) {
showStep(3, 3, t('proVerification'));
const result = {
success: true,
features: [],
squads: [],
configs: [],
};
// Show scaffolded content summary
if (scaffoldResult && scaffoldResult.copiedFiles) {
const files = scaffoldResult.copiedFiles;
// Categorize files
result.squads = files.filter((f) => f.startsWith('squads/'));
result.configs = files.filter(
(f) => f.endsWith('.yaml') || f.endsWith('.json'),
);
showInfo(tf('proFilesInstalled', { count: files.length }));
if (result.squads.length > 0) {
// Extract unique squad names
const squadNames = [...new Set(
result.squads
.map((f) => f.split('/')[1])
.filter(Boolean),
)];
showSuccess(tf('proSquads', { names: squadNames.join(', ') }));
}
if (result.configs.length > 0) {
showSuccess(tf('proConfigs', { count: result.configs.length }));
}
}
// Check feature gate if available
const featureModule = loadFeatureGate();
if (featureModule) {
const { featureGate } = featureModule;
featureGate.reload();
const available = featureGate.listAvailable();
result.features = available;
if (available.length > 0) {
showSuccess(tf('proFeaturesUnlocked', { count: available.length }));
for (const feature of available.slice(0, 5)) {
console.log(colors.dim(` ${feature}`));
}
if (available.length > 5) {
console.log(colors.dim(` ... and ${available.length - 5} more`));
}
}
}
// Final status
console.log('');
console.log(gold(' ════════════════════════════════════════════════'));
console.log(status.celebrate(t('proInstallComplete')));
console.log(gold(' ════════════════════════════════════════════════'));
console.log('');
return result;
}
/**
* Run the full Pro Installation Wizard.
*
* Main entry point. Orchestrates the 3-step flow:
* 1. License Gate (validate key)
* 2. Install/Scaffold (copy pro content)
* 3. Verify (list installed features)
*
* @param {Object} [options={}] - Wizard options
* @param {string} [options.key] - Pre-provided license key
* @param {string} [options.targetDir] - Project root (default: process.cwd())
* @param {boolean} [options.force] - Force overwrite existing content
* @param {boolean} [options.quiet] - Suppress non-essential output
* @returns {Promise<Object>} Wizard result
*/
async function runProWizard(options = {}) {
const targetDir = options.targetDir || process.cwd();
const isCI = isCIEnvironment();
const result = {
success: false,
licenseValidated: false,
scaffolded: false,
verified: false,
};
// Show branding (skip in CI or quiet mode)
if (!isCI && !options.quiet) {
showProHeader();
}
// Pre-check: If license module is not available (brownfield upgrade from older version),
// install @aios-fullstack/pro first to get the license API, then proceed with gate.
if (!loadLicenseApi()) {
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
showInfo(t('proModuleBootstrap'));
// Ensure package.json exists
const packageJsonPath = path.join(targetDir, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
execSync('npm init -y', { cwd: targetDir, stdio: 'pipe' });
}
// Install @aios-fullstack/pro to get license module
const proDir = path.join(targetDir, 'node_modules', '@aios-fullstack', 'pro');
if (!fs.existsSync(proDir)) {
const installSpinner = createSpinner(t('proInstallingPackage'));
installSpinner.start();
try {
execSync('npm install @aios-fullstack/pro', {
cwd: targetDir,
stdio: 'pipe',
timeout: 120000,
});
installSpinner.succeed(t('proPackageInstalled'));
} catch (err) {
installSpinner.fail(t('proPackageInstallFailed'));
result.error = tf('proNpmInstallFailed', { message: err.message });
return result;
}
}
// Clear require cache so loadLicenseApi() picks up newly installed module
Object.keys(require.cache).forEach((key) => {
if (key.includes('license-api') || key.includes('@aios-fullstack')) {
delete require.cache[key];
}
});
}
// Step 1: License Gate
const licenseResult = await stepLicenseGate({
key: options.key || process.env.AIOS_PRO_KEY,
email: options.email || process.env.AIOS_PRO_EMAIL,
password: options.password || process.env.AIOS_PRO_PASSWORD,
});
if (!licenseResult.success) {
showError(licenseResult.error);
if (!isCI) {
showInfo(t('proNeedHelp'));
}
result.error = licenseResult.error;
return result;
}
result.licenseValidated = true;
// Step 2: Install/Scaffold
const scaffoldResult = await stepInstallScaffold(targetDir, {
force: options.force,
});
if (!scaffoldResult.success) {
result.error = scaffoldResult.error;
return result;
}
result.scaffolded = true;
// Step 3: Verify
const verifyResult = await stepVerify(scaffoldResult.scaffoldResult);
result.verified = verifyResult.success;
result.features = verifyResult.features;
result.squads = verifyResult.squads;
result.success = true;
return result;
}
module.exports = {
runProWizard,
stepLicenseGate,
stepInstallScaffold,
stepVerify,
maskLicenseKey,
validateKeyFormat,
isCIEnvironment,
showProHeader,
// Internal helpers exported for testing
_testing: {
validateKeyWithApi,
authenticateWithEmail,
waitForEmailVerification,
activateProByAuth,
loginWithRetry,
createAccountFlow,
stepLicenseGateCI,
stepLicenseGateWithKey,
stepLicenseGateWithKeyInteractive,
stepLicenseGateWithEmail,
loadProModule,
loadLicenseApi,
loadFeatureGate,
loadProScaffolder,
LICENSE_SERVER_URL,
MAX_RETRIES,
LICENSE_KEY_PATTERN,
EMAIL_PATTERN,
MIN_PASSWORD_LENGTH,
VERIFY_POLL_INTERVAL_MS,
VERIFY_POLL_TIMEOUT_MS,
},
};