Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 349 additions & 0 deletions .aiox-core/cli/commands/pro/buyer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
/**
* Pro Buyer Subcommand Module
*
* CLI commands for AIOX Pro buyer validation and management.
* Consumes aiox-license-server via pro/license/license-api.js.
*
* Subcommands (Wave 1 — this file):
* aiox pro buyer validate --email <E> [--json]
* aiox pro buyer validate-batch --file <F> [--concurrency N] [--json]
*
* Subcommands (Wave 2 — depends on POST /api/v1/admin/buyers/register in
* aiox-license-server; not yet implemented):
* aiox pro buyer register --email <E> --name <N> [--cpf <C>] [--yes] [--json]
*
* @module cli/commands/pro/buyer
* @story 123.8 — Cohort Buyer CLI Migration
* @see docs/architecture/design-cohort-buyer-cli-migration.md
*/

'use strict';

const { Command } = require('commander');
const fs = require('fs');
const path = require('path');

// Dynamic license path resolution — duplicates pattern from pro/index.js so
// buyer.js can be loaded independently. Kept intentionally self-contained.
function resolveLicensePath() {
const relativePath = path.resolve(__dirname, '..', '..', '..', '..', 'pro', 'license');
if (fs.existsSync(relativePath)) {
return relativePath;
}

try {
const proPkg = require.resolve('@aiox-fullstack/pro/package.json');
const proDir = path.dirname(proPkg);
const npmPath = path.join(proDir, 'license');
if (fs.existsSync(npmPath)) {
return npmPath;
}
} catch {
// fall through
}

const cwdPath = path.join(process.cwd(), 'node_modules', '@aiox-fullstack', 'pro', 'license');
if (fs.existsSync(cwdPath)) {
return cwdPath;
}

return relativePath;
}

const licensePath = resolveLicensePath();

function loadClient() {
try {
const { licenseApi } = require(path.join(licensePath, 'license-api'));
return licenseApi;
} catch (error) {
console.error('Erro: módulo AIOX Pro license não disponível.');
console.error('Instale: npm install @aiox-fullstack/pro');
console.error(`Detalhe: ${error.message}`);
process.exit(2);
}
}

/**
* Email format check — minimal, server-side validates definitively.
*/
function isValidEmail(email) {
if (typeof email !== 'string' || email.length === 0 || email.length > 254) {
return false;
}
// Simple RFC 5322-lite: no whitespace, one @, dot in domain
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

/**
* Classify error into exit code and human-friendly message.
* Exit codes per story 123.8 ACs:
* 0 success, 1 validation failed (isBuyer: false),
* 2 transport/server, 3 auth (reserved for Wave 2 register).
*/
function classifyError(err) {
const code = err && err.code ? err.code : null;

// Network / transport / server
if (code === 'NETWORK_ERROR' || code === 'SERVER_ERROR') {
return {
exitCode: 2,
message: 'Falha de rede/servidor. Tente novamente em instantes.',
hint: 'Para mais detalhes: AIOX_DEBUG=true aiox pro buyer validate ...',
};
}
if (code === 'AUTH_RATE_LIMITED' || code === 'RATE_LIMITED') {
const retry = err.details && err.details.retryAfter ? ` (retry em ${err.details.retryAfter}s)` : '';
return {
exitCode: 2,
message: `Rate limit atingido${retry}.`,
hint: 'Aguarde antes de tentar novamente.',
};
}

// Default: unknown / bad request
return {
exitCode: 2,
message: err && err.message ? err.message : 'Erro desconhecido.',
hint: null,
};
}

/**
* Emit result to stdout.
* @param {object} payload - Shape { email, isBuyer, hasAccount }
* @param {boolean} asJson - If true, emit JSON only (no decorative text)
*/
function emitValidateResult(payload, asJson) {
if (asJson) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
return;
}
const statusIcon = payload.isBuyer ? '✅' : '❌';
const buyerLabel = payload.isBuyer ? 'Sim' : 'Não';
const accountLabel = payload.hasAccount ? 'Sim' : 'Não';
process.stdout.write(
`\n${statusIcon} ${payload.email}\n` +
` Buyer: ${buyerLabel}\n` +
` Account: ${accountLabel}\n\n`,
);
}

