From c00c82f9c00a7d1fb64184a5c6c907950bfe777e Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Sat, 18 Apr 2026 18:06:05 -0300 Subject: [PATCH 1/2] fix(installer): recover update and preserve brownfield config (cherry picked from commit a17eab949796ce85b653df3f72ed0438335135c6) --- .aiox-core/cli/commands/validate/index.js | 2 + .../scripts/generate-settings-json.js | 33 +++- .aiox-core/install-manifest.yaml | 82 +++++---- ...r-update-recovery-and-brownfield-safety.md | 67 +++++++ .../src/config/configure-environment.js | 168 ++++++++++++------ .../src/installer/aiox-core-installer.js | 151 +++++++++++++--- .../src/installer/brownfield-upgrader.js | 75 +++++++- packages/installer/src/updater/index.js | 161 +++++++++++++++-- .../src/wizard/ide-config-generator.js | 8 +- packages/installer/src/wizard/index.js | 33 +++- .../generate-settings-json.test.js | 10 +- tests/installer/aiox-core-installer.test.js | 51 ++++++ tests/installer/brownfield-upgrader.test.js | 80 +++++++++ .../configure-environment-brownfield.test.js | 60 +++++++ .../installer/generate-settings-json.test.js | 76 ++++++++ tests/updater/aiox-updater.test.js | 125 ++++++++++++- tests/wizard/integration.test.js | 16 ++ 17 files changed, 1050 insertions(+), 148 deletions(-) create mode 100644 docs/stories/epic-123/STORY-123.5-installer-update-recovery-and-brownfield-safety.md create mode 100644 tests/installer/configure-environment-brownfield.test.js create mode 100644 tests/installer/generate-settings-json.test.js diff --git a/.aiox-core/cli/commands/validate/index.js b/.aiox-core/cli/commands/validate/index.js index 0d60eefb29..5e64286fee 100644 --- a/.aiox-core/cli/commands/validate/index.js +++ b/.aiox-core/cli/commands/validate/index.js @@ -62,6 +62,7 @@ function createValidateCommand() { .option('-d, --dry-run', 'Preview repairs without applying (use with --repair)') .option('--detailed', 'Show detailed file list') .option('--no-hash', 'Skip hash verification (faster)') + .option('--no-signature', 'Skip manifest signature verification (insecure; recovery only)') .option('--extras', 'Detect extra files not in manifest') .option('-v, --verbose', 'Enable verbose output') .option('--json', 'Output results as JSON') @@ -213,6 +214,7 @@ async function runValidation(options) { // Create validator instance const validator = new PostInstallValidator(projectRoot, sourceDir, { verifyHashes: options.hash !== false, + requireSignature: options.signature !== false, detectExtras: options.extras === true, verbose: options.verbose === true, onProgress: options.json diff --git a/.aiox-core/infrastructure/scripts/generate-settings-json.js b/.aiox-core/infrastructure/scripts/generate-settings-json.js index 69ba7805c7..489ecf7061 100644 --- a/.aiox-core/infrastructure/scripts/generate-settings-json.js +++ b/.aiox-core/infrastructure/scripts/generate-settings-json.js @@ -243,11 +243,36 @@ function writeSettingsJson(projectRoot, permissions) { } const updated = { ...existing }; - - if (permissions.deny.length > 0 || permissions.allow.length > 0) { - updated.permissions = permissions; + const existingPermissions = + existing && existing.permissions && typeof existing.permissions === 'object' + ? existing.permissions + : {}; + const generatedPermissionsEmpty = + permissions.allow.length === 0 && permissions.deny.length === 0; + + if (generatedPermissionsEmpty) { + if (Object.keys(existingPermissions).length > 0) { + updated.permissions = existingPermissions; + } else { + delete updated.permissions; + } } else { - delete updated.permissions; + const mergedAllow = Array.from( + new Set([...(Array.isArray(existingPermissions.allow) ? existingPermissions.allow : []), ...permissions.allow]), + ); + const mergedDeny = Array.from( + new Set([...(Array.isArray(existingPermissions.deny) ? existingPermissions.deny : []), ...permissions.deny]), + ); + + if (mergedDeny.length > 0 || mergedAllow.length > 0) { + updated.permissions = { + ...existingPermissions, + allow: mergedAllow, + deny: mergedDeny, + }; + } else { + delete updated.permissions; + } } const newContent = JSON.stringify(updated, null, 2) + '\n'; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 05816fa4fd..c4e32cad27 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-11T15:04:09.395Z" +generated_at: "2026-04-18T21:06:06.371Z" generator: scripts/generate-install-manifest.js -file_count: 1090 +file_count: 1091 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -117,9 +117,9 @@ files: type: cli size: 5320 - path: cli/commands/validate/index.js - hash: sha256:b49dcd35424f753f4fedc96c8e1070f716a1f3cb3357baa5b7377d60b5f75cad + hash: sha256:dcf19f1b4a46e562812c5c718783e88f9ac8ef0bf51f27eb1a6d31c7081fafdf type: cli - size: 12893 + size: 13040 - path: cli/commands/workers/formatters/info-formatter.js hash: sha256:11c17e16be0b7d09ba8949497e0887bb20996966d266454aa2bb5dfc9d9d91b8 type: cli @@ -193,21 +193,21 @@ files: type: core size: 5637 - path: core/code-intel/helpers/dev-helper.js - hash: sha256:2418a5f541003c73cc284e88a6b0cb666896a47ffd5ed4c08648269d281efc4c + hash: sha256:7e7f9bb92725ca1d85b0a7151668bc5bcdd6fc9b73fed5b2b2c28217d14535ab type: core - size: 5770 + size: 5751 - path: core/code-intel/helpers/devops-helper.js - hash: sha256:c40cfa9ac2f554a707ff68c7709ae436349041bf00ad2f42811ccbe8ba842462 + hash: sha256:e72f95de2f3737b6e12094526eabfb4974a8339ce6d25f2e323f734fe567c155 type: core - size: 5115 + size: 5043 - path: core/code-intel/helpers/planning-helper.js - hash: sha256:2edcf275122125205a9e737035c8b25efdc4af13e7349ffc10c3ebe8ebe7654d + hash: sha256:9ca5b57b74b5729685369662659e15a91e35ec3a33691973be000ecd85974f3d type: core - size: 6863 + size: 6844 - path: core/code-intel/helpers/qa-helper.js - hash: sha256:ca069dad294224dd5c3369826fb39d5c24287d49d74360049f8bbc55f190eeda + hash: sha256:9dbb84c1c4ed1aa57385ad2a6c74520f2020e6f8883012dd57c51486172ee528 type: core - size: 5184 + size: 5146 - path: core/code-intel/helpers/story-helper.js hash: sha256:778466253ac66103ebc3b1caf71f44b06a0d5fb3d39fe8d3d473dd4bc73fefc6 type: core @@ -277,9 +277,9 @@ files: type: core size: 936 - path: core/config/template-overrides.js - hash: sha256:1708dc8764e7f88dfefd7684240afcd5f13657170ac104aed99145e2bb8ae82c + hash: sha256:202d141a292bc5a8dd0697e044d7627b260839ae8b7119fd40ae486b3a1b0825 type: core - size: 2223 + size: 2224 - path: core/config/templates/user-config.yaml hash: sha256:3505471b0adff9bfcea08f46cca3aeeda46a283bbe7ee711dd566e5974c3257f type: template @@ -337,9 +337,9 @@ files: type: core size: 2265 - path: core/doctor/checks/rules-files.js - hash: sha256:3996e6343a224021fa684d7930dc99b66469c59cb15d416b0c024a770d722ab6 + hash: sha256:ec58342215cede634f50c5b3164155c4f27fd8070af176ec0e02e6deec6fb218 type: core - size: 1426 + size: 1368 - path: core/doctor/checks/settings-json.js hash: sha256:bd26841b966fcfa003eca6f85416d4f877b9dcfea0e4017df9f2a97c14c33fbb type: core @@ -441,13 +441,13 @@ files: type: core size: 11060 - path: core/graph-dashboard/cli.js - hash: sha256:1f2fd6c6b5ace42f3bddc89695fe32d01949321d96057bbf50e2e48892f2c8f5 + hash: sha256:29f273a06fecc77eb3e39162ba1aaf28e1cbadb2a000158f009817021a30b4d1 type: core - size: 10251 + size: 10205 - path: core/graph-dashboard/data-sources/code-intel-source.js - hash: sha256:e508d6cbadcd2358fa7756dcaceefbaa510bd89155e036e2cbd386585408ff8f + hash: sha256:2b0534f57a8f6ca2ff5942e42faf147f1be84773b3af33c9e506ee8f318b558c type: core - size: 6799 + size: 6800 - path: core/graph-dashboard/data-sources/metrics-source.js hash: sha256:b1e4027f82350760b67ea8f58e04a5e739f87f010838487043e29dab7301ae9e type: core @@ -725,7 +725,7 @@ files: type: core size: 3624 - path: core/ids/layer-classifier.js - hash: sha256:4ae1e7d341076a13d08b8b5baf7a687ad2c7df673d50fc3554d522fe79debcdc + hash: sha256:2a240b70ac3507e50a64b96d580c4d933bf2116125fb52c8237db2ed9ebf27b7 type: core size: 2382 - path: core/ids/README.md @@ -1145,9 +1145,9 @@ files: type: core size: 4672 - path: core/synapse/layers/layer-processor.js - hash: sha256:73cb0e5b4bada80d8e256009004679e483792077fac4358c6466cd77136f79fa + hash: sha256:15f9e4c1525d3fa2186170705a26191ad87d94ffd7fa7d61f373b07b6fb3d874 type: core - size: 2881 + size: 2882 - path: core/synapse/memory/memory-bridge.js hash: sha256:820875f97ceea80fc6402c0dab1706cfe58de527897b22dea68db40b0d6ec368 type: core @@ -1217,13 +1217,13 @@ files: type: data size: 34235 - path: data/capability-detection.js - hash: sha256:5176849c01d90e5867f18962e03ff10a10628f40c30fe5c8cb65209f833c0884 + hash: sha256:317d1b51b5cda2e35ac6d468e33e05c0948e6d7f05f51d750d7ce6ff5a58535a type: data - size: 9575 + size: 9590 - path: data/entity-registry.yaml - hash: sha256:cc1bf74d3ef4e90b7a396d5b77259e540b2f9bd4a5b4b1da4977fe49ae83525d + hash: sha256:881b9fa781b5a969d121a3a7c20c898e669d43477a3d714cafeaf856b9a4ffa0 type: data - size: 521869 + size: 523573 - path: data/learned-patterns.yaml hash: sha256:24ac0b160615583a0ff783d3da8af80b7f94191575d6db2054ec8e10a3f945dc type: data @@ -1273,17 +1273,17 @@ files: type: data size: 7026 - path: data/tok3-token-comparison.js - hash: sha256:1f484f8054bec7a7e8055acbc9fddd7863a769948c30c6db42c5c8694410da8f + hash: sha256:da4e3cb3a09684d6fc6d0bb2b285d1837054b9fedc65b58e7fdc8d7a99b1d8a1 type: data - size: 4622 + size: 4486 - path: data/tool-registry.yaml hash: sha256:64e867d0eb36c7f7ac86f4f73f1b2ff89f43f37f28a6de34389be74b9346860c type: data size: 15178 - path: data/tool-search-validation.js - hash: sha256:8757bf087692f002d67115dbe1c8244bbd869600e4f52c49b0d9b07cb9fbb783 + hash: sha256:d83f0680d38996be4615c1f3425d35e0b20c5c93c09da470d2c6a42dcfc3583c type: data - size: 5754 + size: 5755 - path: data/workflow-chains.yaml hash: sha256:1fbf1625e267eedc315cf1e08e5827c250ddc6785fb2cb139e7702def9b66268 type: data @@ -2957,13 +2957,13 @@ files: type: script size: 40724 - path: infrastructure/scripts/codex-skills-sync/index.js - hash: sha256:a7a3c97374c34a900acad13498f61f8a40517574480354218e349d1e1d3931a4 + hash: sha256:e587b49a997e7fab5a12441a63663fb882b9daced606d274cf365dfb04aaf10d type: script - size: 5246 + size: 5474 - path: infrastructure/scripts/codex-skills-sync/validate.js - hash: sha256:0fbc1baff25f20e3a37d3e4be51d146a75254d5ed638b3438d9f1bf0e587c997 + hash: sha256:591ce1dc3d4a7de883d05677eb29fedf0b8b46bfe977390248731de38043e32c type: script - size: 4572 + size: 5111 - path: infrastructure/scripts/collect-tool-usage.js hash: sha256:8a739b79182dc41e28b7e02aeb9ec1dde5ec49f3ca534399acc59711b3b92bbf type: script @@ -3057,9 +3057,9 @@ files: type: script size: 18899 - path: infrastructure/scripts/generate-settings-json.js - hash: sha256:bb4c6f664eb06622fd78eb455c0a74ee29ecee5fe47b4a7fcb2de8a89119ff5a + hash: sha256:dc5b8803825ed749080925d7c17ece39b33a7faf3b02d08a445e8cc7408048a1 type: script - size: 8292 + size: 9159 - path: infrastructure/scripts/git-config-detector.js hash: sha256:52ed96d98fc6f9e83671d7d27f78dcff4f2475f3b8e339dc31922f6b2814ad78 type: script @@ -3085,9 +3085,9 @@ files: type: script size: 5534 - path: infrastructure/scripts/ide-sync/index.js - hash: sha256:2f48896307b1fc3839f13169cab554c0a9a34f9d0e3961f1ccc07a2bbfaebdb2 + hash: sha256:34377b67d0099aa68611ecff15b5fbe7d0073b86acf16edea0fa4a87ff51d6e4 type: script - size: 14906 + size: 14929 - path: infrastructure/scripts/ide-sync/README.md hash: sha256:c18c2563b2ca64580a4814edd3c20a79c96f33fa8b953ee02206ef0faad53d35 type: script @@ -3248,6 +3248,10 @@ files: hash: sha256:118d4cdbc64cf3238065f2fb98958305ae81e1384bc68f5a6c7b768f1232cd1e type: script size: 34686 + - path: infrastructure/scripts/repair-agent-references.js + hash: sha256:63b5f580b783d5098c3ab3d8da11af9871306b3a1fc0f986f25c813eb4c4dd75 + type: script + size: 7200 - path: infrastructure/scripts/repository-detector.js hash: sha256:10ffca7f57d24d3729c71a9104a154500a3c72328d67884e26e38d22199af332 type: script diff --git a/docs/stories/epic-123/STORY-123.5-installer-update-recovery-and-brownfield-safety.md b/docs/stories/epic-123/STORY-123.5-installer-update-recovery-and-brownfield-safety.md new file mode 100644 index 0000000000..a70a9a6af0 --- /dev/null +++ b/docs/stories/epic-123/STORY-123.5-installer-update-recovery-and-brownfield-safety.md @@ -0,0 +1,67 @@ +# Story 123.5: Recovery do update e segurança brownfield no installer + +## Status + +- [x] Rascunho +- [x] Em revisão +- [x] Concluída + +## Contexto + +O fluxo público de atualização do AIOX está quebrado em dois caminhos críticos: + +- `aiox update` instala o pacote novo via npm, mas não sincroniza o `.aiox-core` do projecto antes da validação, causando rollback com manifest antigo. +- `aiox install --force` continua perigoso em brownfield: `--dry-run` é ignorado e ficheiros mutáveis de projecto, como `core-config.yaml`, `MEMORY.md` e regras custom de `.claude/settings.json`, podem ser sobrescritos. + +Isto bloqueia upgrades públicos e viola as garantias de segurança/configuração do framework em projectos existentes. + +## Objetivo + +Restabelecer um caminho seguro e reproduzível para instalar e atualizar o framework em projectos brownfield, sem rollback falso-positivo nem overwrite destrutivo de configuração local. + +## Acceptance Criteria + +- [x] AC1. `aiox update` sincroniza o `.aiox-core` do pacote recém-instalado para o projecto antes da validação e deixa de falhar por carregar manifest antigo. +- [x] AC2. O updater usa metadata suficiente para distinguir ficheiros de framework alterados pelo release de ficheiros customizados pelo utilizador, preservando customizações locais durante o sync. +- [x] AC3. `aiox install --dry-run` não escreve ficheiros nem executa bootstrap/sync, mesmo com `--quiet` e `--force`. +- [x] AC4. O install brownfield deixa de sobrescrever cegamente `.claude/settings.json`, `.aiox-core/core-config.yaml`, `.env.example` e `development/agents/*/MEMORY.md`. +- [x] AC5. `aiox validate` expõe um modo explícito para saltar verificação de assinatura em cenários de recovery/documented break-glass. +- [x] AC6. Há testes automatizados cobrindo os regressions de update, dry-run e preservação brownfield. + +## Tasks + +- [x] Corrigir o sync pós-`npm install` no updater e reaproveitar manifest/source package na validação +- [x] Endurecer persistência de manifest/version metadata para brownfield upgrades +- [x] Implementar guard global de `dryRun` no wizard modular +- [x] Preservar e/ou fazer merge dos ficheiros mutáveis de brownfield reportados no issue +- [x] Adicionar testes de regressão para updater, wizard/install e settings merge + +## Execution + +- Validar localmente com `npm run lint`, `npm run typecheck`, `npm test` +- Smoke test manual em projeto brownfield publicado `5.0.4 -> 5.0.7`, incluindo preservação de `MEMORY.md` customizado +- Regressions adicionais corrigidos durante smoke test final: + - preservação de `permissions.allow/deny` em `.claude/settings.json` mesmo quando o gerador não produz regras novas + - seleção do manifest instalado mais completo a partir do package anterior para evitar overwrite falso de ficheiros mutáveis +- `npm run lint` ✓ +- `npm run typecheck` ✓ +- `npm test` ✓ + +## File List + +- [docs/stories/epic-123/STORY-123.5-installer-update-recovery-and-brownfield-safety.md](./STORY-123.5-installer-update-recovery-and-brownfield-safety.md) +- [.aiox-core/cli/commands/validate/index.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/.aiox-core/cli/commands/validate/index.js) +- [.aiox-core/infrastructure/scripts/generate-settings-json.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/.aiox-core/infrastructure/scripts/generate-settings-json.js) +- [packages/installer/src/config/configure-environment.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/packages/installer/src/config/configure-environment.js) +- [packages/installer/src/installer/aiox-core-installer.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/packages/installer/src/installer/aiox-core-installer.js) +- [packages/installer/src/installer/brownfield-upgrader.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/packages/installer/src/installer/brownfield-upgrader.js) +- [packages/installer/src/updater/index.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/packages/installer/src/updater/index.js) +- [packages/installer/src/wizard/ide-config-generator.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/packages/installer/src/wizard/ide-config-generator.js) +- [packages/installer/src/wizard/index.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/packages/installer/src/wizard/index.js) +- [tests/installer/aiox-core-installer.test.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/tests/installer/aiox-core-installer.test.js) +- [tests/installer/brownfield-upgrader.test.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/tests/installer/brownfield-upgrader.test.js) +- [tests/installer/configure-environment-brownfield.test.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/tests/installer/configure-environment-brownfield.test.js) +- [tests/installer/generate-settings-json.test.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/tests/installer/generate-settings-json.test.js) +- [packages/installer/tests/unit/generate-settings-json/generate-settings-json.test.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/packages/installer/tests/unit/generate-settings-json/generate-settings-json.test.js) +- [tests/updater/aiox-updater.test.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/tests/updater/aiox-updater.test.js) +- [tests/wizard/integration.test.js](/Users/rafaelcosta/Projects/AIOX/aiox-core/tests/wizard/integration.test.js) diff --git a/packages/installer/src/config/configure-environment.js b/packages/installer/src/config/configure-environment.js index a1b645a257..b9a539d565 100644 --- a/packages/installer/src/config/configure-environment.js +++ b/packages/installer/src/config/configure-environment.js @@ -24,6 +24,68 @@ const { } = require('./validation/config-validator'); const { getMergeStrategy, hasMergeStrategy } = require('../merger/index.js'); +function isBrownfieldProjectType(projectType) { + const normalized = String(projectType || '').toLowerCase(); + return normalized === 'brownfield' || normalized === 'existing_aiox' || normalized === 'existing-aiox'; +} + +async function resolveFileAction(filePath, options = {}) { + const { + skipPrompts = false, + forceMerge = false, + noMerge = false, + projectType = 'greenfield', + message, + } = options; + + const exists = await fs.pathExists(filePath); + if (!exists) { + return 'create'; + } + + const isBrownfield = isBrownfieldProjectType(projectType); + const canMerge = !noMerge && hasMergeStrategy(filePath); + + if (forceMerge && canMerge) { + return 'merge'; + } + + if (skipPrompts) { + return isBrownfield && canMerge ? 'merge' : 'overwrite'; + } + + const choices = []; + + if (canMerge) { + choices.push({ + value: 'merge', + label: 'Merge (preserve existing customizations)', + hint: isBrownfield ? 'recommended' : '', + }); + } + + choices.push( + { value: 'backup', label: 'Backup and overwrite' }, + { value: 'overwrite', label: 'Overwrite completely' }, + { value: 'skip', label: 'Skip (keep existing)' }, + ); + + let action = await select({ + message, + options: choices, + initialValue: isBrownfield && canMerge ? 'merge' : 'backup', + }); + + if (action === 'backup') { + const backupPath = `${filePath}.backup.${Date.now()}`; + await fs.copy(filePath, backupPath); + console.log(`✅ Backup created: ${backupPath}`); + action = 'overwrite'; + } + + return action; +} + /** * Configure environment files (.env and core-config.yaml) * @@ -62,50 +124,16 @@ async function configureEnvironment(options = {}) { // Step 1: Check for existing .env and handle with merge/backup/overwrite const envPath = path.join(targetDir, '.env'); const envExists = await fs.pathExists(envPath); - let envAction = 'create'; // 'create', 'merge', 'overwrite', 'skip' - const isBrownfield = projectType === 'BROWNFIELD' || projectType === 'EXISTING_AIOX'; - const canMerge = !noMerge && hasMergeStrategy(envPath); - - if (envExists) { - // Story 9.4: Handle CLI flags for merge behavior - if (forceMerge && canMerge) { - // --merge flag: Force merge without prompting - envAction = 'merge'; - console.log('🔀 Using merge mode (--merge flag)'); - } else if (skipPrompts) { - // Quiet mode: default to merge for brownfield, overwrite for greenfield - envAction = isBrownfield && canMerge ? 'merge' : 'overwrite'; - } else { - // Interactive mode: Offer merge option for brownfield projects - const choices = []; - - if (canMerge) { - choices.push({ - value: 'merge', - label: 'Merge (add new variables, keep existing)', - hint: isBrownfield ? 'recommended' : '', - }); - } - - choices.push( - { value: 'backup', label: 'Backup and overwrite' }, - { value: 'overwrite', label: 'Overwrite completely' }, - { value: 'skip', label: 'Skip (keep existing)' }, - ); - - envAction = await select({ - message: 'Found existing .env file. What would you like to do?', - options: choices, - initialValue: isBrownfield && canMerge ? 'merge' : 'backup', - }); - - if (envAction === 'backup') { - const backupPath = path.join(targetDir, `.env.backup.${Date.now()}`); - await fs.copy(envPath, backupPath); - console.log(`✅ Backup created: ${backupPath}`); - envAction = 'overwrite'; - } - } + const envAction = await resolveFileAction(envPath, { + skipPrompts, + forceMerge, + noMerge, + projectType, + message: 'Found existing .env file. What would you like to do?', + }); + + if (envAction === 'merge' && envExists && forceMerge) { + console.log('🔀 Using merge mode (--merge flag)'); } // Step 2: API keys are configured later via .env or aiox-master @@ -158,9 +186,29 @@ async function configureEnvironment(options = {}) { // Step 4: Generate and write .env.example const envExamplePath = path.join(targetDir, '.env.example'); const envExampleContent = generateEnvExample(); - await fs.writeFile(envExamplePath, envExampleContent, { encoding: 'utf8' }); - results.envExampleCreated = true; - console.log('✅ Created .env.example file'); + const envExampleAction = await resolveFileAction(envExamplePath, { + skipPrompts, + forceMerge, + noMerge, + projectType, + message: 'Found existing .env.example file. What would you like to do?', + }); + + if (envExampleAction === 'skip') { + console.log('⏭️ Skipped .env.example file (keeping existing)'); + } else if (envExampleAction === 'merge' && await fs.pathExists(envExamplePath)) { + const existingContent = await fs.readFile(envExamplePath, 'utf8'); + const merger = getMergeStrategy(envExamplePath); + const mergeResult = await merger.merge(existingContent, envExampleContent); + + await fs.writeFile(envExamplePath, mergeResult.content, { encoding: 'utf8' }); + results.envExampleCreated = true; + console.log('✅ Merged .env.example file'); + } else { + await fs.writeFile(envExamplePath, envExampleContent, { encoding: 'utf8' }); + results.envExampleCreated = true; + console.log('✅ Created .env.example file'); + } // Step 5: Update .gitignore await updateGitignore(targetDir); @@ -194,9 +242,29 @@ async function configureEnvironment(options = {}) { } const coreConfigPath = path.join(coreConfigDir, 'core-config.yaml'); - await fs.writeFile(coreConfigPath, coreConfigContent, { encoding: 'utf8' }); - results.coreConfigCreated = true; - console.log('✅ Created .aiox-core/core-config.yaml'); + const coreConfigAction = await resolveFileAction(coreConfigPath, { + skipPrompts, + forceMerge, + noMerge, + projectType, + message: 'Found existing .aiox-core/core-config.yaml. What would you like to do?', + }); + + if (coreConfigAction === 'skip') { + console.log('⏭️ Skipped .aiox-core/core-config.yaml (keeping existing)'); + } else if (coreConfigAction === 'merge' && await fs.pathExists(coreConfigPath)) { + const existingContent = await fs.readFile(coreConfigPath, 'utf8'); + const merger = getMergeStrategy(coreConfigPath); + const mergeResult = await merger.merge(coreConfigContent, existingContent); + + await fs.writeFile(coreConfigPath, mergeResult.content, { encoding: 'utf8' }); + results.coreConfigCreated = true; + console.log('✅ Merged .aiox-core/core-config.yaml'); + } else { + await fs.writeFile(coreConfigPath, coreConfigContent, { encoding: 'utf8' }); + results.coreConfigCreated = true; + console.log('✅ Created .aiox-core/core-config.yaml'); + } return results; } catch (error) { diff --git a/packages/installer/src/installer/aiox-core-installer.js b/packages/installer/src/installer/aiox-core-installer.js index 38a9f4e525..80e9119036 100644 --- a/packages/installer/src/installer/aiox-core-installer.js +++ b/packages/installer/src/installer/aiox-core-installer.js @@ -12,6 +12,7 @@ const fs = require('fs-extra'); const path = require('path'); const ora = require('ora'); const { hashFile } = require('./file-hasher'); +const { loadSourceManifest, updateInstalledManifest } = require('./brownfield-upgrader'); /** * Get the path to the source .aiox-core directory in the package @@ -70,6 +71,59 @@ const ROOT_FILES_TO_COPY = [ 'working-in-the-brownfield.md', ]; +const BROWNFIELD_PRESERVE_PATTERNS = [ + /^core-config\.yaml$/, + /^development\/agents\/[^/]+\/MEMORY\.md$/, +]; + +function isBrownfieldProjectType(projectType = '') { + const normalized = String(projectType).toLowerCase(); + return normalized === 'brownfield' || normalized === 'existing_aiox' || normalized === 'existing-aiox'; +} + +function shouldPreserveExistingFile(relativePath, options = {}) { + if (!options.preserveExisting) { + return false; + } + + return BROWNFIELD_PRESERVE_PATTERNS.some((pattern) => pattern.test(relativePath)); +} + +function extractManifestFileHashes(manifest) { + if (!manifest || !Array.isArray(manifest.files)) { + return {}; + } + + const fileHashes = {}; + for (const entry of manifest.files) { + if (entry && entry.path && entry.hash) { + fileHashes[entry.path] = entry.hash; + } + } + + return fileHashes; +} + +async function copyManifestArtifacts(sourceDir, targetAioxCore) { + const manifestPath = path.join(sourceDir, 'install-manifest.yaml'); + const signaturePath = manifestPath + '.minisig'; + const targetSignaturePath = path.join(targetAioxCore, 'install-manifest.yaml.minisig'); + + if (await fs.pathExists(manifestPath)) { + await fs.copy(manifestPath, path.join(targetAioxCore, 'install-manifest.yaml'), { + overwrite: true, + }); + } + + if (await fs.pathExists(signaturePath)) { + await fs.copy(signaturePath, targetSignaturePath, { + overwrite: true, + }); + } else if (await fs.pathExists(targetSignaturePath)) { + await fs.remove(targetSignaturePath); + } +} + /** * Replace {root} placeholder in file content * @param {string} content - File content @@ -128,9 +182,10 @@ async function generateVersionJson(options) { version, installedFiles, mode = 'project-development', + fileHashes: providedFileHashes = null, } = options; - const fileHashes = await generateFileHashes(targetAioxCore, installedFiles); + const fileHashes = providedFileHashes || await generateFileHashes(targetAioxCore, installedFiles); const versionJson = { version, @@ -153,8 +208,14 @@ async function generateVersionJson(options) { * @param {boolean} replaceRoot - Whether to replace {root} placeholders * @returns {Promise} Success status */ -async function copyFileWithRootReplacement(sourcePath, destPath, replaceRoot = true) { +async function copyFileWithRootReplacement(sourcePath, destPath, replaceRoot = true, options = {}) { try { + if (options.relativePath && shouldPreserveExistingFile(options.relativePath, options)) { + if (await fs.pathExists(destPath)) { + return { copied: false, preserved: true, relativePath: options.relativePath }; + } + } + await fs.ensureDir(path.dirname(destPath)); // Check if file needs {root} replacement (.md, .yaml, .yml) @@ -169,10 +230,10 @@ async function copyFileWithRootReplacement(sourcePath, destPath, replaceRoot = t await fs.copy(sourcePath, destPath); } - return true; + return { copied: true, preserved: false, relativePath: options.relativePath || null }; } catch (error) { console.error(`Failed to copy ${sourcePath}: ${error.message}`); - return false; + return { copied: false, preserved: false, relativePath: options.relativePath || null }; } } @@ -183,7 +244,7 @@ async function copyFileWithRootReplacement(sourcePath, destPath, replaceRoot = t * @param {Function} onProgress - Progress callback * @returns {Promise} List of copied files (relative paths) */ -async function copyDirectoryWithRootReplacement(sourceDir, destDir, onProgress = null) { +async function copyDirectoryWithRootReplacement(sourceDir, destDir, onProgress = null, options = {}) { const copiedFiles = []; if (!await fs.pathExists(sourceDir)) { @@ -205,15 +266,28 @@ async function copyDirectoryWithRootReplacement(sourceDir, destDir, onProgress = } if (item.isDirectory()) { - const subFiles = await copyDirectoryWithRootReplacement(sourcePath, destPath, onProgress); + const subFiles = await copyDirectoryWithRootReplacement(sourcePath, destPath, onProgress, { + ...options, + baseDir: options.baseDir || destDir, + }); copiedFiles.push(...subFiles); } else { - const success = await copyFileWithRootReplacement(sourcePath, destPath); - if (success) { - copiedFiles.push(path.relative(destDir, destPath)); + const baseDir = options.baseDir || destDir; + const relativePath = path.relative(baseDir, destPath).replace(/\\/g, '/'); + const fullRelativePath = options.pathPrefix + ? path.posix.join(options.pathPrefix, relativePath) + : relativePath; + const result = await copyFileWithRootReplacement(sourcePath, destPath, true, { + ...options, + relativePath: fullRelativePath, + }); + if (result.copied) { + copiedFiles.push(relativePath); if (onProgress) { onProgress({ file: item.name, copied: true }); } + } else if (result.preserved && onProgress) { + onProgress({ file: item.name, copied: false, preserved: true }); } } } @@ -237,6 +311,9 @@ async function installAioxCore(options = {}) { const { targetDir = process.cwd(), onProgress = null, + projectType = 'greenfield', + sourceDir: providedSourceDir = null, + packageVersion: providedPackageVersion = null, } = options; const result = { @@ -249,8 +326,9 @@ async function installAioxCore(options = {}) { const spinner = ora('Installing AIOX core framework...').start(); try { - const sourceDir = getAioxCoreSourcePath(); + const sourceDir = providedSourceDir || getAioxCoreSourcePath(); const targetAioxCore = path.join(targetDir, '.aiox-core'); + const preserveExisting = isBrownfieldProjectType(projectType); // Check if source exists if (!await fs.pathExists(sourceDir)) { @@ -272,6 +350,11 @@ async function installAioxCore(options = {}) { folderSource, folderDest, onProgress, + { + baseDir: folderDest, + pathPrefix: folder, + preserveExisting, + }, ); if (copiedFiles.length > 0) { @@ -288,28 +371,37 @@ async function installAioxCore(options = {}) { if (await fs.pathExists(fileSource)) { spinner.text = `Copying ${file}...`; - const success = await copyFileWithRootReplacement(fileSource, fileDest); - if (success) { + const relativePath = file; + const copyResult = await copyFileWithRootReplacement(fileSource, fileDest, true, { + relativePath, + preserveExisting, + }); + if (copyResult.copied) { result.installedFiles.push(file); } } } - // Create install manifest - spinner.text = 'Creating installation manifest...'; - const packageVersion = require('../../../../package.json').version; - const manifest = { - version: packageVersion, - installed_at: new Date().toISOString(), - install_type: 'full', - files: result.installedFiles, - }; - - await fs.writeFile( - path.join(targetAioxCore, 'install-manifest.yaml'), - require('js-yaml').dump(manifest), - 'utf8', - ); + const sourceManifest = loadSourceManifest(sourceDir); + const manifestFileHashes = extractManifestFileHashes(sourceManifest); + const packageVersion = providedPackageVersion || require('../../../../package.json').version; + + spinner.text = 'Copying installation manifest...'; + await copyManifestArtifacts(sourceDir, targetAioxCore); + if (!sourceManifest) { + const manifest = { + version: packageVersion, + installed_at: new Date().toISOString(), + install_type: 'full', + files: result.installedFiles, + }; + + await fs.writeFile( + path.join(targetAioxCore, 'install-manifest.yaml'), + require('js-yaml').dump(manifest), + 'utf8', + ); + } // Story 7.2: Create version.json with file hashes for update tracking spinner.text = 'Generating version tracking info...'; @@ -318,9 +410,14 @@ async function installAioxCore(options = {}) { version: packageVersion, installedFiles: result.installedFiles, mode: 'project-development', + fileHashes: Object.keys(manifestFileHashes).length > 0 ? manifestFileHashes : null, }); result.versionInfo = versionInfo; + if (sourceManifest) { + updateInstalledManifest(targetDir, sourceManifest, `aiox-core@${packageVersion}`); + } + // BUG-2 fix (INS-1): Install .aiox-core dependencies after copy // The copied .aiox-core/package.json has dependencies (js-yaml, execa, etc.) // that must be installed for the activation pipeline to work diff --git a/packages/installer/src/installer/brownfield-upgrader.js b/packages/installer/src/installer/brownfield-upgrader.js index 0a669683d1..ec03f2da1f 100644 --- a/packages/installer/src/installer/brownfield-upgrader.js +++ b/packages/installer/src/installer/brownfield-upgrader.js @@ -53,10 +53,38 @@ function loadManifest(basePath, manifestName = 'install-manifest.yaml') { * @returns {Object|null} - Installed manifest or null if not found */ function loadInstalledManifest(targetDir) { - return loadManifest( + const installedManifest = loadManifest( path.join(targetDir, '.aiox-core'), '.installed-manifest.yaml', ); + + if (installedManifest) { + return installedManifest; + } + + const versionJsonPath = path.join(targetDir, '.aiox-core', 'version.json'); + if (!fs.existsSync(versionJsonPath)) { + return null; + } + + try { + const versionInfo = fs.readJsonSync(versionJsonPath); + if (!versionInfo || !versionInfo.version || !versionInfo.fileHashes) { + return null; + } + + return { + installed_version: versionInfo.version, + files: Object.entries(versionInfo.fileHashes).map(([filePath, hash]) => ({ + path: filePath, + hash, + modified_by_user: false, + })), + }; + } catch (error) { + console.warn(`Error loading version.json from ${versionJsonPath}:`, error.message); + return null; + } } /** @@ -150,13 +178,42 @@ function generateUpgradeReport(sourceManifest, installedManifest, targetDir) { const absolutePath = path.join(aioxCoreDir, filePath); if (!installedEntry) { - // New file in source - report.newFiles.push({ - path: filePath, - type: sourceEntry.type, - hash: sourceEntry.hash, - size: sourceEntry.size, - }); + // File is not tracked by the installed manifest. + // In older releases some files existed on disk without being recorded + // in version metadata. Preserve those local files instead of + // overwriting them as "new" framework files. + if (fs.existsSync(absolutePath)) { + try { + const currentHash = `sha256:${hashFile(absolutePath)}`; + if (hashesMatch(currentHash, sourceEntry.hash)) { + report.unchangedFiles++; + } else { + report.userModifiedFiles.push({ + path: filePath, + type: sourceEntry.type, + sourceHash: sourceEntry.hash, + installedHash: null, + reason: 'Local file exists but is not tracked by installed manifest', + }); + } + } catch { + report.userModifiedFiles.push({ + path: filePath, + type: sourceEntry.type, + sourceHash: sourceEntry.hash, + installedHash: null, + reason: 'Local file exists but could not be hashed', + }); + } + } else { + // New file in source + report.newFiles.push({ + path: filePath, + type: sourceEntry.type, + hash: sourceEntry.hash, + size: sourceEntry.size, + }); + } } else if (!hashesMatch(sourceEntry.hash, installedEntry.hash)) { // File changed in source // Check if user modified the local copy @@ -287,7 +344,7 @@ async function applyUpgrade(report, sourceDir, targetDir, options = {}) { result.mergeWarnings = result.mergeWarnings || []; for (const conflict of conflicts) { result.mergeWarnings.push( - `core-config.yaml: ${conflict.identifier} — ${conflict.reason}` + `core-config.yaml: ${conflict.identifier} — ${conflict.reason}`, ); } } diff --git a/packages/installer/src/updater/index.js b/packages/installer/src/updater/index.js index 633bea69e6..be8e74b20d 100644 --- a/packages/installer/src/updater/index.js +++ b/packages/installer/src/updater/index.js @@ -22,6 +22,91 @@ const https = require('https'); const { execSync } = require('child_process'); const { hashFile, hashesMatch } = require('../installer/file-hasher'); const { PostInstallValidator, formatReport: formatValidationReport } = require('../installer/post-install-validator'); +const { + loadSourceManifest, + loadInstalledManifest, + generateUpgradeReport, + applyUpgrade, + updateInstalledManifest, +} = require('../installer/brownfield-upgrader'); + +function manifestToInstalledManifest(manifest) { + if (!manifest || !Array.isArray(manifest.files)) { + return null; + } + + return { + installed_version: manifest.version || 'unknown', + files: manifest.files + .filter((entry) => entry && entry.path && entry.hash) + .map((entry) => ({ + path: entry.path, + hash: entry.hash, + type: entry.type, + modified_by_user: false, + })), + }; +} + +function extractManifestFileHashes(manifest) { + if (!manifest || !Array.isArray(manifest.files)) { + return {}; + } + + const fileHashes = {}; + for (const entry of manifest.files) { + if (entry && entry.path && entry.hash) { + fileHashes[entry.path] = entry.hash; + } + } + + return fileHashes; +} + +function selectInstalledManifest(projectManifest, packageManifest) { + if (!projectManifest) { + return packageManifest; + } + + if (!packageManifest) { + return projectManifest; + } + + const projectFiles = Array.isArray(projectManifest.files) ? projectManifest.files : []; + const packageFiles = Array.isArray(packageManifest.files) ? packageManifest.files : []; + + if (packageFiles.length === 0) { + return projectManifest; + } + + if (projectFiles.length === 0) { + return packageManifest; + } + + const mergedFiles = new Map(); + + for (const entry of packageFiles) { + if (entry && entry.path) { + mergedFiles.set(entry.path, entry); + } + } + + for (const entry of projectFiles) { + if (entry && entry.path && !mergedFiles.has(entry.path)) { + mergedFiles.set(entry.path, entry); + } + } + + return { + ...projectManifest, + installed_version: + packageManifest.installed_version || + packageManifest.version || + projectManifest.installed_version || + projectManifest.version, + files: Array.from(mergedFiles.values()), + }; +} /** * Update status types @@ -78,6 +163,8 @@ class AIOXUpdater { this.versionInfo = null; this.changelog = null; this.backupDir = null; + this.lastSourcePackageRoot = null; + this.lastSourceManifest = null; } /** @@ -467,15 +554,21 @@ class AIOXUpdater { } result.filesUpdated = updateApplied.filesUpdated; - result.filesPreserved = customizations.customized.length; + result.filesPreserved = updateApplied.filesSkipped?.length || customizations.customized.length; + this.lastSourcePackageRoot = updateApplied.sourcePackageRoot || this.lastSourcePackageRoot; + this.lastSourceManifest = updateApplied.sourceManifest || this.lastSourceManifest; // Update version.json onProgress('finalizing', 'Updating version info...'); - await this.updateVersionInfo(checkResult.latest); + await this.updateVersionInfo(checkResult.latest, { + fileHashes: extractManifestFileHashes(this.lastSourceManifest), + }); // Validate installation after update onProgress('validating', 'Validating installation...'); - const validationResult = await this.validateAfterUpdate(); + const validationResult = await this.validateAfterUpdate({ + sourceDir: this.lastSourcePackageRoot, + }); result.validationPassed = validationResult.success; result.integrityScore = validationResult.integrityScore; @@ -525,6 +618,7 @@ class AIOXUpdater { const filesToBackup = [ 'version.json', 'install-manifest.yaml', + 'install-manifest.yaml.minisig', ]; for (const file of filesToBackup) { @@ -586,6 +680,9 @@ class AIOXUpdater { }; try { + const previousPackageRoot = path.join(this.projectRoot, 'node_modules', 'aiox-core'); + const previousSourceManifest = loadSourceManifest(path.join(previousPackageRoot, '.aiox-core')); + // Use npm to update the package const cmd = `npm install aiox-core@${targetVersion} --save-exact`; this.log(`Running: ${cmd}`); @@ -596,11 +693,54 @@ class AIOXUpdater { timeout: 120000, // 2 minutes }); - result.success = true; - result.filesUpdated = 1; // At least package updated + const sourcePackageRoot = path.join(this.projectRoot, 'node_modules', 'aiox-core'); + const sourceAioxCore = path.join(sourcePackageRoot, '.aiox-core'); + const sourceManifest = loadSourceManifest(sourceAioxCore); + + if (!sourceManifest) { + result.error = 'Updated package does not contain install-manifest.yaml'; + return result; + } - // TODO: Copy new files from node_modules to .aiox-core - // preserving customizedFiles + const installedManifest = selectInstalledManifest( + loadInstalledManifest(this.projectRoot), + manifestToInstalledManifest(previousSourceManifest), + ); + const report = generateUpgradeReport(sourceManifest, installedManifest, this.projectRoot); + const applyResult = await applyUpgrade(report, sourceAioxCore, this.projectRoot, { + includeModified: true, + }); + + if (!applyResult.success) { + result.error = applyResult.errors.map((entry) => `${entry.path}: ${entry.error}`).join('; '); + return result; + } + + await fs.copy( + path.join(sourceAioxCore, 'install-manifest.yaml'), + path.join(this.aioxCoreDir, 'install-manifest.yaml'), + { overwrite: true }, + ); + + const sourceSignaturePath = path.join(sourceAioxCore, 'install-manifest.yaml.minisig'); + const targetSignaturePath = path.join(this.aioxCoreDir, 'install-manifest.yaml.minisig'); + if (await fs.pathExists(sourceSignaturePath)) { + await fs.copy( + sourceSignaturePath, + targetSignaturePath, + { overwrite: true }, + ); + } else if (await fs.pathExists(targetSignaturePath)) { + await fs.remove(targetSignaturePath); + } + + updateInstalledManifest(this.projectRoot, sourceManifest, `aiox-core@${targetVersion}`); + + result.success = true; + result.filesUpdated = applyResult.filesInstalled.length; + result.filesSkipped = applyResult.filesSkipped; + result.sourceManifest = sourceManifest; + result.sourcePackageRoot = sourcePackageRoot; return result; } catch (error) { @@ -615,7 +755,7 @@ class AIOXUpdater { * @param {string} newVersion - New version * @returns {Promise} */ - async updateVersionInfo(newVersion) { + async updateVersionInfo(newVersion, options = {}) { const versionJsonPath = path.join(this.aioxCoreDir, 'version.json'); const versionInfo = { @@ -623,7 +763,7 @@ class AIOXUpdater { installedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), mode: this.versionInfo?.mode || 'project-development', - fileHashes: {}, // Will be populated by file copy + fileHashes: options.fileHashes || {}, }; await fs.writeJson(versionJsonPath, versionInfo, { spaces: 2 }); @@ -646,7 +786,7 @@ class AIOXUpdater { }; try { - const validator = new PostInstallValidator(this.projectRoot, null, { + const validator = new PostInstallValidator(this.projectRoot, options.sourceDir || null, { verifyHashes: true, detectExtras: false, verbose: options.verbose || this.options.verbose, @@ -809,4 +949,5 @@ module.exports = { FileAction, formatCheckResult, formatUpdateResult, + selectInstalledManifest, }; diff --git a/packages/installer/src/wizard/ide-config-generator.js b/packages/installer/src/wizard/ide-config-generator.js index 701a3a10b4..58e2ce5871 100644 --- a/packages/installer/src/wizard/ide-config-generator.js +++ b/packages/installer/src/wizard/ide-config-generator.js @@ -83,7 +83,11 @@ async function backupFile(filePath) { async function promptFileExists(filePath, options = {}) { const { projectType, forceMerge, noMerge } = options; const canMerge = !noMerge && hasMergeStrategy(filePath); - const isBrownfield = projectType === 'BROWNFIELD' || projectType === 'EXISTING_AIOX'; + const normalizedProjectType = String(projectType || '').toLowerCase(); + const isBrownfield = + normalizedProjectType === 'brownfield' || + normalizedProjectType === 'existing_aiox' || + normalizedProjectType === 'existing-aiox'; // If force merge is set and merge is available, return merge directly if (forceMerge && canMerge) { @@ -264,7 +268,7 @@ async function copyAgentFiles(projectRoot, agentFolder, ideConfig = null) { const targetPath = path.join(targetDir, filename); await fs.writeFile(targetPath, content, 'utf8'); copiedFiles.push(targetPath); - } catch (transformError) { + } catch { // Fallback: copy raw file with .agent.md extension const targetPath = path.join(targetDir, `${agentName}.agent.md`); await fs.copy(sourcePath, targetPath); diff --git a/packages/installer/src/wizard/index.js b/packages/installer/src/wizard/index.js index fde425068e..e2782c23cb 100644 --- a/packages/installer/src/wizard/index.js +++ b/packages/installer/src/wizard/index.js @@ -329,12 +329,43 @@ async function runWizard(options = {}) { } } + if (options.dryRun) { + const preview = { + dryRun: true, + projectType: answers.projectType, + selectedIDEs: answers.selectedIDEs || [], + selectedTechPreset: answers.selectedTechPreset || 'none', + steps: [ + 'install-aiox-core', + 'generate-ide-configs', + 'generate-boundary-rules', + 'copy-skills-and-commands', + 'run-ide-sync', + 'bootstrap-entity-registry', + 'configure-environment', + 'install-dependencies', + 'validate-installation', + ], + }; + + if (!options.quiet) { + console.log('\n🧪 Dry run mode'); + console.log(' No files will be modified.'); + console.log(` Project type: ${preview.projectType}`); + console.log(` IDEs: ${preview.selectedIDEs.length > 0 ? preview.selectedIDEs.join(', ') : 'none'}`); + console.log(` Tech preset: ${preview.selectedTechPreset}`); + } + + return preview; + } + // Story 1.4: Install AIOX core framework (agents, tasks, workflows, templates) console.log('\n📦 Installing AIOX core framework...'); let aioxCoreResult = null; try { aioxCoreResult = await installAioxCore({ targetDir: process.cwd(), + projectType: answers.projectType || 'greenfield', onProgress: (_status) => { // Silent progress - spinner handles feedback }, @@ -554,7 +585,7 @@ async function runWizard(options = {}) { try { await commandValidate({ quiet: true }); answers.ideSyncValidation = 'pass'; - } catch (validateError) { + } catch { answers.ideSyncValidation = 'drift'; } finally { console.log = _origLog; diff --git a/packages/installer/tests/unit/generate-settings-json/generate-settings-json.test.js b/packages/installer/tests/unit/generate-settings-json/generate-settings-json.test.js index c6f5221f2c..b53f37ac1c 100644 --- a/packages/installer/tests/unit/generate-settings-json/generate-settings-json.test.js +++ b/packages/installer/tests/unit/generate-settings-json/generate-settings-json.test.js @@ -49,7 +49,7 @@ function createTempProject(boundary, existingSettings) { fs.writeFileSync( path.join(claudeDir, 'settings.json'), JSON.stringify(existingSettings, null, 2) + '\n', - 'utf8' + 'utf8', ); } @@ -256,7 +256,7 @@ describe('generate-settings-json', () => { protected: ['bin/aiox.js'], exceptions: [], }, - { language: 'pt', customSetting: true } + { language: 'pt', customSetting: true }, ); try { @@ -273,14 +273,14 @@ describe('generate-settings-json', () => { } }); - test('frameworkProtection false preserves user settings and removes permissions', () => { + test('frameworkProtection false preserves user settings and existing permissions', () => { const tmpDir = createTempProject( { frameworkProtection: false, protected: ['bin/aiox.js'], exceptions: [], }, - { language: 'pt', permissions: { deny: ['old-rule'], allow: [] } } + { language: 'pt', permissions: { deny: ['old-rule'], allow: [] } }, ); try { @@ -289,7 +289,7 @@ describe('generate-settings-json', () => { const parsed = JSON.parse(content); expect(parsed.language).toBe('pt'); - expect(parsed.permissions).toBeUndefined(); + expect(parsed.permissions).toEqual({ deny: ['old-rule'], allow: [] }); } finally { cleanupTempProject(tmpDir); } diff --git a/tests/installer/aiox-core-installer.test.js b/tests/installer/aiox-core-installer.test.js index 82a955fb40..05c3b88e51 100644 --- a/tests/installer/aiox-core-installer.test.js +++ b/tests/installer/aiox-core-installer.test.js @@ -9,6 +9,8 @@ const fs = require('fs-extra'); const os = require('os'); const { + installAioxCore, + copyDirectoryWithRootReplacement, generateFileHashes, generateVersionJson, } = require('../../packages/installer/src/installer/aiox-core-installer'); @@ -171,4 +173,53 @@ describe('AIOX Core Installer - Version Tracking', () => { expect(result.fileHashes['config.yaml']).toBeDefined(); }); }); + + describe('brownfield preservation', () => { + it('should keep nested relative paths when copying directories', async () => { + const sourceDir = path.join(tempDir, 'source', 'development'); + const destDir = path.join(tempDir, 'target', '.aiox-core', 'development'); + + await fs.ensureDir(path.join(sourceDir, 'agents')); + await fs.writeFile(path.join(sourceDir, 'agents', 'dev.md'), '# Dev'); + + const copied = await copyDirectoryWithRootReplacement(sourceDir, destDir, null, { + baseDir: destDir, + }); + + expect(copied).toContain('agents/dev.md'); + }); + + it('should preserve agent MEMORY.md during brownfield install', async () => { + const sourceDir = path.join(tempDir, 'package-source'); + const targetDir = path.join(tempDir, 'project'); + const existingMemoryPath = path.join( + targetDir, + '.aiox-core', + 'development', + 'agents', + 'dev', + 'MEMORY.md', + ); + + await fs.ensureDir(path.join(sourceDir, 'development', 'agents', 'dev')); + await fs.writeFile( + path.join(sourceDir, 'development', 'agents', 'dev', 'MEMORY.md'), + 'framework memory', + 'utf8', + ); + + await fs.ensureDir(path.dirname(existingMemoryPath)); + await fs.writeFile(existingMemoryPath, 'custom project memory', 'utf8'); + + const result = await installAioxCore({ + targetDir, + sourceDir, + projectType: 'brownfield', + packageVersion: '9.9.9', + }); + + expect(result.success).toBe(true); + expect(await fs.readFile(existingMemoryPath, 'utf8')).toBe('custom project memory'); + }); + }); }); diff --git a/tests/installer/brownfield-upgrader.test.js b/tests/installer/brownfield-upgrader.test.js index d94080dbb5..f0ca3884ca 100644 --- a/tests/installer/brownfield-upgrader.test.js +++ b/tests/installer/brownfield-upgrader.test.js @@ -133,6 +133,61 @@ describe('brownfield-upgrader', () => { expect(report.newFiles[0].path).toBe('new-file.md'); }); + it('should preserve local files missing from installed manifest when they already exist on disk', () => { + const aioxCoreDir = path.join(targetDir, '.aiox-core'); + fs.ensureDirSync(aioxCoreDir); + fs.ensureDirSync(path.join(aioxCoreDir, 'development', 'agents', 'dev')); + fs.writeFileSync( + path.join(aioxCoreDir, 'development', 'agents', 'dev', 'MEMORY.md'), + 'custom memory content', + ); + + const sourceManifest = { + version: '2.1.0', + files: [ + { + path: 'development/agents/dev/MEMORY.md', + hash: 'sha256:source_hash', + type: 'memory', + }, + ], + }; + const installedManifest = { + installed_version: '2.0.0', + files: [], + }; + + const report = generateUpgradeReport(sourceManifest, installedManifest, targetDir); + + expect(report.newFiles).toHaveLength(0); + expect(report.userModifiedFiles).toHaveLength(1); + expect(report.userModifiedFiles[0].path).toBe('development/agents/dev/MEMORY.md'); + expect(report.userModifiedFiles[0].reason).toContain('not tracked by installed manifest'); + }); + + it('should treat untracked local files as unchanged when they already match source', () => { + const aioxCoreDir = path.join(targetDir, '.aiox-core'); + fs.ensureDirSync(aioxCoreDir); + const filePath = path.join(aioxCoreDir, 'already-there.md'); + fs.writeFileSync(filePath, 'same content'); + const sourceHash = `sha256:${hashFile(filePath)}`; + + const sourceManifest = { + version: '2.1.0', + files: [{ path: 'already-there.md', hash: sourceHash, type: 'agent' }], + }; + const installedManifest = { + installed_version: '2.0.0', + files: [], + }; + + const report = generateUpgradeReport(sourceManifest, installedManifest, targetDir); + + expect(report.newFiles).toHaveLength(0); + expect(report.userModifiedFiles).toHaveLength(0); + expect(report.unchangedFiles).toBe(1); + }); + it('should identify modified files', () => { const aioxCoreDir = path.join(targetDir, '.aiox-core'); fs.ensureDirSync(aioxCoreDir); @@ -288,6 +343,31 @@ describe('brownfield-upgrader', () => { expect(content.installed_version).toBe('2.1.0'); expect(content.installed_from).toBe('aiox-core@2.1.0'); }); + + it('should load installed manifest from version.json when explicit manifest is missing', () => { + fs.ensureDirSync(path.join(targetDir, '.aiox-core')); + fs.writeJsonSync(path.join(targetDir, '.aiox-core', 'version.json'), { + version: '5.0.4', + fileHashes: { + 'core/config.yaml': 'sha256:abc123', + 'development/agents/dev/MEMORY.md': 'sha256:def456', + }, + }); + + const loaded = require('../../packages/installer/src/installer/brownfield-upgrader') + .loadInstalledManifest(targetDir); + + expect(loaded.installed_version).toBe('5.0.4'); + expect(loaded.files).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'core/config.yaml', hash: 'sha256:abc123' }), + expect.objectContaining({ + path: 'development/agents/dev/MEMORY.md', + hash: 'sha256:def456', + }), + ]), + ); + }); }); describe('formatUpgradeReport', () => { diff --git a/tests/installer/configure-environment-brownfield.test.js b/tests/installer/configure-environment-brownfield.test.js new file mode 100644 index 0000000000..deab26c75e --- /dev/null +++ b/tests/installer/configure-environment-brownfield.test.js @@ -0,0 +1,60 @@ +const path = require('path'); +const os = require('os'); +const fs = require('fs-extra'); +const yaml = require('js-yaml'); + +const { configureEnvironment } = require('../../packages/installer/src/config/configure-environment'); + +describe('configureEnvironment brownfield merge behavior', () => { + let tempDir; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'aiox-config-brownfield-')); + await fs.ensureDir(path.join(tempDir, '.aiox-core')); + }); + + afterEach(async () => { + if (tempDir && await fs.pathExists(tempDir)) { + await fs.remove(tempDir); + } + }); + + it('should merge .env.example and core-config.yaml for lowercase brownfield projectType', async () => { + await fs.writeFile( + path.join(tempDir, '.env.example'), + 'CUSTOM_ONLY=keep-me\nCIRCLE_TOKEN=\n', + 'utf8', + ); + await fs.writeFile( + path.join(tempDir, '.aiox-core', 'core-config.yaml'), + yaml.dump({ + user_profile: 'bob', + metrics: { + custom: true, + }, + }), + 'utf8', + ); + + const result = await configureEnvironment({ + targetDir: tempDir, + projectType: 'brownfield', + userProfile: 'advanced', + skipPrompts: true, + }); + + expect(result.envExampleCreated).toBe(true); + expect(result.coreConfigCreated).toBe(true); + + const envExample = await fs.readFile(path.join(tempDir, '.env.example'), 'utf8'); + expect(envExample).toContain('CUSTOM_ONLY=keep-me'); + expect(envExample).toContain('OPENAI_API_KEY='); + + const coreConfig = yaml.load( + await fs.readFile(path.join(tempDir, '.aiox-core', 'core-config.yaml'), 'utf8'), + ); + expect(coreConfig.user_profile).toBe('bob'); + expect(coreConfig.metrics).toEqual({ custom: true }); + expect(coreConfig.boundary.frameworkProtection).toBe(true); + }); +}); diff --git a/tests/installer/generate-settings-json.test.js b/tests/installer/generate-settings-json.test.js new file mode 100644 index 0000000000..ac88a96a7d --- /dev/null +++ b/tests/installer/generate-settings-json.test.js @@ -0,0 +1,76 @@ +const path = require('path'); +const os = require('os'); +const fs = require('fs-extra'); + +const { writeSettingsJson } = require('../../.aiox-core/infrastructure/scripts/generate-settings-json'); + +describe('generate-settings-json', () => { + let tempDir; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'aiox-settings-json-')); + await fs.ensureDir(path.join(tempDir, '.claude')); + }); + + afterEach(async () => { + if (tempDir && await fs.pathExists(tempDir)) { + await fs.remove(tempDir); + } + }); + + it('should merge permissions without dropping existing security rules', async () => { + const settingsPath = path.join(tempDir, '.claude', 'settings.json'); + await fs.writeFile( + settingsPath, + JSON.stringify({ + defaultMode: 'acceptEdits', + permissions: { + allow: ['Bash(git status:*)'], + deny: ['Bash(git push --force:*)'], + }, + }, null, 2) + '\n', + 'utf8', + ); + + writeSettingsJson(tempDir, { + allow: ['Read(./.aiox-core/**)'], + deny: ['Bash(--no-verify:*)'], + }); + + const settings = JSON.parse(await fs.readFile(settingsPath, 'utf8')); + expect(settings.defaultMode).toBe('acceptEdits'); + expect(settings.permissions.allow).toEqual( + expect.arrayContaining(['Bash(git status:*)', 'Read(./.aiox-core/**)']), + ); + expect(settings.permissions.deny).toEqual( + expect.arrayContaining(['Bash(git push --force:*)', 'Bash(--no-verify:*)']), + ); + }); + + it('should preserve existing permissions when no new permissions are generated', async () => { + const settingsPath = path.join(tempDir, '.claude', 'settings.json'); + await fs.writeFile( + settingsPath, + JSON.stringify({ + defaultMode: 'acceptEdits', + permissions: { + allow: ['Bash(git push origin main)'], + deny: ['Bash(git push --force)'], + }, + }, null, 2) + '\n', + 'utf8', + ); + + writeSettingsJson(tempDir, { + allow: [], + deny: [], + }); + + const settings = JSON.parse(await fs.readFile(settingsPath, 'utf8')); + expect(settings.defaultMode).toBe('acceptEdits'); + expect(settings.permissions).toEqual({ + allow: ['Bash(git push origin main)'], + deny: ['Bash(git push --force)'], + }); + }); +}); diff --git a/tests/updater/aiox-updater.test.js b/tests/updater/aiox-updater.test.js index 2cd7e99ff4..e2cb6346c6 100644 --- a/tests/updater/aiox-updater.test.js +++ b/tests/updater/aiox-updater.test.js @@ -8,7 +8,13 @@ const path = require('path'); const fs = require('fs-extra'); const os = require('os'); -const { AIOXUpdater, UpdateStatus, formatCheckResult, formatUpdateResult } = require('../../packages/installer/src/updater'); +const { + AIOXUpdater, + UpdateStatus, + formatCheckResult, + formatUpdateResult, + selectInstalledManifest, +} = require('../../packages/installer/src/updater'); describe('AIOXUpdater', () => { let tempDir; @@ -189,6 +195,18 @@ describe('AIOXUpdater', () => { const backupVersionJson = path.join(updater.backupDir, 'version.json'); expect(fs.existsSync(backupVersionJson)).toBe(true); }); + + it('should backup manifest signature when present', async () => { + await fs.writeFile( + path.join(tempDir, '.aiox-core', 'install-manifest.yaml.minisig'), + 'signature', + 'utf8', + ); + + await updater.createBackup(); + + expect(fs.existsSync(path.join(updater.backupDir, 'install-manifest.yaml.minisig'))).toBe(true); + }); }); describe('rollback', () => { @@ -234,6 +252,59 @@ describe('AIOXUpdater', () => { expect(versionJson.version).toBe('2.0.0'); expect(versionJson.updatedAt).toBeDefined(); }); + + it('should persist provided file hashes', async () => { + await updater.updateVersionInfo('2.0.0', { + fileHashes: { + 'development/agents/dev.md': 'sha256:abc123', + }, + }); + + const versionJson = await fs.readJson(path.join(tempDir, '.aiox-core', 'version.json')); + expect(versionJson.fileHashes).toEqual({ + 'development/agents/dev.md': 'sha256:abc123', + }); + }); + }); + + describe('selectInstalledManifest', () => { + it('should prefer the more complete package manifest while keeping project-only entries', () => { + const projectManifest = { + installed_version: '5.0.4', + files: [ + { path: 'core-config.yaml', hash: 'sha256:project-core' }, + { path: 'project-only.md', hash: 'sha256:project-only' }, + ], + }; + const packageManifest = { + installed_version: '5.0.4', + files: [ + { path: 'core-config.yaml', hash: 'sha256:package-core' }, + { path: 'development/agents/dev/MEMORY.md', hash: 'sha256:memory' }, + ], + }; + + const selected = selectInstalledManifest(projectManifest, packageManifest); + + expect(selected.installed_version).toBe('5.0.4'); + expect(selected.files).toEqual( + expect.arrayContaining([ + { path: 'core-config.yaml', hash: 'sha256:package-core' }, + { path: 'development/agents/dev/MEMORY.md', hash: 'sha256:memory' }, + { path: 'project-only.md', hash: 'sha256:project-only' }, + ]), + ); + }); + + it('should fall back when one manifest is missing', () => { + const projectManifest = { + installed_version: '5.0.4', + files: [{ path: 'core-config.yaml', hash: 'sha256:project-core' }], + }; + + expect(selectInstalledManifest(projectManifest, null)).toBe(projectManifest); + expect(selectInstalledManifest(null, projectManifest)).toBe(projectManifest); + }); }); describe('update (dry-run)', () => { @@ -244,6 +315,58 @@ describe('AIOXUpdater', () => { expect(result.dryRun).toBe(true); }); }); + + describe('update execution', () => { + it('should validate against the updated package source and persist manifest hashes', async () => { + jest.spyOn(updater, 'checkForUpdates').mockResolvedValue({ + hasUpdate: true, + installed: '1.0.0', + latest: '1.1.0', + }); + jest.spyOn(updater, 'createBackup').mockResolvedValue(); + jest.spyOn(updater, 'detectCustomizations').mockResolvedValue({ + customized: ['development/agents/dev/MEMORY.md'], + }); + + const sourceManifest = { + version: '1.1.0', + files: [ + { + path: 'development/agents/dev.md', + hash: 'sha256:newhash', + }, + ], + }; + + jest.spyOn(updater, 'applyUpdate').mockResolvedValue({ + success: true, + filesUpdated: 3, + filesSkipped: [{ path: 'development/agents/dev/MEMORY.md' }], + sourcePackageRoot: '/tmp/aiox-package', + sourceManifest, + }); + const updateVersionInfoSpy = jest.spyOn(updater, 'updateVersionInfo').mockResolvedValue(); + const validateSpy = jest.spyOn(updater, 'validateAfterUpdate').mockResolvedValue({ + success: true, + integrityScore: 100, + }); + jest.spyOn(updater, 'cleanupBackup').mockResolvedValue(); + + const result = await updater.update(); + + expect(updateVersionInfoSpy).toHaveBeenCalledWith('1.1.0', { + fileHashes: { + 'development/agents/dev.md': 'sha256:newhash', + }, + }); + expect(validateSpy).toHaveBeenCalledWith({ + sourceDir: '/tmp/aiox-package', + }); + expect(result.success).toBe(true); + expect(result.filesUpdated).toBe(3); + expect(result.filesPreserved).toBe(1); + }); + }); }); describe('formatCheckResult', () => { diff --git a/tests/wizard/integration.test.js b/tests/wizard/integration.test.js index bfc2483154..e602aadb37 100644 --- a/tests/wizard/integration.test.js +++ b/tests/wizard/integration.test.js @@ -151,6 +151,22 @@ describe('Wizard Integration - Story 1.7', () => { projectPath: process.cwd(), }); }); + + it('should short-circuit in dry-run mode before any filesystem writes', async () => { + const preview = await runWizard({ dryRun: true, quiet: true }); + + expect(preview).toEqual( + expect.objectContaining({ + dryRun: true, + projectType: 'brownfield', + steps: expect.arrayContaining(['install-aiox-core', 'configure-environment']), + }), + ); + expect(installAioxCore).not.toHaveBeenCalled(); + expect(generateIDEConfigs).not.toHaveBeenCalled(); + expect(configureEnvironment).not.toHaveBeenCalled(); + expect(installDependencies).not.toHaveBeenCalled(); + }); }); describe('Package Manager Auto-Detection (AC1)', () => { From d5d22cdec6a3b106c165ad02ba2752ec47374c82 Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Thu, 16 Apr 2026 12:47:54 -0300 Subject: [PATCH 2/2] salvage(installer): preserve Pro guided-install fixes from wip --- bin/utils/framework-guard.js | 6 +- packages/installer/src/wizard/i18n.js | 33 +- packages/installer/src/wizard/pro-setup.js | 342 ++++++++++++++++-- .../core/health-check/check-registry.test.js | 101 ++++-- tests/installer/pro-setup-auth.test.js | 217 +++++++++++ tests/synapse/synapse-memory-provider.test.js | 51 +-- 6 files changed, 633 insertions(+), 117 deletions(-) diff --git a/bin/utils/framework-guard.js b/bin/utils/framework-guard.js index f12053805a..07a9ae8aa5 100644 --- a/bin/utils/framework-guard.js +++ b/bin/utils/framework-guard.js @@ -44,8 +44,10 @@ const FALLBACK_EXCEPTIONS = [ * @returns {RegExp} */ function globToRegex(glob) { + const placeholder = '\u0000'; + // 1. Replace ** with placeholder before processing - let pattern = glob.replace(/\*\*/g, '\u0000'); + let pattern = glob.replace(/\*\*/g, placeholder); // 2. Escape all regex-special chars (dots, plus, etc.) — but NOT * or placeholder pattern = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); @@ -54,7 +56,7 @@ function globToRegex(glob) { pattern = pattern.replace(/\*/g, '[^/]+'); // 4. Restore ** placeholder to any-depth matcher - pattern = pattern.replace(/\u0000/g, '.+'); + pattern = pattern.split(placeholder).join('.+'); // If pattern ends with .+ (was **), match prefix if (glob.endsWith('**')) { diff --git a/packages/installer/src/wizard/i18n.js b/packages/installer/src/wizard/i18n.js index 6195812a89..fc62e0c30e 100644 --- a/packages/installer/src/wizard/i18n.js +++ b/packages/installer/src/wizard/i18n.js @@ -117,12 +117,13 @@ const TRANSLATIONS = { proSeatLimit: 'Deactivate another device or upgrade your license.', proAlreadyActivated: 'Pro license already activated for this account.', proActivationFailed: 'Activation failed: {message}', + proLicenseCacheFailed: 'Local Pro license cache could not be saved: {message}', proEnterKeyPrompt: 'Enter your Pro license key:', proKeyRequired: 'License key is required', proKeyInvalid: 'Invalid format. Expected: PRO-XXXX-XXXX-XXXX-XXXX', proKeyValidated: 'License validated: {key}', - proModuleNotAvailable: 'Pro license module not available. Ensure @aiox-fullstack/pro is installed.', - proModuleBootstrap: 'Pro license module not found locally. Installing @aiox-fullstack/pro to bootstrap...', + proModuleNotAvailable: 'Pro license module not available. Install AIOX Pro with `npx aiox-pro install`.', + proModuleBootstrap: 'Pro license module not found locally. Installing AIOX Pro to bootstrap...', proServerUnreachable: 'License server is unreachable. Check your internet connection and try again.', proVerifyingAccessShort: 'Verifying access...', proAccessConfirmed: 'Pro access confirmed.', @@ -146,10 +147,10 @@ const TRANSLATIONS = { proInitPackageJson: 'Initializing package.json...', proPackageJsonCreated: 'package.json created', proPackageJsonFailed: 'Failed to create package.json', - proInstallingPackage: 'Installing @aiox-fullstack/pro...', + proInstallingPackage: 'Installing AIOX Pro package...', proPackageInstalled: 'Pro package installed', proPackageInstallFailed: 'Failed to install Pro package', - proScaffolderNotAvailable: 'Pro scaffolder not available. Ensure @aiox-fullstack/pro is installed.', + proScaffolderNotAvailable: 'Pro scaffolder not available. Install AIOX Pro with `npx aiox-pro install`.', proFilesInstalled: 'Files installed: {count}', proSquads: 'Squads: {names}', proConfigs: 'Configs: {count} files', @@ -161,7 +162,7 @@ const TRANSLATIONS = { proPackageNotFound: 'Pro package not found after npm install. Check npm output.', proScaffolderNotFound: 'Pro scaffolder module not found.', proNpmInitFailed: 'npm init failed: {message}', - proNpmInstallFailed: 'npm install @aiox-fullstack/pro failed: {message}. Try manually: npm install @aiox-fullstack/pro', + proNpmInstallFailed: 'AIOX Pro package install failed: {message}. Try manually: npx aiox-pro install', }, pt: { @@ -275,12 +276,13 @@ const TRANSLATIONS = { proSeatLimit: 'Desative outro dispositivo ou faça upgrade da sua licença.', proAlreadyActivated: 'Licença Pro já ativada para esta conta.', proActivationFailed: 'Falha na ativação: {message}', + proLicenseCacheFailed: 'Não foi possível salvar o cache local da licença Pro: {message}', proEnterKeyPrompt: 'Insira sua chave de licença Pro:', proKeyRequired: 'Chave de licença é obrigatória', proKeyInvalid: 'Formato inválido. Esperado: PRO-XXXX-XXXX-XXXX-XXXX', proKeyValidated: 'Licença validada: {key}', - proModuleNotAvailable: 'Módulo de licença Pro não disponível. Certifique-se de que @aiox-fullstack/pro está instalado.', - proModuleBootstrap: 'Módulo de licença Pro não encontrado localmente. Instalando @aiox-fullstack/pro...', + proModuleNotAvailable: 'Módulo de licença Pro não disponível. Instale o AIOX Pro com `npx aiox-pro install`.', + proModuleBootstrap: 'Módulo de licença Pro não encontrado localmente. Instalando o AIOX Pro...', proServerUnreachable: 'Servidor de licenças inacessível. Verifique sua conexão com a internet e tente novamente.', proVerifyingAccessShort: 'Verificando acesso...', proAccessConfirmed: 'Acesso Pro confirmado.', @@ -304,10 +306,10 @@ const TRANSLATIONS = { proInitPackageJson: 'Inicializando package.json...', proPackageJsonCreated: 'package.json criado', proPackageJsonFailed: 'Falha ao criar package.json', - proInstallingPackage: 'Instalando @aiox-fullstack/pro...', + proInstallingPackage: 'Instalando pacote AIOX Pro...', proPackageInstalled: 'Pacote Pro instalado', proPackageInstallFailed: 'Falha ao instalar pacote Pro', - proScaffolderNotAvailable: 'Scaffolder Pro não disponível. Certifique-se de que @aiox-fullstack/pro está instalado.', + proScaffolderNotAvailable: 'Scaffolder Pro não disponível. Instale o AIOX Pro com `npx aiox-pro install`.', proFilesInstalled: 'Arquivos instalados: {count}', proSquads: 'Squads: {names}', proConfigs: 'Configs: {count} arquivos', @@ -319,7 +321,7 @@ const TRANSLATIONS = { proPackageNotFound: 'Pacote Pro não encontrado após npm install. Verifique a saída do npm.', proScaffolderNotFound: 'Módulo scaffolder Pro não encontrado.', proNpmInitFailed: 'npm init falhou: {message}', - proNpmInstallFailed: 'npm install @aiox-fullstack/pro falhou: {message}. Tente manualmente: npm install @aiox-fullstack/pro', + proNpmInstallFailed: 'A instalação do pacote AIOX Pro falhou: {message}. Tente manualmente: npx aiox-pro install', }, es: { @@ -432,12 +434,13 @@ const TRANSLATIONS = { proSeatLimit: 'Desactive otro dispositivo o actualice su licencia.', proAlreadyActivated: 'Licencia Pro ya activada para esta cuenta.', proActivationFailed: 'Error de activación: {message}', + proLicenseCacheFailed: 'No se pudo guardar el caché local de la licencia Pro: {message}', proEnterKeyPrompt: 'Ingrese su clave de licencia Pro:', proKeyRequired: 'Clave de licencia es obligatoria', proKeyInvalid: 'Formato inválido. Esperado: PRO-XXXX-XXXX-XXXX-XXXX', proKeyValidated: 'Licencia validada: {key}', - proModuleNotAvailable: 'Módulo de licencia Pro no disponible. Asegúrese de que @aiox-fullstack/pro esté instalado.', - proModuleBootstrap: 'Módulo de licencia Pro no encontrado localmente. Instalando @aiox-fullstack/pro...', + proModuleNotAvailable: 'Módulo de licencia Pro no disponible. Instale AIOX Pro con `npx aiox-pro install`.', + proModuleBootstrap: 'Módulo de licencia Pro no encontrado localmente. Instalando AIOX Pro...', proServerUnreachable: 'Servidor de licencias inaccesible. Verifique su conexión a internet e intente nuevamente.', proVerifyingAccessShort: 'Verificando acceso...', proAccessConfirmed: 'Acceso Pro confirmado.', @@ -461,10 +464,10 @@ const TRANSLATIONS = { proInitPackageJson: 'Inicializando package.json...', proPackageJsonCreated: 'package.json creado', proPackageJsonFailed: 'Error al crear package.json', - proInstallingPackage: 'Instalando @aiox-fullstack/pro...', + proInstallingPackage: 'Instalando paquete AIOX Pro...', proPackageInstalled: 'Paquete Pro instalado', proPackageInstallFailed: 'Error al instalar paquete Pro', - proScaffolderNotAvailable: 'Scaffolder Pro no disponible. Asegúrese de que @aiox-fullstack/pro esté instalado.', + proScaffolderNotAvailable: 'Scaffolder Pro no disponible. Instale AIOX Pro con `npx aiox-pro install`.', proFilesInstalled: 'Archivos instalados: {count}', proSquads: 'Squads: {names}', proConfigs: 'Configs: {count} archivos', @@ -476,7 +479,7 @@ const TRANSLATIONS = { proPackageNotFound: 'Paquete Pro no encontrado después de npm install. Verifique la salida de npm.', proScaffolderNotFound: 'Módulo scaffolder Pro no encontrado.', proNpmInitFailed: 'npm init falló: {message}', - proNpmInstallFailed: 'npm install @aiox-fullstack/pro falló: {message}. Intente manualmente: npm install @aiox-fullstack/pro', + proNpmInstallFailed: 'La instalación del paquete AIOX Pro falló: {message}. Intente manualmente: npx aiox-pro install', }, }; diff --git a/packages/installer/src/wizard/pro-setup.js b/packages/installer/src/wizard/pro-setup.js index 829253ffb8..1330546f16 100644 --- a/packages/installer/src/wizard/pro-setup.js +++ b/packages/installer/src/wizard/pro-setup.js @@ -331,29 +331,55 @@ function showStep(current, total, label) { */ function loadProModule(moduleName) { const path = require('path'); + const tryRequire = (requestPath) => { + try { + return require(requestPath); + } catch (error) { + if ( + error?.code === 'MODULE_NOT_FOUND' + && typeof error.message === 'string' + && error.message.includes(requestPath) + ) { + return null; + } + throw error; + } + }; // 1. Framework-dev mode (cloned repo with pro/ submodule) - try { - return require(`../../../../pro/license/${moduleName}`); - } catch { /* not available */ } + const frameworkPath = `../../../../pro/license/${moduleName}`; + const frameworkModule = tryRequire(frameworkPath); + if (frameworkModule) { + return frameworkModule; + } - // 2. @aiox-fullstack/pro package (works when aiox-core is a local dependency) - try { - return require(`@aiox-fullstack/pro/license/${moduleName}`); - } catch { /* not available */ } + // 2. npm packages — try canonical then fallback + const npmScopes = ['@aiox-fullstack/pro', '@aios-fullstack/pro']; + for (const scope of npmScopes) { + const requestPath = `${scope}/license/${moduleName}`; + const loadedModule = tryRequire(requestPath); + if (loadedModule) { + return loadedModule; + } + } // 3. aiox-core in node_modules (brownfield upgrade from >= v4.2.15) - try { - const absPath = path.join(process.cwd(), 'node_modules', 'aiox-core', 'pro', 'license', moduleName); - return require(absPath); - } catch { /* not available */ } + const aioxCorePath = path.join(process.cwd(), 'node_modules', 'aiox-core', 'pro', 'license', moduleName); + const aioxCoreModule = tryRequire(aioxCorePath); + if (aioxCoreModule) { + return aioxCoreModule; + } - // 4. @aiox-fullstack/pro in user project (npx context — require resolves from + // 4. npm package in user project via absolute path (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', '@aiox-fullstack', 'pro', 'license', moduleName); - return require(absPath); - } catch { /* not available */ } + const absScopeDirs = ['@aiox-fullstack', '@aios-fullstack']; + for (const scopeDir of absScopeDirs) { + const absPath = path.join(process.cwd(), 'node_modules', scopeDir, 'pro', 'license', moduleName); + const loadedModule = tryRequire(absPath); + if (loadedModule) { + return loadedModule; + } + } return null; } @@ -378,6 +404,57 @@ function loadFeatureGate() { return loadProModule('feature-gate'); } +/** + * Try to load the license cache helpers via lazy import. + * Attempts multiple resolution paths for framework-dev, greenfield, and brownfield. + * + * @returns {{ writeLicenseCache: Function }|null} License cache helpers or null + */ +function loadLicenseCache() { + return loadProModule('license-cache'); +} + +/** + * Generate a deterministic machine identifier compatible with the Pro runtime. + * + * Mirrors pro/license/license-crypto.js so licenses activated during install use + * the same seat fingerprint that later validation/deactivation expects. + * + * @returns {string} SHA-256 machine fingerprint (64 hex chars) + */ +function generateMachineId() { + const licenseCryptoModule = loadProModule('license-crypto'); + if (licenseCryptoModule && typeof licenseCryptoModule.generateMachineId === 'function') { + return licenseCryptoModule.generateMachineId(); + } + + const crypto = require('crypto'); + const os = require('os'); + const components = []; + + components.push(os.hostname()); + + const cpus = os.cpus(); + if (cpus.length > 0) { + components.push(cpus[0].model); + } + + const networkInterfaces = os.networkInterfaces(); + for (const [, interfaces] of Object.entries(networkInterfaces)) { + for (const iface of interfaces || []) { + if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') { + components.push(iface.mac); + break; + } + } + if (components.length > 2) { + break; + } + } + + return crypto.createHash('sha256').update(components.join('|')).digest('hex'); +} + /** * Get a license API client instance. * @@ -387,7 +464,9 @@ function loadFeatureGate() { * @returns {Object} Client instance with isOnline, checkEmail, login, signup, activateByAuth */ function getLicenseClient() { - const loader = module.exports._testing ? module.exports._testing.loadLicenseApi : loadLicenseApi; + const loader = module.exports._testing && module.exports._testing.loadLicenseApi + ? module.exports._testing.loadLicenseApi + : loadLicenseApi; const licenseModule = loader(); if (licenseModule) { @@ -412,6 +491,98 @@ function loadProScaffolder() { } } +/** + * Persist the activated license locally so post-install Pro commands can recognize it. + * + * @param {string} targetDir - Project root directory + * @param {Object} licenseResult - Successful result from stepLicenseGate() + * @returns {{ success: boolean, error?: string }} Cache write result + */ +function persistLicenseCache(targetDir, licenseResult) { + const loader = module.exports._testing && module.exports._testing.loadLicenseCache + ? module.exports._testing.loadLicenseCache + : loadLicenseCache; + const cacheModule = loader(); + + if (!cacheModule || typeof cacheModule.writeLicenseCache !== 'function') { + return { success: false, error: 'License cache module not available.' }; + } + + const activationResult = licenseResult && licenseResult.activationResult ? licenseResult.activationResult : {}; + const key = activationResult.key || licenseResult.key; + + if (!key || key === 'existing') { + return { success: false, error: 'Activated license key not available for local cache persistence.' }; + } + + return cacheModule.writeLicenseCache({ + key, + activatedAt: activationResult.activatedAt || new Date().toISOString(), + expiresAt: activationResult.expiresAt, + features: Array.isArray(activationResult.features) ? activationResult.features : [], + seats: activationResult.seats || { used: 1, max: 1 }, + cacheValidDays: activationResult.cacheValidDays, + gracePeriodDays: activationResult.gracePeriodDays, + }, targetDir); +} + +/** + * Ensure auth-based activations are also recognized by the standard key-validation flow. + * + * Some backends may return a valid license key from auth activation before the legacy + * key-validation endpoint recognizes the current machine. In that case, normalize state by + * replaying a key activation with the same machine fingerprint. + * + * @param {Object} client - License client + * @param {Object} activationResult - Result returned by activateByAuth() + * @param {string} machineId - Machine fingerprint + * @param {string} aioxCoreVersion - Current aiox-core version + * @returns {Promise} Activation result safe for cache persistence + `aiox pro validate` + */ +async function ensureKeyValidationParity(client, activationResult, machineId, aioxCoreVersion) { + if (!activationResult || !activationResult.key || typeof client?.activate !== 'function') { + return activationResult; + } + + const mergeActivation = (normalized) => ({ + ...activationResult, + key: normalized.key || activationResult.key, + features: normalized.features || activationResult.features, + seats: normalized.seats || activationResult.seats, + expiresAt: normalized.expiresAt || activationResult.expiresAt, + cacheValidDays: normalized.cacheValidDays || activationResult.cacheValidDays, + gracePeriodDays: normalized.gracePeriodDays || activationResult.gracePeriodDays, + activatedAt: normalized.activatedAt || activationResult.activatedAt, + }); + + if (typeof client.validate === 'function') { + try { + const validationResult = await client.validate(activationResult.key, machineId); + if (validationResult && validationResult.valid !== false) { + return mergeActivation(validationResult); + } + } catch (error) { + if (!['MACHINE_NOT_ACTIVATED', 'NOT_ACTIVATED'].includes(error.code)) { + throw error; + } + } + } + + try { + const normalizedActivation = await client.activate( + activationResult.key, + machineId, + aioxCoreVersion, + ); + return mergeActivation(normalizedActivation); + } catch (error) { + if (['ALREADY_ACTIVATED', 'MACHINE_ALREADY_ACTIVATED'].includes(error.code)) { + return activationResult; + } + throw error; + } +} + /** * Step 1: License Gate — authenticate and validate license. * @@ -557,8 +728,8 @@ async function stepLicenseGateWithEmail() { try { checkResult = await client.checkEmail(trimmedEmail); } catch (checkError) { - checkSpinner.fail(tf('proVerificationFailed', { message: checkError.message })); - return { success: false, error: checkError.message }; + checkSpinner.info(t('proBuyerCheckUnavailable')); + return fallbackAuthWithoutBuyerCheck(client, trimmedEmail); } // Step 2a: NOT a buyer → stop @@ -583,6 +754,83 @@ async function stepLicenseGateWithEmail() { return createAccountFlow(client, trimmedEmail); } +/** + * Fallback interactive auth flow when buyer/account pre-check is unavailable. + * + * Prompts for a password, attempts login first, then falls back to signup if no account exists. + * If the account already exists but the first password is wrong, hands control to the normal + * login retry flow so the user still gets multiple attempts. + * + * @param {object} client - LicenseApiClient instance + * @param {string} email - User email + * @returns {Promise} Result with { success, key, activationResult } + */ +async function fallbackAuthWithoutBuyerCheck(client, email) { + const inquirer = require('inquirer'); + + 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(); + + 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.fail(tf('proAuthFailed', { message: loginError.message })); + return { success: false, error: loginError.message }; + } + + spinner.info(t('proLoginFailedSignup')); + try { + await client.signup(email, password); + showSuccess(t('proAccountCreatedVerify')); + + const loginAfterSignup = await client.login(email, password); + sessionToken = loginAfterSignup.sessionToken; + emailVerified = loginAfterSignup.emailVerified; + } catch (signupError) { + if (signupError.code === 'EMAIL_ALREADY_REGISTERED') { + showInfo(t('proAccountExists')); + return loginWithRetry(client, email); + } + return { success: false, error: signupError.message }; + } + } + + if (!sessionToken) { + return { success: false, error: t('proAuthFailedShort') }; + } + + if (!emailVerified) { + const verifyResult = await waitForEmailVerification(client, sessionToken, email); + if (!verifyResult.success) { + return verifyResult; + } + } + + return activateProByAuth(client, sessionToken); +} + /** * Login flow with password retry (max 3 attempts). * @@ -984,14 +1232,8 @@ async function activateProByAuth(client, sessionToken) { 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); + // Generate machine fingerprint compatible with the Pro runtime + const machineId = generateMachineId(); // Read aiox-core version let aioxCoreVersion = 'unknown'; @@ -1005,7 +1247,13 @@ async function activateProByAuth(client, sessionToken) { // Keep 'unknown' } - const activationResult = await client.activateByAuth(sessionToken, machineId, aioxCoreVersion); + const authActivationResult = await client.activateByAuth(sessionToken, machineId, aioxCoreVersion); + const activationResult = await ensureKeyValidationParity( + client, + authActivationResult, + machineId, + aioxCoreVersion, + ); spinner.succeed(tf('proSubscriptionConfirmed', { key: maskLicenseKey(activationResult.key) })); return { success: true, key: activationResult.key, activationResult }; @@ -1126,14 +1374,8 @@ async function validateKeyWithApi(key) { }; } - // 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); + // Generate machine fingerprint compatible with the Pro runtime + const machineId = generateMachineId(); // Read aiox-core version let aioxCoreVersion = 'unknown'; @@ -1193,16 +1435,24 @@ async function stepInstallScaffold(targetDir, options = {}) { // Resolve pro source directory from multiple locations: // 1. Bundled in aiox-core package (pro/ submodule — npx and local dev) - // 2. @aiox-fullstack/pro in node_modules (legacy brownfield) + // 2. npm package — canonical @aiox-fullstack/pro or fallback @aios-fullstack/pro const bundledProDir = path.resolve(__dirname, '..', '..', '..', '..', 'pro'); - const npmProDir = path.join(targetDir, 'node_modules', '@aiox-fullstack', 'pro'); + const npmProCandidates = [ + path.join(targetDir, 'node_modules', '@aiox-fullstack', 'pro'), + path.join(targetDir, 'node_modules', '@aios-fullstack', 'pro'), + ]; let proSourceDir; if (fs.existsSync(bundledProDir) && fs.existsSync(path.join(bundledProDir, 'squads'))) { proSourceDir = bundledProDir; - } else if (fs.existsSync(npmProDir)) { - proSourceDir = npmProDir; } else { + proSourceDir = npmProCandidates.find((candidate) => ( + fs.existsSync(path.join(candidate, 'package.json')) + && fs.existsSync(path.join(candidate, 'squads')) + )); + } + + if (!proSourceDir) { return { success: false, error: t('proPackageNotFound'), @@ -1379,6 +1629,13 @@ async function runProWizard(options = {}) { result.licenseValidated = true; + const cachePersistResult = persistLicenseCache(targetDir, licenseResult); + if (!cachePersistResult.success) { + result.error = tf('proLicenseCacheFailed', { message: cachePersistResult.error }); + showError(result.error); + return result; + } + // Step 2: Install/Scaffold const scaffoldResult = await stepInstallScaffold(targetDir, { force: options.force, @@ -1418,6 +1675,7 @@ module.exports = { activateProByAuth, loginWithRetry, createAccountFlow, + fallbackAuthWithoutBuyerCheck, stepLicenseGateCI, stepLicenseGateWithKey, stepLicenseGateWithKeyInteractive, @@ -1425,9 +1683,13 @@ module.exports = { loadProModule, loadLicenseApi, loadFeatureGate, + loadLicenseCache, loadProScaffolder, getLicenseClient, InlineLicenseClient, + generateMachineId, + persistLicenseCache, + ensureKeyValidationParity, LICENSE_SERVER_URL, MAX_RETRIES, LICENSE_KEY_PATTERN, diff --git a/tests/core/health-check/check-registry.test.js b/tests/core/health-check/check-registry.test.js index 184fbfa13f..5e3456d2f3 100644 --- a/tests/core/health-check/check-registry.test.js +++ b/tests/core/health-check/check-registry.test.js @@ -5,50 +5,77 @@ * lookup by id/domain/severity/tag, healable checks, stats, and clear. */ -const { BaseCheck, CheckSeverity, CheckDomain } = require('../../../.aiox-core/core/health-check/base-check'); - -// Mock the built-in check modules to prevent loading real files -jest.mock('../../../.aiox-core/core/health-check/checks/project', () => ({}), { virtual: true }); -jest.mock('../../../.aiox-core/core/health-check/checks/local', () => ({}), { virtual: true }); -jest.mock('../../../.aiox-core/core/health-check/checks/repository', () => ({}), { virtual: true }); -jest.mock('../../../.aiox-core/core/health-check/checks/deployment', () => ({}), { virtual: true }); -jest.mock('../../../.aiox-core/core/health-check/checks/services', () => ({}), { virtual: true }); - -const CheckRegistry = require('../../../.aiox-core/core/health-check/check-registry'); - -// Test check subclasses -class ProjectCheck extends BaseCheck { - constructor(id, opts = {}) { - super({ - id, - name: opts.name || `Check ${id}`, - domain: CheckDomain.PROJECT, - severity: opts.severity || CheckSeverity.MEDIUM, - healingTier: opts.healingTier || 0, - tags: opts.tags || [], - }); +const BUILT_IN_CHECK_MODULES = [ + '../../../.aiox-core/core/health-check/checks/project', + '../../../.aiox-core/core/health-check/checks/local', + '../../../.aiox-core/core/health-check/checks/repository', + '../../../.aiox-core/core/health-check/checks/deployment', + '../../../.aiox-core/core/health-check/checks/services', +]; + +function loadFreshRegistryModules() { + jest.resetModules(); + + for (const modulePath of BUILT_IN_CHECK_MODULES) { + jest.doMock(modulePath, () => ({}), { virtual: true }); } - async execute() { return this.pass('ok'); } -} -class LocalCheck extends BaseCheck { - constructor(id, opts = {}) { - super({ - id, - name: opts.name || `Check ${id}`, - domain: CheckDomain.LOCAL, - severity: opts.severity || CheckSeverity.LOW, - healingTier: opts.healingTier || 0, - tags: opts.tags || [], - }); - } - async execute() { return this.pass('ok'); } + let loadedModules; + jest.isolateModules(() => { + const baseCheck = require('../../../.aiox-core/core/health-check/base-check'); + const CheckRegistry = require('../../../.aiox-core/core/health-check/check-registry'); + loadedModules = { ...baseCheck, CheckRegistry }; + }); + + return loadedModules; } describe('check-registry', () => { + let BaseCheck; + let CheckSeverity; + let CheckDomain; + let CheckRegistry; + let ProjectCheck; + let LocalCheck; let registry; beforeEach(() => { + ({ BaseCheck, CheckSeverity, CheckDomain, CheckRegistry } = loadFreshRegistryModules()); + + ProjectCheck = class extends BaseCheck { + constructor(id, opts = {}) { + super({ + id, + name: opts.name || `Check ${id}`, + domain: CheckDomain.PROJECT, + severity: opts.severity || CheckSeverity.MEDIUM, + healingTier: opts.healingTier || 0, + tags: opts.tags || [], + }); + } + + async execute() { + return this.pass('ok'); + } + }; + + LocalCheck = class extends BaseCheck { + constructor(id, opts = {}) { + super({ + id, + name: opts.name || `Check ${id}`, + domain: CheckDomain.LOCAL, + severity: opts.severity || CheckSeverity.LOW, + healingTier: opts.healingTier || 0, + tags: opts.tags || [], + }); + } + + async execute() { + return this.pass('ok'); + } + }; + registry = new CheckRegistry(); }); @@ -68,7 +95,7 @@ describe('check-registry', () => { test('throws for duplicate id', () => { registry.register(new ProjectCheck('dup')); - expect(() => registry.register(new ProjectCheck('dup'))).toThrow("already registered"); + expect(() => registry.register(new ProjectCheck('dup'))).toThrow('already registered'); }); test('indexes check by domain', () => { diff --git a/tests/installer/pro-setup-auth.test.js b/tests/installer/pro-setup-auth.test.js index d42ac156a5..2da8173c43 100644 --- a/tests/installer/pro-setup-auth.test.js +++ b/tests/installer/pro-setup-auth.test.js @@ -8,6 +8,7 @@ 'use strict'; const proSetup = require('../../packages/installer/src/wizard/pro-setup'); +const { generateMachineId: generateRuntimeMachineId } = require('../../pro/license/license-crypto'); describe('pro-setup auth constants', () => { it('should export EMAIL_PATTERN', () => { @@ -144,5 +145,221 @@ describe('pro-setup backward compatibility (AC-7)', () => { expect(typeof proSetup._testing.waitForEmailVerification).toBe('function'); expect(typeof proSetup._testing.activateProByAuth).toBe('function'); expect(typeof proSetup._testing.stepLicenseGateCI).toBe('function'); + expect(typeof proSetup._testing.fallbackAuthWithoutBuyerCheck).toBe('function'); + expect(typeof proSetup._testing.generateMachineId).toBe('function'); + expect(typeof proSetup._testing.persistLicenseCache).toBe('function'); + }); +}); + +describe('pro-setup interactive email fallback', () => { + afterEach(() => { + proSetup._testing.loadLicenseApi = undefined; + }); + + it('should continue with direct auth when buyer pre-check is unavailable', async () => { + const inquirer = require('inquirer'); + const originalPrompt = inquirer.prompt; + const mockClient = { + isOnline: jest.fn().mockResolvedValue(true), + checkEmail: jest.fn().mockRejectedValue(new Error('Buyer validation service unavailable')), + login: jest.fn().mockResolvedValue({ + sessionToken: 'session-token', + emailVerified: true, + }), + validate: jest.fn().mockResolvedValue({ + valid: true, + features: ['pro'], + seats: { used: 1, max: 3 }, + cacheValidDays: 30, + gracePeriodDays: 7, + }), + activate: jest.fn(), + activateByAuth: jest.fn().mockResolvedValue({ + key: 'PRO-ABCD-1234-5678-WXYZ', + features: ['pro'], + seats: { used: 1, max: 3 }, + cacheValidDays: 30, + gracePeriodDays: 7, + }), + }; + + proSetup._testing.loadLicenseApi = () => ({ + LicenseApiClient: jest.fn().mockReturnValue(mockClient), + }); + + inquirer.prompt = jest.fn() + .mockResolvedValueOnce({ email: 'buyer@example.com' }) + .mockResolvedValueOnce({ password: 'Password123' }); + + try { + const result = await proSetup._testing.stepLicenseGateWithEmail(); + + expect(result.success).toBe(true); + expect(mockClient.checkEmail).toHaveBeenCalledWith('buyer@example.com'); + expect(mockClient.login).toHaveBeenCalledWith('buyer@example.com', 'Password123'); + expect(mockClient.activateByAuth).toHaveBeenCalled(); + } finally { + inquirer.prompt = originalPrompt; + } + }); +}); + +describe('pro-setup machine id compatibility', () => { + it('should generate a 64-char machine id for backend requests', () => { + const machineId = proSetup._testing.generateMachineId(); + + expect(machineId).toMatch(/^[a-f0-9]{64}$/i); + }); + + it('should match the Pro runtime machine id derivation', () => { + const wizardMachineId = proSetup._testing.generateMachineId(); + const runtimeMachineId = generateRuntimeMachineId(); + + expect(wizardMachineId).toBe(runtimeMachineId); + }); + + it('should pass a 64-char machine id when activating via auth', async () => { + const client = { + activateByAuth: jest.fn().mockResolvedValue({ + key: 'PRO-ABCD-1234-5678-WXYZ', + features: ['pro.squads.*'], + seats: { used: 1, max: 3 }, + cacheValidDays: 30, + gracePeriodDays: 7, + }), + validate: jest.fn().mockResolvedValue({ + valid: true, + features: ['pro.squads.*'], + seats: { used: 1, max: 3 }, + cacheValidDays: 30, + gracePeriodDays: 7, + }), + activate: jest.fn(), + }; + + const result = await proSetup._testing.activateProByAuth(client, 'session-token'); + const [, machineId] = client.activateByAuth.mock.calls[0]; + + expect(result.success).toBe(true); + expect(machineId).toMatch(/^[a-f0-9]{64}$/i); + expect(client.validate).toHaveBeenCalledWith('PRO-ABCD-1234-5678-WXYZ', machineId); + expect(client.activate).not.toHaveBeenCalled(); + }); + + it('should backfill key activation when auth activation is not yet validatable', async () => { + let observedMachineId; + const client = { + activateByAuth: jest.fn().mockResolvedValue({ + key: 'PRO-ABCD-1234-5678-WXYZ', + features: ['pro.squads.*'], + seats: { used: 1, max: 3 }, + cacheValidDays: 30, + gracePeriodDays: 7, + }), + validate: jest.fn().mockRejectedValue({ + code: 'MACHINE_NOT_ACTIVATED', + message: 'This machine is not activated for this license', + }), + activate: jest.fn().mockImplementation((key, machineId) => { + observedMachineId = machineId; + return Promise.resolve({ + key, + features: ['pro.squads.*', 'pro.memory.*'], + seats: { used: 1, max: 3 }, + cacheValidDays: 30, + gracePeriodDays: 7, + }); + }), + }; + + const result = await proSetup._testing.activateProByAuth(client, 'session-token'); + + expect(result.success).toBe(true); + expect(observedMachineId).toMatch(/^[a-f0-9]{64}$/i); + expect(client.activate).toHaveBeenCalledWith( + 'PRO-ABCD-1234-5678-WXYZ', + observedMachineId, + expect.any(String), + ); + expect(result.activationResult.features).toEqual(['pro.squads.*', 'pro.memory.*']); + }); + + it('should pass a 64-char machine id in license-key activation flow', async () => { + let observedMachineId; + const mockLicenseApi = { + LicenseApiClient: jest.fn().mockReturnValue({ + isOnline: jest.fn().mockResolvedValue(true), + activate: jest.fn().mockImplementation((key, machineId) => { + observedMachineId = machineId; + return Promise.resolve({ + key, + features: ['pro.squads.*'], + seats: { used: 1, max: 3 }, + cacheValidDays: 30, + gracePeriodDays: 7, + }); + }), + syncPendingDeactivation: jest.fn().mockResolvedValue(false), + }), + }; + + proSetup._testing.loadLicenseApi = () => mockLicenseApi; + + const result = await proSetup._testing.validateKeyWithApi('PRO-ABCD-1234-5678-WXYZ'); + + expect(result.success).toBe(true); + expect(observedMachineId).toMatch(/^[a-f0-9]{64}$/i); + + proSetup._testing.loadLicenseApi = undefined; + }); +}); + +describe('pro-setup license cache persistence', () => { + afterEach(() => { + proSetup._testing.loadLicenseCache = undefined; + }); + + it('should persist the activated license into the target project cache', () => { + const writeLicenseCache = jest.fn().mockReturnValue({ success: true }); + proSetup._testing.loadLicenseCache = () => ({ writeLicenseCache }); + + const result = proSetup._testing.persistLicenseCache('/tmp/aiox-pro-target', { + success: true, + key: 'PRO-ABCD-1234-5678-WXYZ', + activationResult: { + activatedAt: '2026-04-15T12:00:00.000Z', + expiresAt: '2027-04-15T12:00:00.000Z', + features: ['pro.squads.*'], + seats: { used: 1, max: 3 }, + cacheValidDays: 30, + gracePeriodDays: 7, + }, + }); + + expect(result).toEqual({ success: true }); + expect(writeLicenseCache).toHaveBeenCalledWith({ + key: 'PRO-ABCD-1234-5678-WXYZ', + activatedAt: '2026-04-15T12:00:00.000Z', + expiresAt: '2027-04-15T12:00:00.000Z', + features: ['pro.squads.*'], + seats: { used: 1, max: 3 }, + cacheValidDays: 30, + gracePeriodDays: 7, + }, '/tmp/aiox-pro-target'); + }); + + it('should fail when no concrete license key is available to persist', () => { + const writeLicenseCache = jest.fn(); + proSetup._testing.loadLicenseCache = () => ({ writeLicenseCache }); + + const result = proSetup._testing.persistLicenseCache('/tmp/aiox-pro-target', { + success: true, + key: 'existing', + activationResult: { reactivation: true }, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Activated license key not available'); + expect(writeLicenseCache).not.toHaveBeenCalled(); }); }); diff --git a/tests/synapse/synapse-memory-provider.test.js b/tests/synapse/synapse-memory-provider.test.js index c83d7464f3..a31f33e3d1 100644 --- a/tests/synapse/synapse-memory-provider.test.js +++ b/tests/synapse/synapse-memory-provider.test.js @@ -10,28 +10,29 @@ jest.setTimeout(10000); -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -const mockQueryMemories = jest.fn(() => Promise.resolve([])); - -jest.mock('../../pro/memory/memory-loader', () => ({ - MemoryLoader: jest.fn().mockImplementation(() => ({ - queryMemories: mockQueryMemories, - })), -}), { virtual: true }); - -// --------------------------------------------------------------------------- -// Import (after mocks) -// --------------------------------------------------------------------------- +let mockQueryMemories; +let SynapseMemoryProvider; +let AGENT_SECTOR_PREFERENCES; +let BRACKET_CONFIG; +let DEFAULT_SECTORS; + +function loadProviderModule() { + jest.resetModules(); + mockQueryMemories = jest.fn(() => Promise.resolve([])); + + jest.doMock('../../pro/memory/memory-loader', () => ({ + MemoryLoader: jest.fn().mockImplementation(() => ({ + queryMemories: mockQueryMemories, + })), + }), { virtual: true }); + + let loadedModule; + jest.isolateModules(() => { + loadedModule = require('../../.aiox-core/core/synapse/memory/synapse-memory-provider'); + }); -const { - SynapseMemoryProvider, - AGENT_SECTOR_PREFERENCES, - BRACKET_CONFIG, - DEFAULT_SECTORS, -} = require('../../.aiox-core/core/synapse/memory/synapse-memory-provider'); + return loadedModule; +} // ============================================================================= // SynapseMemoryProvider @@ -41,8 +42,12 @@ describe('SynapseMemoryProvider', () => { let provider; beforeEach(() => { - mockQueryMemories.mockReset(); - mockQueryMemories.mockResolvedValue([]); + ({ + SynapseMemoryProvider, + AGENT_SECTOR_PREFERENCES, + BRACKET_CONFIG, + DEFAULT_SECTORS, + } = loadProviderModule()); provider = new SynapseMemoryProvider(); });