// ---------------------------------------------------------------------------
// aiox pro buyer validate
// ---------------------------------------------------------------------------

async function validateAction(options) {
const email = options && options.email;
const asJson = Boolean(options && options.json);

if (!isValidEmail(email)) {
if (asJson) {
process.stdout.write(`${JSON.stringify({ error: 'INVALID_EMAIL', email })}\n`);
} else {
process.stderr.write('Erro: email inválido.\n');
}
process.exit(2);
}

const client = loadClient();

try {
const result = await client.validateBuyer(email);
emitValidateResult(result, asJson);
process.exit(result.isBuyer ? 0 : 1);
} catch (err) {
const classified = classifyError(err);
if (asJson) {
process.stdout.write(`${JSON.stringify({
error: err && err.code ? err.code : 'UNKNOWN',
message: classified.message,
email,
})}\n`);
} else {
process.stderr.write(`\nFalha: ${classified.message}\n`);
if (classified.hint) {
process.stderr.write(`${classified.hint}\n`);
}
}
process.exit(classified.exitCode);
}
}

// ---------------------------------------------------------------------------
// aiox pro buyer validate-batch
// ---------------------------------------------------------------------------

/**
* Run `worker(item)` over `items` with bounded parallelism.
* Keeps order of results matching input.
*/
async function mapWithConcurrency(items, concurrency, worker) {
const results = new Array(items.length);
let cursor = 0;

async function next() {
while (true) {
const idx = cursor++;
if (idx >= items.length) return;
results[idx] = await worker(items[idx], idx);
}
}

const limit = Math.max(1, Math.min(concurrency, items.length));
const workers = [];
for (let i = 0; i < limit; i += 1) {
workers.push(next());
}
await Promise.all(workers);
return results;
}

function parseEmailsFile(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
return raw
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'));
}

async function validateBatchAction(options) {
const filePath = options && options.file;
const asJson = Boolean(options && options.json);
const concurrencyRaw = options && options.concurrency ? Number(options.concurrency) : 5;
const concurrency = Math.max(1, Math.min(10, Number.isFinite(concurrencyRaw) ? concurrencyRaw : 5));

if (!filePath || !fs.existsSync(filePath)) {
const msg = filePath ? `Arquivo não encontrado: ${filePath}` : 'Erro: --file é obrigatório.';
if (asJson) {
process.stdout.write(`${JSON.stringify({ error: 'FILE_NOT_FOUND', file: filePath })}\n`);
} else {
process.stderr.write(`${msg}\n`);
}
process.exit(2);
}

let emails;
try {
emails = parseEmailsFile(filePath);
} catch (err) {
if (asJson) {
process.stdout.write(`${JSON.stringify({ error: 'FILE_READ_ERROR', message: err.message })}\n`);
} else {
process.stderr.write(`Falha ao ler arquivo: ${err.message}\n`);
}
process.exit(2);
}

if (emails.length === 0) {
if (asJson) {
process.stdout.write('[]\n');
} else {
process.stdout.write('Nenhum email no arquivo.\n');
}
process.exit(0);
}

const client = loadClient();

const results = await mapWithConcurrency(emails, concurrency, async (email) => {
if (!isValidEmail(email)) {
return { email, isBuyer: false, hasAccount: false, error: 'INVALID_EMAIL' };
}
try {
const r = await client.validateBuyer(email);
return { email: r.email, isBuyer: r.isBuyer, hasAccount: r.hasAccount };
} catch (err) {
return {
email,
isBuyer: false,
hasAccount: false,
error: err && err.code ? err.code : 'UNKNOWN',
};
}
});

if (asJson) {
process.stdout.write(`${JSON.stringify(results)}\n`);
} else {
for (const r of results) {
if (r.error) {
process.stdout.write(`⚠️ ${r.email} [${r.error}]\n`);
} else {
const icon = r.isBuyer ? '✅' : '❌';
process.stdout.write(`${icon} ${r.email} buyer=${r.isBuyer} account=${r.hasAccount}\n`);
}
}
const successes = results.filter((r) => !r.error && r.isBuyer).length;
process.stdout.write(`\n${successes}/${results.length} buyers.\n`);
}

const anyFailure = results.some((r) => r.error || !r.isBuyer);
process.exit(anyFailure ? 1 : 0);
Comment on lines +266 to +282
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "buyer.js" -path "*cli/commands/pro*" | head -5

Repository: SynkraAI/aiox-core

Length of output: 101


🏁 Script executed:

# Also search more broadly
find . -name "buyer.js" 2>/dev/null | head -10

Repository: SynkraAI/aiox-core

Length of output: 101


🏁 Script executed:

# Search in common locations
find . -type f -name "*.js" | grep -i "buyer" | head -10

Repository: SynkraAI/aiox-core

Length of output: 173


🏁 Script executed:

wc -l ./.aiox-core/cli/commands/pro/buyer.js

Repository: SynkraAI/aiox-core

Length of output: 105


🏁 Script executed:

cat -n ./.aiox-core/cli/commands/pro/buyer.js | sed -n '260,290p'

Repository: SynkraAI/aiox-core

Length of output: 1301


🏁 Script executed:

cat -n ./.aiox-core/cli/commands/pro/buyer.js | sed -n '145,175p'

Repository: SynkraAI/aiox-core

Length of output: 1146


🏁 Script executed:

cat -n ./.aiox-core/cli/commands/pro/buyer.js | sed -n '320,340p'

Repository: SynkraAI/aiox-core

Length of output: 1041


🏁 Script executed:

cat -n ./.aiox-core/cli/commands/pro/buyer.js | sed -n '289,320p'

Repository: SynkraAI/aiox-core

Length of output: 1438


🏁 Script executed:

# Find and read emitValidateResult function
cat -n ./.aiox-core/cli/commands/pro/buyer.js | head -100 | tail -50

Repository: SynkraAI/aiox-core

Length of output: 2032


🏁 Script executed:

cat -n ./.aiox-core/cli/commands/pro/buyer.js | sed -n '1,100p'

Repository: SynkraAI/aiox-core

Length of output: 3966


Don't force-exit immediately after emitting batch output.

The code writes to stdout (lines 267, 271, 274, 278) and then calls process.exit() immediately at line 282. When stdout is piped, Node can drop buffered output on forced exit, making validate-batch --json unreliable for automation. Use process.exitCode = ... and return instead, allowing the stream to flush before exit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.aiox-core/cli/commands/pro/buyer.js around lines 266 - 282, The code
currently forces termination with process.exit(anyFailure ? 1 : 0) after writing
batch output, which can drop buffered stdout when piped; change this to set
process.exitCode = anyFailure ? 1 : 0 and return from the command handler
instead of calling process.exit(), keeping all existing output logic (the asJson
branch, the per-result writes, the successes count) intact so streams can flush
normally; locate the use of results and the anyFailure calculation and replace
the process.exit call accordingly.

}

// ---------------------------------------------------------------------------
// aiox pro buyer register — Wave 2 stub (hidden until endpoint exists)
// ---------------------------------------------------------------------------

async function registerAction() {
process.stderr.write(
'\nOperação `register` pendente (Wave 2 da Story 123.8).\n' +
'Depende do endpoint POST /api/v1/admin/buyers/register no repo aiox-license-server,\n' +
'que ainda não foi implementado.\n\n' +
'Acompanhe em docs/stories/epic-123/STORY-123.8-cohort-buyer-cli-migration.md\n',
);
process.exit(2);
}

// ---------------------------------------------------------------------------
// Command builder
// ---------------------------------------------------------------------------

/**
* Create the `aiox pro buyer` subcommand group.
* @returns {Command}
*/
function createBuyerCommand() {
const cmd = new Command('buyer')
.description('Validar e gerenciar buyers AIOX Pro (Cohort admin)');

cmd
.command('validate')
.description('Verificar se um email é comprador AIOX Pro')
.requiredOption('-e, --email <email>', 'Email do buyer a validar')
.option('--json', 'Emitir saída JSON estável (sem decoração)')
.action(validateAction);

cmd
.command('validate-batch')
.description('Validar múltiplos emails de um arquivo (bounded concurrency)')
.requiredOption('-f, --file <path>', 'Arquivo com um email por linha')
.option('-c, --concurrency <n>', 'Requisições paralelas (default 5, máx 10)', '5')
.option('--json', 'Emitir saída JSON (array de resultados)')
.action(validateBatchAction);

// Wave 2 stub — kept so the CLI surface is stable once endpoint lands.
cmd
.command('register')
.description('Cadastrar novo buyer (pendente Wave 2 — endpoint cross-repo)')
.option('-e, --email <email>', 'Email do buyer')
.option('-n, --name <name>', 'Nome do buyer')
.option('--cpf <cpf>', 'CPF (opcional)')
.option('-y, --yes', 'Pular confirmação')
.option('--json', 'Emitir saída JSON')
.action(registerAction);
Comment on lines +327 to +335
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

register --json is advertised but not implemented.

The command surface exposes --json, but registerAction() always emits human text to stderr and ignores the flag entirely. Either honor options.json in the stub or remove the option until Wave 2 actually supports a machine-readable response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.aiox-core/cli/commands/pro/buyer.js around lines 327 - 335, The CLI
declares a --json flag for the "register" command but registerAction currently
ignores it and always writes human-readable text to stderr; update
registerAction to check options.json and, when true, emit a machine-readable
JSON response to stdout (e.g., success status, created buyer id/data or error
message) instead of stderr text, and preserve human-friendly stderr output when
options.json is false; alternatively, if you prefer not to implement JSON now,
remove the .option('--json', ...) from the cmd.command('register') declaration
so the flag is not advertised. Ensure you reference and modify registerAction
and the cmd.command('register') option declaration consistently and use
appropriate exit codes.


return cmd;
}

module.exports = {
createBuyerCommand,
// Exports internos para testes:
_internal: {
isValidEmail,
classifyError,
parseEmailsFile,
mapWithConcurrency,
},
};
4 changes: 4 additions & 0 deletions .aiox-core/cli/commands/pro/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const { Command } = require('commander');
const path = require('path');
const fs = require('fs');
const readline = require('readline');
const { createBuyerCommand } = require('./buyer');

// BUG-6 fix (INS-1): Dynamic licensePath resolution
// In framework-dev: __dirname = aiox-core/.aiox-core/cli/commands/pro → ../../../../pro/license
Expand Down Expand Up @@ -695,6 +696,9 @@ function createProCommand() {
.option('--verify', 'Only verify installation without installing')
.action(setupAction);

// aiox pro buyer — Cohort admin operations (Story 123.8)
proCmd.addCommand(createBuyerCommand());

return proCmd;
}

Expand Down
12 changes: 8 additions & 4 deletions .aiox-core/install-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
# - File types for categorization
#
version: 5.0.3
generated_at: "2026-03-11T15:04:09.395Z"
generated_at: "2026-04-28T14:34:49.346Z"
generator: scripts/generate-install-manifest.js
file_count: 1090
file_count: 1091
files:
- path: cli/commands/config/index.js
hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9
Expand Down Expand Up @@ -100,10 +100,14 @@ files:
hash: sha256:fe8fa2479ff81e880a2570cfd3cf06ce548b9f1fbbb673493480ad5a102e47fb
type: cli
size: 12326
- path: cli/commands/pro/buyer.js
hash: sha256:ed972e1b46873fd4d5de12ab7b4e0fe1b7e7df6ec47f49cc1a3f7ec59c1a770a
type: cli
size: 10945
- path: cli/commands/pro/index.js
hash: sha256:3e26f15119719a7be374f3db1935e6bd35fc2c14a66a2a30175979b5731bb29b
hash: sha256:f3382f226d7da791e294a5c6a2deb0cda7dcefb2825519da301fec7dd3cc1290
type: cli
size: 21940
size: 22097
- path: cli/commands/qa/index.js
hash: sha256:3a9e30419a66e56781f9b5dcddc8f4dd0ed24dabf8fe8c3005cd26f5cb02558f
type: cli
Expand Down
Loading
Loading