diff --git a/e2e-tests/dependency-sync.test.ts b/e2e-tests/dependency-sync.test.ts new file mode 100644 index 000000000..3d30a227c --- /dev/null +++ b/e2e-tests/dependency-sync.test.ts @@ -0,0 +1,450 @@ +import { parseJsonOutput, spawnAndCollect } from '../src/test-utils/index.js'; +import { + baseCanRun as canRun, + hasAws, + installCdkTarball, + runAgentCoreCLI, + teardownE2EProject, + writeAwsTargets, +} from './e2e-helper.js'; +import { dumpFailureContext } from './utils/failure-context.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * E2E tests for the managed-dependency sync deploy preflight. + * + * The unit tests in src/lib/dependency-management/__tests__/ cover the sync logic with the npm + * subprocess mocked. These tests exercise the wiring through the real CLI binary — option + * passing, JSON envelope shape, exit codes — and (test 1 only) a REAL `npm install` against a + * real node_modules, surviving a real CDK deploy. Only test 1 creates a stack; tests 2-4 stop + * before any CloudFormation work happens. + * + * Each suite is fully independent (own project, own beforeAll/afterAll) per the e2e convention. + */ + +/** The managed dependency these tests manipulate. Verified against the vended manifest at runtime. */ +const MANAGED_DEP = 'constructs'; +/** Second managed dep migrated in test 1 (also tilde-pinned in the vended asset). */ +const SECOND_MANAGED_DEP = 'aws-cdk-lib'; +/** User-owned dependency the sync must never touch. */ +const USER_DEP = 'lodash'; +const USER_DEP_SPEC = '^4.17.21'; + +/** Shape of the dependency-sync outcome inside the `deploy --json` envelope (DependencySyncResult). */ +interface DepSyncJson { + outcome: 'synced' | 'check-only' | 'opted-out' | 'skipped' | 'failure-suppressed' | 'failed'; + optedOut: boolean; + checkOnly: boolean; + migratedFromCaret: boolean; + reinstalled: boolean; + skewWarning: boolean; + changes: { name: string; section: string; from: string; to: string }[]; + restored: { name: string; section: string; to: string }[]; + skipped: { name: string; raw: string; reason: string }[]; + warnings: string[]; + notice: string | null; +} + +interface DeployJsonEnvelope { + success: boolean; + error?: string; + errorName?: string; + dependencySyncResult?: DepSyncJson; +} + +interface CdkManifest { + dependencies?: Record; + devDependencies?: Record; + [key: string]: unknown; +} + +function cdkPackageJsonPath(projectPath: string): string { + return join(projectPath, 'agentcore', 'cdk', 'package.json'); +} + +async function readCdkManifest(projectPath: string): Promise<{ manifest: CdkManifest; raw: string }> { + const raw = await readFile(cdkPackageJsonPath(projectPath), 'utf-8'); + return { manifest: JSON.parse(raw) as CdkManifest, raw }; +} + +/** Read-modify-write one dependency specifier in agentcore/cdk/package.json. */ +async function setManagedDepVersion(projectPath: string, name: string, spec: string): Promise { + const { manifest } = await readCdkManifest(projectPath); + manifest.dependencies ??= {}; + manifest.dependencies[name] = spec; + await writeFile(cdkPackageJsonPath(projectPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); +} + +/** Extract the base major.minor.patch from a tilde/caret/exact specifier. */ +function baseVersionOf(spec: string): { major: number; minor: number; patch: number } { + const match = /^[~^]?(\d+)\.(\d+)\.(\d+)/.exec(spec); + if (!match) throw new Error(`Cannot parse managed dependency specifier "${spec}"`); + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }; +} + +/** A version strictly lower than the given spec's base — a routine in-range upgrade, never skew. */ +function lowerVersionOf(spec: string): string { + const v = baseVersionOf(spec); + if (v.patch > 0) return `${v.major}.${v.minor}.${v.patch - 1}`; + if (v.minor > 0) return `${v.major}.${v.minor - 1}.0`; + return `${v.major - 1}.0.0`; +} + +/** A higher-minor version than the given spec's base — the newer-than-CLI skew case. */ +function higherMinorVersionOf(spec: string): string { + const v = baseVersionOf(spec); + return `${v.major}.${v.minor + 2}.0`; +} + +/** Rewrite any specifier to a caret range over the same base (simulates a pre-pinning project). */ +function toCaret(spec: string): string { + return `^${spec.replace(/^[~^]/, '')}`; +} + +/** + * Scaffold a real project the way createE2ESuite does: `create` → write aws-targets.json → + * install the CDK tarball override when CDK_TARBALL is set (CI builds). + */ +async function scaffoldProject(testDir: string, agentName: string): Promise { + const result = await runAgentCoreCLI( + [ + 'create', + '--name', + agentName, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir + ); + expect(result.exitCode, `Create failed: stderr=${result.stderr}\n\nstdout=${result.stdout}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { projectPath: string }; + await writeAwsTargets(json.projectPath); + installCdkTarball(json.projectPath); + return json.projectPath; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 1 — caret migration + real npm install + user-dep preservation +// The only test in this suite that performs a full real deploy (and destroy). +// ───────────────────────────────────────────────────────────────────────────── + +describe.sequential('e2e: dependency sync — caret migration survives a real deploy', () => { + let testDir: string; + let projectPath: string; + const agentName = `E2eDepSyncMig${randomUUID().replace(/-/g, '').slice(0, 8)}`; + let vendedManagedSpec: string; + let vendedSecondSpec: string; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-depsync-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + projectPath = await scaffoldProject(testDir, agentName); + + // Capture the vended specifiers BEFORE editing — they are the sync's source of truth. + const { manifest } = await readCdkManifest(projectPath); + vendedManagedSpec = manifest.dependencies?.[MANAGED_DEP] ?? ''; + vendedSecondSpec = manifest.dependencies?.[SECOND_MANAGED_DEP] ?? ''; + expect(vendedManagedSpec, `${MANAGED_DEP} should be a managed (vended) dependency`).toMatch(/^~/); + expect(vendedSecondSpec, `${SECOND_MANAGED_DEP} should be a managed (vended) dependency`).toMatch(/^~/); + + // Simulate a pre-pinning project: caret ranges on managed deps (one on a lower base, + // one on the same base) plus a user-owned dependency the CLI doesn't manage. + await setManagedDepVersion(projectPath, MANAGED_DEP, `^${lowerVersionOf(vendedManagedSpec)}`); + await setManagedDepVersion(projectPath, SECOND_MANAGED_DEP, toCaret(vendedSecondSpec)); + await setManagedDepVersion(projectPath, USER_DEP, USER_DEP_SPEC); + }, 300000); + + // Always destroy AWS resources — never skip this + afterAll(async () => { + if (projectPath && hasAws) { + await teardownE2EProject(projectPath, agentName, 'Bedrock'); + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600000); + + it.skipIf(!canRun)( + 'migrates caret pins, reinstalls, preserves user deps, and deploys', + async () => { + expect(projectPath, 'Project should have been created').toBeTruthy(); + + const result = await runAgentCoreCLI(['deploy', '--yes', '--json'], projectPath); + if (result.exitCode !== 0) { + await dumpFailureContext({ + label: 'deploy (dependency-sync migration)', + result, + cwd: projectPath, + stackName: `AgentCore-${agentName}-default`, + }); + } + expect(result.exitCode, `Deploy failed (stderr: ${result.stderr}, stdout: ${result.stdout})`).toBe(0); + + const json = parseJsonOutput(result.stdout) as DeployJsonEnvelope; + expect(json.success, 'Deploy should report success').toBe(true); + + // The sync outcome rides on the JSON envelope as dependencySyncResult. + const sync = json.dependencySyncResult; + expect(sync, 'Deploy result should carry dependencySyncResult').toBeDefined(); + expect(sync!.migratedFromCaret, 'Caret ranges should be detected as a pre-pinning migration').toBe(true); + expect(sync!.reinstalled, 'npm install should have run to reconcile the rewritten manifest').toBe(true); + expect(sync!.notice, 'Migration should produce a user-facing notice').toBeTruthy(); + const changedNames = sync!.changes.map(c => c.name); + expect(changedNames).toContain(MANAGED_DEP); + expect(changedNames).toContain(SECOND_MANAGED_DEP); + + // Managed deps are back on the vended specifiers — no more carets. + const { manifest } = await readCdkManifest(projectPath); + expect(manifest.dependencies?.[MANAGED_DEP]).toBe(vendedManagedSpec); + expect(manifest.dependencies?.[SECOND_MANAGED_DEP]).toBe(vendedSecondSpec); + + // The user-owned dependency is untouched at its original range. + expect(manifest.dependencies?.[USER_DEP]).toBe(USER_DEP_SPEC); + + // Spot-check the real installed tree: node_modules/constructs resolves within the pinned range. + const installedRaw = await readFile( + join(projectPath, 'agentcore', 'cdk', 'node_modules', MANAGED_DEP, 'package.json'), + 'utf-8' + ); + const installed = baseVersionOf((JSON.parse(installedRaw) as { version: string }).version); + const pinned = baseVersionOf(vendedManagedSpec); + expect(installed.major, `${MANAGED_DEP} major should match the pinned range`).toBe(pinned.major); + expect(installed.minor, `${MANAGED_DEP} minor should match the tilde-pinned range`).toBe(pinned.minor); + expect(installed.patch, `${MANAGED_DEP} patch should satisfy the tilde-pinned range`).toBeGreaterThanOrEqual( + pinned.patch + ); + }, + 600000 + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test 2 — in-range lower version becomes a routine upgrade, reported by --dry-run +// Cheap: reaches the sync step and synth, but never bootstraps or deploys. +// ───────────────────────────────────────────────────────────────────────────── + +describe.sequential('e2e: dependency sync — dry-run reports an in-range upgrade without writing', () => { + let testDir: string; + let projectPath: string; + const agentName = `E2eDepSyncUp${randomUUID().replace(/-/g, '').slice(0, 8)}`; + let vendedManagedSpec: string; + let staleSpec: string; + let rawAfterEdit: string; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-depsync-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + projectPath = await scaffoldProject(testDir, agentName); + + const { manifest } = await readCdkManifest(projectPath); + vendedManagedSpec = manifest.dependencies?.[MANAGED_DEP] ?? ''; + expect(vendedManagedSpec, `${MANAGED_DEP} should be a managed (vended) dependency`).toMatch(/^~/); + + // Lower-than-vended base version: an ordinary upgrade on next deploy, NOT skew. + staleSpec = `~${lowerVersionOf(vendedManagedSpec)}`; + await setManagedDepVersion(projectPath, MANAGED_DEP, staleSpec); + rawAfterEdit = (await readCdkManifest(projectPath)).raw; + }, 300000); + + afterAll(async () => { + // No stack was ever created — only the local project needs cleaning up. + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 30000); + + it.skipIf(!canRun)( + 'dry-run reports the pending version bump and leaves package.json unchanged', + async () => { + expect(projectPath, 'Project should have been created').toBeTruthy(); + + const result = await runAgentCoreCLI(['deploy', '--dry-run', '--yes', '--json'], projectPath); + expect(result.exitCode, `Dry-run failed (stderr: ${result.stderr}, stdout: ${result.stdout})`).toBe(0); + + const json = parseJsonOutput(result.stdout) as DeployJsonEnvelope; + expect(json.success).toBe(true); + + const sync = json.dependencySyncResult; + expect(sync, 'Dry-run result should carry dependencySyncResult').toBeDefined(); + expect(sync!.checkOnly, 'Preview mode should run the sync check-only').toBe(true); + expect(sync!.reinstalled).toBe(false); + expect(sync!.changes).toContainEqual({ + name: MANAGED_DEP, + section: 'dependencies', + from: staleSpec, + to: vendedManagedSpec, + }); + expect(sync!.notice, 'Dry-run should report the pending bump in the notice').toBeTruthy(); + expect(sync!.notice).toContain(staleSpec); + expect(sync!.notice).toContain(vendedManagedSpec); + + // The whole preview-mode design hinges on this: --dry-run must never mutate the working tree. + const { raw } = await readCdkManifest(projectPath); + expect(raw, 'package.json must be untouched by --dry-run').toBe(rawAfterEdit); + }, + 300000 + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test 3 — newer-than-CLI skew blocks a real deploy with the upgrade-CLI error +// Cheap: CliVersionTooOldError is thrown inside the sync step itself, before the +// CDK build/synth ever run — no stack is created. Note this must be a REAL deploy +// (not --dry-run): preview mode deliberately downgrades skew to a warning, so the +// hard failure is only observable on a mutating deploy. +// ───────────────────────────────────────────────────────────────────────────── + +describe.sequential('e2e: dependency sync — newer-than-CLI skew fails fast with the upgrade error', () => { + let testDir: string; + let projectPath: string; + const agentName = `E2eDepSyncSkw${randomUUID().replace(/-/g, '').slice(0, 8)}`; + let rawAfterEdit: string; + // Defensive: if the skew guard ever regresses and the deploy goes through, tear the stack down. + let deployedUnexpectedly = false; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-depsync-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + projectPath = await scaffoldProject(testDir, agentName); + + const { manifest } = await readCdkManifest(projectPath); + const vendedManagedSpec = manifest.dependencies?.[MANAGED_DEP] ?? ''; + expect(vendedManagedSpec, `${MANAGED_DEP} should be a managed (vended) dependency`).toMatch(/^~/); + + // Higher minor than the vended pin: the project was touched by a newer CLI. + await setManagedDepVersion(projectPath, MANAGED_DEP, `~${higherMinorVersionOf(vendedManagedSpec)}`); + rawAfterEdit = (await readCdkManifest(projectPath)).raw; + }, 300000); + + afterAll(async () => { + if (deployedUnexpectedly && projectPath && hasAws) { + await teardownE2EProject(projectPath, agentName, 'Bedrock'); + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600000); + + it.skipIf(!canRun)( + 'deploy exits 1 with the upgrade-CLI error and never writes package.json', + async () => { + expect(projectPath, 'Project should have been created').toBeTruthy(); + + const result = await runAgentCoreCLI(['deploy', '--yes', '--json'], projectPath); + deployedUnexpectedly = result.exitCode === 0; + + expect(result.exitCode, `Skewed deploy should fail (stdout: ${result.stdout})`).toBe(1); + + const json = parseJsonOutput(result.stdout) as DeployJsonEnvelope; + expect(json.success).toBe(false); + // Distinctive substrings from formatCliUpgradeError, not the full copy. + expect(json.error).toContain('requires a newer version of the AgentCore CLI'); + expect(json.error).toContain(MANAGED_DEP); + expect(json.errorName).toBe('CliVersionTooOldError'); + // The failure envelope still carries the sync outcome (dep_sync_* telemetry rides on it). + expect(json.dependencySyncResult?.outcome).toBe('failed'); + + // Skew is detected before anything is written — the manifest must be untouched. + const { raw } = await readCdkManifest(projectPath); + expect(raw, 'package.json must be untouched by a skew failure').toBe(rawAfterEdit); + }, + 180000 + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test 4 — the global opt-out config reaches the sync through the real CLI +// Cheap: same shape as test 2 (--dry-run, never deploys). Uses an isolated +// AGENTCORE_CONFIG_DIR so the `agentcore config` write can't leak into the +// machine's real global config. NOTE: because this is a --dry-run, check mode +// alone would already downgrade skew to a warning — the opt-out-specific signal +// here is `outcome: 'opted-out'` / `optedOut: true` in the JSON envelope. The +// behavioral downgrade (opt-out turning a would-be CliVersionTooOldError into a +// warning on a REAL deploy) is covered by the unit test "downgrades skew to a +// warning and touches nothing when disabled" in +// src/lib/dependency-management/__tests__/sync.test.ts. +// ───────────────────────────────────────────────────────────────────────────── + +describe.sequential('e2e: dependency sync — global opt-out config is plumbed through to the sync', () => { + let testDir: string; + let projectPath: string; + let configDir: string; + const agentName = `E2eDepSyncOpt${randomUUID().replace(/-/g, '').slice(0, 8)}`; + let rawAfterEdit: string; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-depsync-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + projectPath = await scaffoldProject(testDir, agentName); + + // Opt out of dependency management, isolated from the real ~/.agentcore. + configDir = join(testDir, 'agentcore-global-config'); + const configResult = await spawnAndCollect( + 'agentcore', + ['config', 'disableDependencyManagement', 'true'], + projectPath, + { AGENTCORE_CONFIG_DIR: configDir } + ); + expect(configResult.exitCode, `config set failed: ${configResult.stderr}`).toBe(0); + expect(configResult.stdout).toContain('Set disableDependencyManagement = true'); + + // Same higher-minor skew as test 3. + const { manifest } = await readCdkManifest(projectPath); + const vendedManagedSpec = manifest.dependencies?.[MANAGED_DEP] ?? ''; + expect(vendedManagedSpec, `${MANAGED_DEP} should be a managed (vended) dependency`).toMatch(/^~/); + await setManagedDepVersion(projectPath, MANAGED_DEP, `~${higherMinorVersionOf(vendedManagedSpec)}`); + rawAfterEdit = (await readCdkManifest(projectPath)).raw; + }, 300000); + + afterAll(async () => { + // No stack was ever created — only the local project needs cleaning up. + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 30000); + + it.skipIf(!canRun)( + 'sync reports opted-out, surfaces skew as a warning, and leaves package.json unchanged', + async () => { + expect(projectPath, 'Project should have been created').toBeTruthy(); + + const result = await spawnAndCollect('agentcore', ['deploy', '--dry-run', '--yes', '--json'], projectPath, { + AGENTCORE_CONFIG_DIR: configDir, + }); + expect(result.exitCode, `Opted-out deploy failed (stderr: ${result.stderr}, stdout: ${result.stdout})`).toBe(0); + + const json = parseJsonOutput(result.stdout) as DeployJsonEnvelope; + expect(json.success).toBe(true); + + const sync = json.dependencySyncResult; + expect(sync, 'Deploy result should carry dependencySyncResult').toBeDefined(); + // The opt-out-specific signal: the config write reached the sync through the real CLI. + // (This is a --dry-run, so check mode would surface skew as a warning even without the + // opt-out — outcome/optedOut is what proves the opt-out plumbing.) + expect(sync!.outcome, 'Opt-out should win over check mode in the outcome').toBe('opted-out'); + expect(sync!.optedOut, 'Global opt-out should be reflected in the sync result').toBe(true); + // In this mode skew is surfaced as a warning, not an error. + expect(sync!.skewWarning, 'Skew should be surfaced as a warning in this mode').toBe(true); + const warnings = sync!.warnings.join('\n'); + expect(warnings).toContain(MANAGED_DEP); + expect(warnings).toContain('newer than this CLI was tested with'); + expect(warnings).toContain('upgrade the CLI'); + + // Opted-out sync never writes. + const { raw } = await readCdkManifest(projectPath); + expect(raw, 'package.json must be untouched when opted out').toBe(rawAfterEdit); + }, + 300000 + ); +}); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9d26c978c..ddc45dc74 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.23.0", + "version": "0.24.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.23.0", + "version": "0.24.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -46,6 +46,7 @@ "ink-spinner": "^5.0.0", "js-yaml": "^4.1.1", "react": "^19.2.3", + "semver": "^7.8.5", "ws": "^8.21.0", "yaml": "^2.8.3", "zod": "^4.3.5" @@ -63,6 +64,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^25.0.3", "@types/react": "^19.2.7", + "@types/semver": "^7.7.1", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.50.0", "@typescript-eslint/parser": "^8.50.0", @@ -2102,17 +2104,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.20.tgz", - "integrity": "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==", + "version": "3.976.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz", + "integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.12", - "@aws-sdk/xml-builder": "^3.972.29", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.6", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.36", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.4", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -2120,38 +2122,13 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/core/node_modules/@aws-sdk/xml-builder": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz", - "integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==", + "node_modules/@aws-sdk/core/node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "fast-xml-parser": "5.5.8", - "tslib": "^2.6.2" - }, "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core/node_modules/fast-xml-parser": { - "version": "5.5.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", - "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.1.3", - "strnum": "^2.2.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" + "node": ">=18.0.0" } }, "node_modules/@aws-sdk/crc64-nvme": { @@ -2733,12 +2710,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.12.tgz", - "integrity": "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==", + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2837,6 +2814,20 @@ } } }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz", + "integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws/agent-inspector": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/@aws/agent-inspector/-/agent-inspector-0.6.1.tgz", @@ -2886,12 +2877,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -2900,29 +2891,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -2948,13 +2939,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -2964,13 +2955,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -2989,36 +2980,36 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -3028,52 +3019,52 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -3083,31 +3074,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -3115,13 +3106,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -4224,9 +4215,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4572,12 +4563,12 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", + "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4637,13 +4628,13 @@ } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", - "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.9.0.tgz", + "integrity": "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -5224,13 +5215,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz", - "integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==", + "version": "3.29.6", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.6.tgz", + "integrity": "sha512-TO3w25cdGWBeYqKNDaqH3v4O3jjMPpKwf39YlG5X5xhqWfpOWJbi5gQi1lrllukuwohdhY0TPB8jBEv6UC50Vg==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5608,13 +5598,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.6.tgz", - "integrity": "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==", + "version": "5.6.7", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.7.tgz", + "integrity": "sha512-32PmEsuZV9lz7SZk3gJcm+EfIAoIVu83AJyEzgALpwmSqLvuacdAu0fvCVNMbDbegyk1S0lHUDrMWIfR47Micw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@smithy/core": "^3.29.6", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5640,9 +5630,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz", - "integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6137,6 +6127,13 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -6943,6 +6940,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -7767,9 +7776,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", - "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz", + "integrity": "sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -7833,9 +7842,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -7857,9 +7866,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "funding": [ { "type": "opencollective", @@ -7876,11 +7885,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -7975,9 +7984,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "funding": [ { "type": "opencollective", @@ -8604,9 +8613,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.325", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", - "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "version": "1.5.394", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz", + "integrity": "sha512-Wmt2Gm0o8JWBuGgmc4XZ0u9s1RaCRqhxP47phplmfg04+qypTUurpeJGP45A7Fhv7jdrrVH44PLlR9qXo37cVQ==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -9664,9 +9673,25 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", + "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", "funding": [ { "type": "github", @@ -9675,8 +9700,12 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" } }, "node_modules/fastq": { @@ -11272,9 +11301,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -13187,10 +13226,13 @@ } }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "license": "MIT" + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-package-data": { "version": "8.0.0", @@ -13571,9 +13613,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", "funding": [ { "type": "github", @@ -13839,9 +13881,9 @@ } }, "node_modules/protobufjs": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.3.tgz", - "integrity": "sha512-alQyzT0j401LGBtwsqu6uprjR6pfNH1UJf9N6GBFMjIcd+HzTe0/HrjAbFCqun+zvnfLarrxAtMM2xvZ+kFZ5A==", + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.1.tgz", + "integrity": "sha512-agdGHrXNTv0IrYscJPDou/PlEJk1c/hBZ9o/B5NH2i/nSPtPqacNxzgwf1CebXxFMjMrZH5sqv9uQuw96aGt/A==", "license": "BSD-3-Clause", "dependencies": { "long": "^5.3.2" @@ -14497,9 +14539,9 @@ } }, "node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -15146,16 +15188,19 @@ } }, "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } }, "node_modules/structured-source": { "version": "4.0.0", @@ -16901,9 +16946,9 @@ } }, "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 7e6e23e97..220cbe756 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "ink-spinner": "^5.0.0", "js-yaml": "^4.1.1", "react": "^19.2.3", + "semver": "^7.8.5", "ws": "^8.21.0", "yaml": "^2.8.3", "zod": "^4.3.5" @@ -131,6 +132,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^25.0.3", "@types/react": "^19.2.7", + "@types/semver": "^7.7.1", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.50.0", "@typescript-eslint/parser": "^8.50.0", diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 38b414e55..798f0dd0f 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -636,18 +636,18 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/package.json should m "format:check": "prettier --check ." }, "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^24.10.1", - "jest": "^29.7.0", - "ts-jest": "^29.2.5", - "aws-cdk": "2.1126.0", - "prettier": "^3.4.2", + "@types/jest": "~29.5.14", + "@types/node": "~24.13.3", + "jest": "~29.7.0", + "ts-jest": "~29.4.11", + "aws-cdk": "~2.1126.0", + "prettier": "~3.9.5", "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "^0.1.0-alpha.19", - "aws-cdk-lib": "^2.248.0", - "constructs": "^10.0.0" + "@aws/agentcore-cdk": "0.1.0-alpha.45", + "aws-cdk-lib": "~2.261.0", + "constructs": "~10.7.0" } } " diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 4646bfb3e..550a52797 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -14,17 +14,17 @@ "format:check": "prettier --check ." }, "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^24.10.1", - "jest": "^29.7.0", - "ts-jest": "^29.2.5", - "aws-cdk": "2.1126.0", - "prettier": "^3.4.2", + "@types/jest": "~29.5.14", + "@types/node": "~24.13.3", + "jest": "~29.7.0", + "ts-jest": "~29.4.11", + "aws-cdk": "~2.1126.0", + "prettier": "~3.9.5", "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "^0.1.0-alpha.19", - "aws-cdk-lib": "^2.248.0", - "constructs": "^10.0.0" + "@aws/agentcore-cdk": "0.1.0-alpha.45", + "aws-cdk-lib": "~2.261.0", + "constructs": "~10.7.0" } } diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 841355934..d506cfb76 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,4 +1,12 @@ -import { ConfigIO, ResourceNotFoundError, SecureCredentials, ValidationError, toError } from '../../../lib'; +import { + ConfigIO, + DependencySyncError, + ResourceNotFoundError, + SecureCredentials, + ValidationError, + toError, +} from '../../../lib'; +import type { DependencySyncResult } from '../../../lib/dependency-management'; import type { Result } from '../../../lib/result'; import type { AgentCoreMcpSpec, DeployedState } from '../../../schema'; import { applyTargetRegionToEnv } from '../../aws'; @@ -26,6 +34,7 @@ import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; import { MANAGED_MEMORY_DEPLOY_NOTICE, + SYNC_CDK_DEPENDENCIES_STEP, assertEnvFileExists, backfillContainerVpcIds, bootstrapEnvironment, @@ -33,6 +42,8 @@ import { checkBootstrapNeeded, checkStackDeployability, ensureDefaultDeploymentTarget, + ensureManagedDependencies, + failedSyncResult, getAllCredentials, hasIdentityApiProviders, hasIdentityOAuthProviders, @@ -42,6 +53,7 @@ import { setupOAuth2Providers, setupTransactionSearch, synthesizeCdk, + teardownSyncFailureResult, validateProject, } from '../../operations/deploy'; import { computeProjectDeployHash } from '../../operations/deploy/change-detection'; @@ -67,7 +79,7 @@ export interface ValidatedDeployOptions { verbose?: boolean; plan?: boolean; diff?: boolean; - onProgress?: (step: string, status: 'start' | 'success' | 'error') => void; + onProgress?: (step: string, status: 'start' | 'success' | 'error' | 'warn') => void; onResourceEvent?: (message: string) => void; onDeployMessage?: (message: DeployMessage) => void; /** Emit a one-shot, user-facing notice to the terminal mid-deploy (e.g. the managed-memory heads-up @@ -168,6 +180,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { currentStepName = name; @@ -175,11 +191,28 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { + const endStep = (status: 'success' | 'error' | 'warn', message?: string) => { logger.endStep(status, message); onProgress?.(currentStepName, status); }; + /** + * Build the failure result every failed deploy must return: finalize the log, then attach + * `logPath` and the CURRENT `dependencySyncResult` (read from closure at call time — dep_sync_* + * telemetry must survive deploy failures). Centralizing the ritual makes it impossible for a + * failure site to forget one of these fields. Per-step `endStep('error', ...)` calls and any + * extra logging stay at the call sites, where the messages differ. + */ + const fail = (error: Error): Extract => { + logger.finalize(false); + return { + success: false, + error, + logPath: logger.getRelativeLogPath(), + dependencySyncResult, + }; + }; + try { const configIO = new ConfigIO(); @@ -194,12 +227,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise t.name === options.target); if (!target) { endStep('error', `Target "${options.target}" not found`); - logger.finalize(false); - return { - success: false, - error: new ResourceNotFoundError(`Target "${options.target}" not found in aws-targets.json`), - logPath: logger.getRelativeLogPath(), - }; + return fail(new ResourceNotFoundError(`Target "${options.target}" not found in aws-targets.json`)); } // Make the resolved target region authoritative for downstream SDK / CDK // calls that don't receive an explicit region option. @@ -244,14 +272,11 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise r.status === 'error' && r.error); const errorMsg = errorResult?.error?.message ?? 'Identity setup failed'; endStep('error', errorMsg); - logger.finalize(false); - - return { - success: false, - error: errorResult?.error ?? new Error('unknown error occurred when setting up api key providers'), - logPath: logger.getRelativeLogPath(), - }; + return fail(errorResult?.error ?? new Error('unknown error occurred when setting up api key providers')); } identityKmsKeyArn = identityResult.kmsKeyArn; @@ -346,13 +406,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise e.message).join('; '); endStep('error', errorMsgs); logger.log(`Payment credential setup errors: ${errorMsgs}`, 'error'); - logger.finalize(false); - return { - success: false, - error: paymentPreDeployResult.errors[0] ?? new Error('payment deploy preflight steps failed'), - logPath: logger.getRelativeLogPath(), - }; + return fail(paymentPreDeployResult.errors[0] ?? new Error('payment deploy preflight steps failed')); } // Merge payment credential provider ARNs into deployedCredentials (same as identity credentials) @@ -420,7 +469,6 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? allWarnings : undefined, + dependencySyncResult, }; } catch (err: unknown) { logger.log(getErrorMessage(err), 'error'); - logger.finalize(false); - return { success: false, error: toError(err), logPath: logger.getRelativeLogPath() }; + // fail() carries the dep-sync outcome on the failure result too, so dep_sync_* telemetry + // is recorded even when a later step throws. + return fail(toError(err)); } finally { // Each cleanup step must run regardless of whether an earlier one fails — a throw from // dispose() (common after a synth/bootstrap failure on a creds-less preview) must not skip the diff --git a/src/cli/commands/deploy/command.tsx b/src/cli/commands/deploy/command.tsx index 0af261125..b6c3fd525 100644 --- a/src/cli/commands/deploy/command.tsx +++ b/src/cli/commands/deploy/command.tsx @@ -1,6 +1,7 @@ import { ConfigIO, serializeResult } from '../../../lib'; -import { COMMAND_DESCRIPTIONS } from '../../constants'; +import { ANSI, COMMAND_DESCRIPTIONS } from '../../constants'; import { getErrorMessage } from '../../errors'; +import { toDepSyncAttrs } from '../../operations/deploy'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { renderTUI } from '../../tui'; import { requireProject, requireTTY } from '../../tui/guards'; @@ -42,10 +43,15 @@ async function handleDeployCLI(options: DeployOptions): Promise { .then(spec => computeDeployAttrs(spec, mode)) .catch(() => ({ ...DEFAULT_DEPLOY_ATTRS, mode }) as const); - const { deployResult } = await withCommandRunTelemetry('deploy', attrs, async () => { + const { deployResult } = await withCommandRunTelemetry('deploy', attrs, async recorder => { const result = await executeDeploy(options).catch( (e): DeployResult => ({ success: false, error: e instanceof Error ? e : new Error(getErrorMessage(e)) }) ); + // Record dep_sync attrs whenever the sync ran — including on failed deploys, where the + // failure result still carries the outcome (see handleDeploy's catch). + if (result.dependencySyncResult) { + recorder.set(toDepSyncAttrs(result.dependencySyncResult)); + } if (!result.success) { return { success: false as const, error: result.error, deployResult: result }; } @@ -57,6 +63,13 @@ async function handleDeployCLI(options: DeployOptions): Promise { if (options.json) { console.log(JSON.stringify(serializeResult(deployResult))); } else { + // Dependency sync warnings still matter on a failed deploy: a downgraded-skew + // warning may explain the failure itself, and printDeployResult only runs on success. + // (The sync notice is NOT re-printed here — onNotice already printed it live during the + // sync step for every non-JSON run.) + for (const warning of deployResult.dependencySyncResult?.warnings ?? []) { + console.error(`⚠ ${warning}`); + } console.error(deployResult.error.message); if (deployResult.logPath) { console.error(`Log: ${deployResult.logPath}`); @@ -78,7 +91,7 @@ async function executeDeploy(options: DeployOptions): Promise { // Progress callback for --progress mode const onProgress = options.progress - ? (step: string, status: 'start' | 'success' | 'error') => { + ? (step: string, status: 'start' | 'success' | 'error' | 'warn') => { if (spinner) { clearInterval(spinner); process.stdout.write('\r\x1b[K'); // Clear line @@ -93,6 +106,8 @@ async function executeDeploy(options: DeployOptions): Promise { }, 80); } else if (status === 'success') { console.log(`✓ ${step}`); + } else if (status === 'warn') { + console.log(`${ANSI.yellow}⚠ ${step}${ANSI.reset}`); } else { console.log(`✗ ${step}`); } @@ -146,6 +161,15 @@ function printDeployResult(result: DeployResult & { success: true }, options: De return; } + // Dependency sync warnings: downgraded skew, skipped specifiers. Informational + // only — they must reach the terminal but must NOT flip the exit code to 2 (the bundled-tarball + // override in e2e/dev builds is always "left unmanaged", and exit 2 would fail every deploy). + if (result.dependencySyncResult?.warnings && result.dependencySyncResult.warnings.length > 0) { + for (const warning of result.dependencySyncResult.warnings) { + console.warn(`⚠ ${warning}`); + } + } + if (options.diff) { console.log(`\n✓ Diff complete for '${result.targetName}' (stack: ${result.stackName})`); } else if (options.plan) { diff --git a/src/cli/commands/deploy/progress.ts b/src/cli/commands/deploy/progress.ts index da7eb7b3c..d37ec368c 100644 --- a/src/cli/commands/deploy/progress.ts +++ b/src/cli/commands/deploy/progress.ts @@ -8,7 +8,7 @@ import { handleDeploy } from './actions'; export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; export interface SpinnerProgress { - onProgress: (step: string, status: 'start' | 'success' | 'error') => void; + onProgress: (step: string, status: 'start' | 'success' | 'error' | 'warn') => void; cleanup: () => void; } @@ -23,7 +23,7 @@ export function createSpinnerProgress(): SpinnerProgress { } }; - const onProgress = (step: string, status: 'start' | 'success' | 'error') => { + const onProgress = (step: string, status: 'start' | 'success' | 'error' | 'warn') => { clearSpinner(); if (status === 'start') { @@ -35,6 +35,8 @@ export function createSpinnerProgress(): SpinnerProgress { }, 80); } else if (status === 'success') { console.log(`✓ ${step}`); + } else if (status === 'warn') { + console.log(`${ANSI.yellow}⚠ ${step}${ANSI.reset}`); } else { console.log(`✗ ${step}`); } @@ -68,6 +70,15 @@ export async function runCliDeploy(): Promise { }); cleanup(); + // Print the dependency sync notice and warnings — handleDeploy only writes them to the + // deploy log, and this dev path passes no onNotice. + if (result.dependencySyncResult?.notice) { + console.log(`\n${result.dependencySyncResult.notice}\n`); + } + for (const warning of result.dependencySyncResult?.warnings ?? []) { + console.warn(`${ANSI.yellow}⚠ ${warning}${ANSI.reset}`); + } + if (result.success) { console.log('Deploy complete.'); if (result.logPath) { diff --git a/src/cli/commands/deploy/types.ts b/src/cli/commands/deploy/types.ts index a29b6ef8e..d0df81a5e 100644 --- a/src/cli/commands/deploy/types.ts +++ b/src/cli/commands/deploy/types.ts @@ -1,3 +1,4 @@ +import type { DependencySyncResult } from '../../../lib/dependency-management'; import type { Result } from '../../../lib/result'; export interface DeployOptions { @@ -17,7 +18,11 @@ export type DeployResult = Result<{ nextSteps?: string[]; notes?: string[]; postDeployWarnings?: string[]; -}> & { logPath?: string }; +}> & { + logPath?: string; + /** Sync outcome rides on BOTH branches so dep_sync_* telemetry survives a failed deploy. */ + dependencySyncResult?: DependencySyncResult; +}; export type PreflightResult = Result<{ stackNames?: string[]; diff --git a/src/cli/logging/exec-logger.ts b/src/cli/logging/exec-logger.ts index cc708e86b..82f860088 100644 --- a/src/cli/logging/exec-logger.ts +++ b/src/cli/logging/exec-logger.ts @@ -137,21 +137,22 @@ ${separator} } /** - * Mark the end of the current step + * Mark the end of the current step. + * @param message Optional error or warning detail logged with the step outcome. */ - endStep(status: 'success' | 'error', error?: string): void { + endStep(status: 'success' | 'error' | 'warn', message?: string): void { if (!this.currentStep) { return; } const duration = Date.now() - this.currentStep.startTime; - const statusText = status === 'success' ? 'SUCCESS' : 'FAILED'; + const statusText = status === 'success' ? 'SUCCESS' : status === 'warn' ? 'WARNING' : 'FAILED'; if (status === 'error') { this.lastFailedStep = this.currentStep.name; - if (error) { - this.appendLine(`[${this.formatTime()}] Error: ${error}`); - } + } + if ((status === 'error' || status === 'warn') && message) { + this.appendLine(`[${this.formatTime()}] ${status === 'error' ? 'Error' : 'Warning'}: ${message}`); } this.appendLine(`[${this.formatTime()}] Status: ${statusText}`); diff --git a/src/cli/operations/deploy/__tests__/dependency-sync.test.ts b/src/cli/operations/deploy/__tests__/dependency-sync.test.ts new file mode 100644 index 000000000..cf60b3952 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/dependency-sync.test.ts @@ -0,0 +1,107 @@ +import type { DependencySyncResult } from '../../../../lib/dependency-management'; +import type { LocalCdkProject } from '../../../cdk/local-cdk-project'; +import { + ensureManagedDependencies, + failedSyncResult, + teardownSyncFailureResult, + toDepSyncAttrs, +} from '../dependency-sync'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockSyncManagedDependencies } = vi.hoisted(() => ({ + mockSyncManagedDependencies: vi.fn(), +})); + +vi.mock('../../../../lib/dependency-management', () => ({ + syncManagedDependencies: mockSyncManagedDependencies, +})); + +vi.mock('../../../../lib/schemas/io/global-config', () => ({ + readGlobalConfigSync: () => ({}), +})); + +/** Every field of the no-op base shape shared by failedSyncResult / teardownSyncFailureResult. */ +function expectNoOpShape(result: DependencySyncResult): void { + expect(result.optedOut).toBe(false); + expect(result.checkOnly).toBe(false); + expect(result.migratedFromCaret).toBe(false); + expect(result.reinstalled).toBe(false); + expect(result.skewWarning).toBe(false); + expect(result.changes).toEqual([]); + expect(result.restored).toEqual([]); + expect(result.skipped).toEqual([]); + expect(result.notice).toBeNull(); +} + +describe('failedSyncResult', () => { + it('is a no-op result with outcome "failed" and no warning line (the thrown error carries the message)', () => { + const result = failedSyncResult(); + expect(result.outcome).toBe('failed'); + expect(result.warnings).toEqual([]); + expectNoOpShape(result); + }); +}); + +describe('teardownSyncFailureResult', () => { + it('is a no-op result with outcome "failure-suppressed" and the failure surfaced as a warning', () => { + const result = teardownSyncFailureResult(new Error('npm install failed (exit 1): E404')); + expect(result.outcome).toBe('failure-suppressed'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('Dependency sync failed (continuing with teardown)'); + expect(result.warnings[0]).toContain('npm install failed (exit 1): E404'); + expectNoOpShape(result); + }); +}); + +describe('ensureManagedDependencies', () => { + const origSkipInstall = process.env.AGENTCORE_SKIP_INSTALL; + + afterEach(() => { + vi.clearAllMocks(); + if (origSkipInstall !== undefined) process.env.AGENTCORE_SKIP_INSTALL = origSkipInstall; + else delete process.env.AGENTCORE_SKIP_INSTALL; + }); + + it('skips the sync entirely when AGENTCORE_SKIP_INSTALL is set', async () => { + process.env.AGENTCORE_SKIP_INSTALL = '1'; + + const result = await ensureManagedDependencies({ projectDir: '/project/agentcore/cdk' } as LocalCdkProject); + + expect(result.outcome).toBe('skipped'); + expect(result.warnings).toEqual([]); + expectNoOpShape(result); + expect(mockSyncManagedDependencies).not.toHaveBeenCalled(); + }); +}); + +describe('toDepSyncAttrs', () => { + it('maps a sync result to dep_sync_* telemetry attrs, including the outcome', () => { + const result: DependencySyncResult = { + outcome: 'synced', + optedOut: false, + checkOnly: false, + migratedFromCaret: true, + reinstalled: true, + skewWarning: true, + changes: [{ name: 'aws-cdk-lib', section: 'dependencies', from: '^2.248.0', to: '~2.261.0' }], + restored: [{ name: 'constructs', section: 'dependencies', to: '~10.4.2' }], + skipped: [], + warnings: ['skew warning'], + notice: 'updated', + }; + + expect(toDepSyncAttrs(result)).toEqual({ + dep_sync_outcome: 'synced', + dep_sync_changed_count: 2, + dep_sync_migrated: true, + dep_sync_opted_out: false, + dep_sync_skew_warning: true, + dep_sync_reinstalled: true, + }); + }); + + it('maps every no-op outcome through dep_sync_outcome', () => { + expect(toDepSyncAttrs(failedSyncResult()).dep_sync_outcome).toBe('failed'); + expect(toDepSyncAttrs(teardownSyncFailureResult(new Error('boom'))).dep_sync_outcome).toBe('failure-suppressed'); + }); +}); diff --git a/src/cli/operations/deploy/dependency-sync.ts b/src/cli/operations/deploy/dependency-sync.ts new file mode 100644 index 000000000..02353101e --- /dev/null +++ b/src/cli/operations/deploy/dependency-sync.ts @@ -0,0 +1,108 @@ +import { syncManagedDependencies } from '../../../lib/dependency-management'; +import type { DependencySyncOutcome, DependencySyncResult } from '../../../lib/dependency-management'; +import { readGlobalConfigSync } from '../../../lib/schemas/io/global-config'; +import type { LocalCdkProject } from '../../cdk/local-cdk-project'; +import { getDistroConfig } from '../../constants'; +import { getTemplatePath } from '../../templates/templateRoot'; + +/** Step label for the managed-dependency sync, shared by the CLI and TUI deploy flows. */ +export const SYNC_CDK_DEPENDENCIES_STEP = 'Sync CDK dependencies'; + +export interface EnsureManagedDependenciesOptions { + /** + * Check-only run for preview modes (--dry-run / --diff): computes the plan, warnings, and a + * future-tense notice without writing package.json or running npm install. + */ + checkOnly?: boolean; + /** + * Teardown deploys: newer-than-CLI skew becomes a warning instead of CliVersionTooOldError, + * so skew never blocks destroying a cost-incurring stack. + */ + treatSkewAsWarning?: boolean; +} + +/** + * A DependencySyncResult in which no dependency operation took effect: nothing was + * written or installed. `outcome` says why the sync was a no-op. Base shape for the + * no-op states below. + */ +function noOpSyncResult(outcome: DependencySyncOutcome, warnings: string[] = []): DependencySyncResult { + return { + outcome, + optedOut: false, + checkOnly: false, + migratedFromCaret: false, + reinstalled: false, + skewWarning: false, + changes: [], + restored: [], + skipped: [], + warnings, + notice: null, + }; +} + +/** + * Minimal result attached to the failure path when the sync itself throws + * (CliVersionTooOldError, or DependencySyncError on a non-teardown deploy): nothing + * took effect, and dep_sync_* telemetry records that the sync was the failure. + * No warning line is added — the thrown error already carries the message. + */ +export function failedSyncResult(): DependencySyncResult { + return noOpSyncResult('failed'); +} + +/** + * Warning-only result for a DependencySyncError caught on a teardown deploy. Teardown must + * never be blocked by pinning: skew is already downgraded via `treatSkewAsWarning`, and this + * extends the same invariant to write/install failures (broken npm env, registry outage) — + * the failure is surfaced through the normal warnings channel and the teardown proceeds. If + * node_modules is genuinely unusable, the build step fails on its own. + */ +export function teardownSyncFailureResult(err: Error): DependencySyncResult { + return noOpSyncResult('failure-suppressed', [`Dependency sync failed (continuing with teardown): ${err.message}`]); +} + +/** + * Map a dependency sync result to its dep_sync_* telemetry attrs. + * Single source for both the CLI command (command.tsx) and the TUI flow (useDeployFlow). + */ +export function toDepSyncAttrs(sync: DependencySyncResult) { + return { + dep_sync_outcome: sync.outcome, + dep_sync_changed_count: sync.changes.length + sync.restored.length, + dep_sync_migrated: sync.migratedFromCaret, + dep_sync_opted_out: sync.optedOut, + dep_sync_skew_warning: sync.skewWarning, + dep_sync_reinstalled: sync.reinstalled, + }; +} + +/** + * Deploy-preflight entry point for managed dependency pinning: resolves the + * CLI's vended CDK package.json (source of truth), the global opt-out, and the + * distro-aware CLI install command, then runs the sync against the project's + * agentcore/cdk directory. A future `agentcore build` command should call this same + * function. Skipped entirely when AGENTCORE_SKIP_INSTALL is set (same escape hatch + * as create --no-install and the language setup steps). + * + * Throws CliVersionTooOldError when the project was updated by a newer CLI (unless + * `treatSkewAsWarning` or opted out), and DependencySyncError on rewrite/install failure. + */ +export async function ensureManagedDependencies( + cdkProject: LocalCdkProject, + options: EnsureManagedDependenciesOptions = {} +): Promise { + if (process.env.AGENTCORE_SKIP_INSTALL) { + return noOpSyncResult('skipped'); + } + const disabled = readGlobalConfigSync().disableDependencyManagement === true; + return syncManagedDependencies({ + vendedPackageJsonPath: getTemplatePath('cdk', 'package.json'), + projectDir: cdkProject.projectDir, + disabled, + mode: options.checkOnly ? 'check' : 'apply', + treatSkewAsWarning: options.treatSkewAsWarning, + installCommand: getDistroConfig().installCommand, + }); +} diff --git a/src/cli/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index d4eb1e5df..7e0d53e4a 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -68,6 +68,16 @@ export { ensureDefaultDeploymentTarget } from './ensure-target'; // Pre-synth backfill of vpcId for pre-existing Container+VPC configs written before vpcId was added export { backfillContainerVpcIds, type BackfillVpcIdResult } from './backfill-vpc-id'; +// Managed dependency pinning — shared by CLI deploy + TUI preflight +export { + ensureManagedDependencies, + failedSyncResult, + teardownSyncFailureResult, + toDepSyncAttrs, + SYNC_CDK_DEPENDENCIES_STEP, + type EnsureManagedDependenciesOptions, +} from './dependency-sync'; + // Managed-memory heads-up (shared by the CLI command + TUI deploy flow + add harness) export { MANAGED_MEMORY_DEPLOY_NOTICE, diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index f92b15afc..0fca9cc3c 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -10,6 +10,7 @@ import { BuildType, Count, CredentialType, + DepSyncOutcome, DeployModeSchema, DevAction, EvaluatorLevel, @@ -119,6 +120,12 @@ const DeployAttrs = safeSchema({ policy_engine_count: Count, policy_count: Count, deploy_mode: DeployModeSchema, + dep_sync_outcome: DepSyncOutcome.optional(), + dep_sync_changed_count: Count.optional(), + dep_sync_migrated: z.boolean().optional(), + dep_sync_opted_out: z.boolean().optional(), + dep_sync_skew_warning: z.boolean().optional(), + dep_sync_reinstalled: z.boolean().optional(), }); const DevAttrs = safeSchema({ diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index 6072b87ad..a319cf570 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -35,6 +35,8 @@ export const AuthType = z.enum(['sigv4', 'bearer_token']); export const AuthorizerType = z.enum(['aws_iam', 'custom_jwt', 'none']); export const BuildType = z.enum(['codezip', 'container']); export const CredentialType = z.enum(['api-key', 'oauth']); +// Mirrors DependencySyncOutcome in src/lib/dependency-management/types.ts. +export const DepSyncOutcome = z.enum(['synced', 'check-only', 'opted-out', 'skipped', 'failure-suppressed', 'failed']); export const SkillSourceType = z.enum(['path', 's3', 'git', 'aws_skills']); export const EvaluatorType = z.enum(['llm-as-a-judge', 'code-based']); export const ExitReason = z.enum(['success', 'failure']); @@ -110,8 +112,10 @@ export const ErrorName = z.enum([ 'ConfigReadError', 'ConfigValidationError', 'ConfigWriteError', + 'CliVersionTooOldError', 'ConflictError', 'DependencyCheckError', + 'DependencySyncError', 'DevServerConnectionError', 'DevServerError', 'GitInitError', diff --git a/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx b/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx index d4130ccd0..2613df818 100644 --- a/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx +++ b/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx @@ -3,14 +3,19 @@ import { Text } from 'ink'; import { render } from 'ink-testing-library'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockHandleDeploy, mockReadProjectSpec, mockEnsureDefaultDeploymentTarget, mockCanSkipDeploy } = vi.hoisted( - () => ({ - mockHandleDeploy: vi.fn(), - mockReadProjectSpec: vi.fn(), - mockEnsureDefaultDeploymentTarget: vi.fn(), - mockCanSkipDeploy: vi.fn(), - }) -); +const { + mockHandleDeploy, + mockReadProjectSpec, + mockEnsureDefaultDeploymentTarget, + mockCanSkipDeploy, + TEST_MANAGED_MEMORY_NOTICE, +} = vi.hoisted(() => ({ + mockHandleDeploy: vi.fn(), + mockReadProjectSpec: vi.fn(), + mockEnsureDefaultDeploymentTarget: vi.fn(), + mockCanSkipDeploy: vi.fn(), + TEST_MANAGED_MEMORY_NOTICE: 'Managed memory: this harness provisions a dedicated AgentCore Memory resource', +})); vi.mock('../../../commands/deploy/actions.js', () => ({ handleDeploy: (...args: unknown[]) => mockHandleDeploy(...args), @@ -29,6 +34,7 @@ vi.mock('../../../../lib', async importActual => ({ vi.mock('../../../operations/deploy', () => ({ ensureDefaultDeploymentTarget: (...args: unknown[]) => mockEnsureDefaultDeploymentTarget(...args), + MANAGED_MEMORY_DEPLOY_NOTICE: TEST_MANAGED_MEMORY_NOTICE, })); vi.mock('../../../operations/deploy/change-detection', () => ({ @@ -36,11 +42,14 @@ vi.mock('../../../operations/deploy/change-detection', () => ({ })); function Harness({ skip }: { skip?: boolean }) { - const { steps, isComplete, error, managedMemoryNotice } = useDevDeploy({ skip }); + const { steps, isComplete, error, managedMemoryNotice, dependencySyncNotice, dependencySyncWarnings } = useDevDeploy({ + skip, + }); return ( steps:{steps.length} isComplete:{String(isComplete)} error:{error ?? 'null'} notice: - {managedMemoryNotice ?? 'null'} + {managedMemoryNotice ?? 'null'} depSyncNotice:{dependencySyncNotice ?? 'null'} depSyncWarnings: + {dependencySyncWarnings.length} ); } @@ -111,7 +120,7 @@ describe('useDevDeploy', () => { it('surfaces the managed-memory heads-up from the onNotice callback', async () => { mockHandleDeploy.mockImplementation((opts: { onNotice?: (message: string) => void }) => { - opts.onNotice?.('Managed memory: this harness automatically provisions a dedicated AgentCore Memory resource'); + opts.onNotice?.(TEST_MANAGED_MEMORY_NOTICE); return Promise.resolve({ success: true }); }); @@ -123,6 +132,38 @@ describe('useDevDeploy', () => { }); }); + it('does not let other onNotice messages clobber the managed-memory slot', async () => { + mockHandleDeploy.mockImplementation((opts: { onNotice?: (message: string) => void }) => { + opts.onNotice?.('Updated managed dependencies in agentcore/cdk/package.json:'); + return Promise.resolve({ success: true }); + }); + + const { lastFrame } = render(); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('isComplete:true'); + }); + expect(lastFrame()).toContain('notice:null'); + }); + + it('reads the dependency sync notice and warnings from the deploy result', async () => { + mockHandleDeploy.mockResolvedValue({ + success: true, + dependencySyncResult: { + notice: 'dep-sync-notice', + warnings: ['lodash (file:x) uses a non-semver specifier and was left unmanaged.'], + }, + }); + + const { lastFrame } = render(); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('depSyncNotice:dep-sync-notice'); + expect(lastFrame()).toContain('depSyncWarnings:1'); + expect(lastFrame()).toContain('isComplete:true'); + }); + }); + it('leaves the managed-memory heads-up null when onNotice is not called', async () => { mockHandleDeploy.mockResolvedValue({ success: true }); diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index b2462130e..68dcc4874 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -1,5 +1,6 @@ import { ConfigIO, SecureCredentials, toError } from '../../../lib'; -import { AwsCredentialsError, UserCancellationError } from '../../../lib/errors/types'; +import type { DependencySyncResult } from '../../../lib/dependency-management'; +import { AwsCredentialsError, DependencySyncError, UserCancellationError } from '../../../lib/errors/types'; import type { DeployedState } from '../../../schema'; import { applyTargetRegionToEnv } from '../../aws'; import { validateAwsCredentials } from '../../aws/account'; @@ -9,11 +10,14 @@ import type { ExecLogger } from '../../logging'; import { type MissingCredential, type PreflightContext, + SYNC_CDK_DEPENDENCIES_STEP, bootstrapEnvironment, buildCdkProject, checkBootstrapNeeded, checkDependencyVersions, checkStackDeployability, + ensureManagedDependencies, + failedSyncResult, formatError, getAllCredentials, hasIdentityApiProviders, @@ -21,6 +25,7 @@ import { setupApiKeyProviders, setupOAuth2Providers, synthesizeCdk, + teardownSyncFailureResult, validateProject, } from '../../operations/deploy'; import { @@ -142,6 +147,12 @@ export interface PreflightOptions { isInteractive?: boolean; /** Skip identity provider check (for plan command which only synthesizes) */ skipIdentityCheck?: boolean; + /** + * Preview mode (diff): the managed-dependency sync runs check-only, computing the plan and a + * future-tense notice without writing package.json or running npm install. Previews must never + * mutate the working tree. + */ + dependencySyncCheckOnly?: boolean; } export interface PreflightResult { @@ -162,6 +173,8 @@ export interface PreflightResult { missingCredentials: MissingCredential[]; /** KMS key ARN used for identity token vault encryption */ identityKmsKeyArn?: string; + /** Result of the managed dependency sync — notice/warnings for display, attrs for telemetry */ + dependencySyncResult: DependencySyncResult | null; /** Credential ARNs (API key + OAuth) from pre-deploy setup */ allCredentials: Record; startPreflight: () => Promise; @@ -184,13 +197,15 @@ export interface PreflightResult { // Step indices for base preflight steps (always present) const STEP_VALIDATE = 0; const STEP_DEPS = 1; -const STEP_BUILD = 2; -// Note: Identity steps are inserted at index 3+ when needed, shifting synth and stack status down. +const STEP_SYNC_DEPS = 2; +const STEP_BUILD = 3; +// Note: Identity steps are inserted at index 4+ when needed, shifting synth and stack status down. // Use findStepIndex() to locate synth and stack status dynamically. const BASE_PREFLIGHT_STEPS: Step[] = [ { label: 'Validate project', status: 'pending' }, { label: 'Check dependencies', status: 'pending' }, + { label: SYNC_CDK_DEPENDENCIES_STEP, status: 'pending' }, { label: 'Build CDK project', status: 'pending' }, { label: 'Synthesize CloudFormation', status: 'pending' }, { label: 'Check stack status', status: 'pending' }, @@ -205,7 +220,7 @@ const IDENTITY_STEP: Step = { label: LABEL_API_KEY, status: 'pending' }; const BOOTSTRAP_STEP: Step = { label: 'Bootstrap AWS environment', status: 'pending' }; export function useCdkPreflight(options: PreflightOptions): PreflightResult { - const { logger, isInteractive = false, skipIdentityCheck = false } = options; + const { logger, isInteractive = false, skipIdentityCheck = false, dependencySyncCheckOnly = false } = options; // Create switchable ioHost - starts silent, can be flipped to verbose for deploy const switchableIoHost = useMemo(() => createSwitchableIoHost(), []); @@ -226,6 +241,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { Record >({}); const [teardownConfirmed, setTeardownConfirmed] = useState(false); + const [dependencySyncResult, setDependencySyncResult] = useState(null); const lastErrorRef = useRef(undefined); // Guard against concurrent runs (React StrictMode, re-renders, etc.) @@ -282,6 +298,9 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { setBootstrapContext(null); setHasTokenExpiredError(false); // Reset token expired state when retrying setHasCredentialsError(false); // Reset credentials error state when retrying + // Reset the previous run's dep-sync result so a retry doesn't render stale + // notices/warnings or attach stale dep_sync_* attrs to the new run's telemetry. + setDependencySyncResult(null); setPhase('running'); }, [disposeWrapper, restoreRegionEnv]); @@ -485,6 +504,51 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { return; } + // Step: Sync managed dependencies — pin agentcore/cdk/package.json to the versions + // this CLI was tested with, migrating pre-pinning (caret) projects. Runs before the build so + // the compile sees the reinstalled tree. Diff mode runs check-only (never mutates the + // working tree), and teardown deploys downgrade every sync failure to a warning: skew via + // treatSkewAsWarning, write/install failures (DependencySyncError) via the catch below. + // Nothing about pinning may block destroying a cost-incurring stack. + updateStep(STEP_SYNC_DEPS, { status: 'running' }); + logger.startStep(SYNC_CDK_DEPENDENCIES_STEP); + try { + const syncResult = await ensureManagedDependencies(preflightContext.cdkProject, { + checkOnly: dependencySyncCheckOnly, + treatSkewAsWarning: preflightContext.isTeardownDeploy, + }); + for (const warning of syncResult.warnings) { + logger.log(warning, 'warn'); + } + if (syncResult.notice) { + logger.log(syncResult.notice); + } + setDependencySyncResult(syncResult); + logger.endStep('success'); + updateStep(STEP_SYNC_DEPS, { status: 'success' }); + } catch (err) { + if (preflightContext.isTeardownDeploy && err instanceof DependencySyncError) { + // Surface through the same warning channels as a downgraded skew: the deploy-flow + // screens render dependencySyncResult.warnings, and the step shows the warning inline. + const downgraded = teardownSyncFailureResult(err); + const warning = downgraded.warnings[0]!; + logger.log(warning, 'warn'); + setDependencySyncResult(downgraded); + // No message: the logger.log above already wrote the warning line. + logger.endStep('warn'); + updateStep(STEP_SYNC_DEPS, { status: 'warn', warn: warning }); + } else { + const errorMsg = formatError(err); + logger.endStep('error', errorMsg); + updateStep(STEP_SYNC_DEPS, { status: 'error', error: getErrorMessage(err) }); + // The sync itself is the failure: attach a minimal 'failed' result so + // useDeployFlow's failure telemetry still carries dep_sync_* attrs. + setDependencySyncResult(failedSyncResult()); + failPreflight(err); + return; + } + } + // Step: Build CDK project updateStep(STEP_BUILD, { status: 'running' }); logger.startStep('Build CDK project'); @@ -632,7 +696,16 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { return () => { process.off('unhandledRejection', handleUnhandledRejection); }; - }, [phase, logger, switchableIoHost, isInteractive, skipIdentityCheck, teardownConfirmed, restoreRegionEnv]); + }, [ + phase, + logger, + switchableIoHost, + isInteractive, + skipIdentityCheck, + dependencySyncCheckOnly, + teardownConfirmed, + restoreRegionEnv, + ]); // Handle identity-setup phase (after user provides credentials) useEffect(() => { @@ -1047,6 +1120,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { lastError: lastErrorRef.current, missingCredentials, identityKmsKeyArn, + dependencySyncResult, allCredentials, startPreflight, confirmTeardown, diff --git a/src/cli/tui/hooks/useDevDeploy.ts b/src/cli/tui/hooks/useDevDeploy.ts index d6615f8ee..4327311e9 100644 --- a/src/cli/tui/hooks/useDevDeploy.ts +++ b/src/cli/tui/hooks/useDevDeploy.ts @@ -2,7 +2,7 @@ import { ConfigIO } from '../../../lib'; import type { DeployMessage } from '../../cdk/toolkit-lib'; import { handleDeploy } from '../../commands/deploy/actions'; import { getErrorMessage } from '../../errors'; -import { ensureDefaultDeploymentTarget } from '../../operations/deploy'; +import { MANAGED_MEMORY_DEPLOY_NOTICE, ensureDefaultDeploymentTarget } from '../../operations/deploy'; import { canSkipDeploy } from '../../operations/deploy/change-detection'; import type { Step } from '../components/StepProgress'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -20,6 +20,10 @@ export interface UseDevDeployResult { logPath: string | undefined; /** Managed-memory heads-up surfaced by handleDeploy (null when not applicable) */ managedMemoryNotice: string | null; + /** Managed dependency sync summary from the deploy result (null when nothing changed) */ + dependencySyncNotice: string | null; + /** Managed dependency sync warnings from the deploy result */ + dependencySyncWarnings: string[]; } export function useDevDeploy({ skip, ready = true }: UseDevDeployOptions = {}): UseDevDeployResult { @@ -29,9 +33,14 @@ export function useDevDeploy({ skip, ready = true }: UseDevDeployOptions = {}): const [error, setError] = useState(); const [logPath, setLogPath] = useState(); const [managedMemoryNotice, setManagedMemoryNotice] = useState(null); + // Dep-sync gets its own slot: it's a multi-line summary read from the deploy RESULT, not the + // generic onNotice stream — multiplexing it through onNotice would let the managed-memory + // heads-up clobber it in the single "Note:" line. + const [dependencySyncNotice, setDependencySyncNotice] = useState(null); + const [dependencySyncWarnings, setDependencySyncWarnings] = useState([]); const hasStarted = useRef(false); - const onProgress = useCallback((stepName: string, status: 'start' | 'success' | 'error') => { + const onProgress = useCallback((stepName: string, status: 'start' | 'success' | 'error' | 'warn') => { setSteps(prev => { if (status === 'start') { return [...prev, { label: stepName, status: 'running' }]; @@ -44,8 +53,13 @@ export function useDevDeploy({ skip, ready = true }: UseDevDeployOptions = {}): setDeployMessages(prev => [...prev, msg]); }, []); + // onNotice is a generic stream (handleDeploy also emits the dep-sync notice through it); + // only the managed-memory heads-up belongs in the single "Note:" line — the dep-sync + // summary is read from the deploy result instead, so the two can't clobber each other. const onNotice = useCallback((message: string) => { - setManagedMemoryNotice(message); + if (message === MANAGED_MEMORY_DEPLOY_NOTICE) { + setManagedMemoryNotice(message); + } }, []); useEffect(() => { @@ -92,6 +106,11 @@ export function useDevDeploy({ skip, ready = true }: UseDevDeployOptions = {}): setLogPath(result.logPath); } + if (result.dependencySyncResult) { + setDependencySyncNotice(result.dependencySyncResult.notice); + setDependencySyncWarnings(result.dependencySyncResult.warnings); + } + if (!result.success) { setError(getErrorMessage(result.error)); } @@ -108,5 +127,14 @@ export function useDevDeploy({ skip, ready = true }: UseDevDeployOptions = {}): // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- skip is boolean, not nullable; || is the correct operator here const isComplete = skip || deployDone; - return { steps, deployMessages, isComplete, error, logPath, managedMemoryNotice }; + return { + steps, + deployMessages, + isComplete, + error, + logPath, + managedMemoryNotice, + dependencySyncNotice, + dependencySyncWarnings, + }; } diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index e033bb9fa..e3c26d58e 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -83,6 +83,8 @@ export function DeployScreen({ numStacksWithChanges, deployNotes, managedMemoryNotice, + dependencySyncNotice, + dependencySyncWarnings, postDeployWarnings, postDeployHasError, isDiffLoading, @@ -344,6 +346,24 @@ export function DeployScreen({ <> + {/* Managed dependency sync summary: what preflight changed in agentcore/cdk/package.json. */} + {dependencySyncNotice && ( + + {dependencySyncNotice} + + )} + + {/* Managed dependency sync warnings: downgraded skew, skipped specifiers. */} + {dependencySyncWarnings.length > 0 && ( + + {dependencySyncWarnings.map((warning, i) => ( + + ⚠ {warning} + + ))} + + )} + {/* Managed-memory heads-up: shown while the slow CFN apply runs (not gated on success). Styled as a plain dim "Note:" to match the transaction-search note convention below. */} {managedMemoryNotice && !isComplete && ( diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index c866b924c..72586ff6b 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -1,4 +1,5 @@ import { ConfigIO } from '../../../../lib'; +import type { DependencySyncResult } from '../../../../lib/dependency-management'; import type { AwsDeploymentTarget } from '../../../../schema'; import type { CdkToolkitWrapper, DeployMessage, SwitchableIoHost } from '../../../cdk/toolkit-lib'; import { @@ -28,6 +29,7 @@ import { hasManagedMemoryHarness, performStackTeardown, setupTransactionSearch, + toDepSyncAttrs, } from '../../../operations/deploy'; import { computeProjectDeployHash } from '../../../operations/deploy/change-detection'; import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status'; @@ -115,6 +117,10 @@ interface DeployFlowState { deployNotes: string[]; /** Managed-memory heads-up, shown while the CFN apply runs (null when not applicable) */ managedMemoryNotice: string | null; + /** Managed dependency sync summary from preflight, null when nothing changed */ + dependencySyncNotice: string | null; + /** Managed dependency sync warnings (downgraded skew, skipped specifiers) from preflight */ + dependencySyncWarnings: string[]; /** Warnings from post-deploy steps (config bundles, AB tests) */ postDeployWarnings: string[]; /** True if any post-deploy sub-resource operation had errors */ @@ -140,6 +146,12 @@ interface DeployFlowState { skipCredentials: () => void; } +/** Overlay dep_sync_* telemetry attrs from the preflight dependency sync, if it ran. */ +function withDepSyncAttrs(attrs: T, sync: DependencySyncResult | null): T { + if (!sync) return attrs; + return { ...attrs, ...toDepSyncAttrs(sync) }; +} + export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState { const { preSynthesized, isInteractive = false, diffMode = false, selectedTargets } = options; const skipPreflight = !!preSynthesized; @@ -147,8 +159,10 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Create logger once for the entire deploy flow const [logger] = useState(() => new ExecLogger({ command: 'deploy' })); - // Always call the hook (React rules), but we won't use it when preSynthesized is provided - const preflight = useCdkPreflight({ logger, isInteractive }); + // Always call the hook (React rules), but we won't use it when preSynthesized is provided. + // Diff mode is a preview: the managed-dependency sync runs check-only so the working tree + // is never mutated by `agentcore deploy --diff`. + const preflight = useCdkPreflight({ logger, isInteractive, dependencySyncCheckOnly: diffMode }); // Use pre-synthesized values when provided, otherwise use preflight values const cdkToolkitWrapper = preSynthesized?.cdkToolkitWrapper ?? preflight.cdkToolkitWrapper; @@ -741,7 +755,10 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Preflight failed — emit telemetry and bail if (preflight.phase === 'error') { const error = preflight.lastError ?? new Error('Preflight failed'); - const attrs = context ? computeDeployAttrs(context.projectSpec, 'deploy') : { ...DEFAULT_DEPLOY_ATTRS }; + const attrs = withDepSyncAttrs( + context ? computeDeployAttrs(context.projectSpec, 'deploy') : { ...DEFAULT_DEPLOY_ATTRS }, + preflight.dependencySyncResult + ); withCommandRunTelemetry('deploy', attrs, () => ({ success: false as const, error })).catch(() => { /* telemetry is best-effort */ }); @@ -751,7 +768,10 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (deployStep.status !== 'pending') return; if (!cdkToolkitWrapper) return; - const attrs = context ? computeDeployAttrs(context.projectSpec, 'deploy') : { ...DEFAULT_DEPLOY_ATTRS }; + const attrs = withDepSyncAttrs( + context ? computeDeployAttrs(context.projectSpec, 'deploy') : { ...DEFAULT_DEPLOY_ATTRS }, + preflight.dependencySyncResult + ); const run = async (): Promise<{ success: true } | { success: false; error: Error }> => { // Run diff before deploy to capture pre-deploy differences. @@ -1022,9 +1042,12 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Preflight failed — emit telemetry and bail if (preflight.phase === 'error') { const error = preflight.lastError ?? new Error('Preflight failed'); - const attrs = context - ? computeDeployAttrs(context.projectSpec, 'diff') - : { ...DEFAULT_DEPLOY_ATTRS, deploy_mode: 'diff' as const }; + const attrs = withDepSyncAttrs( + context + ? computeDeployAttrs(context.projectSpec, 'diff') + : { ...DEFAULT_DEPLOY_ATTRS, deploy_mode: 'diff' as const }, + preflight.dependencySyncResult + ); withCommandRunTelemetry('deploy', attrs, () => ({ success: false as const, error })).catch(() => { /* telemetry is best-effort */ }); @@ -1034,9 +1057,12 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (diffStep.status !== 'pending') return; if (!cdkToolkitWrapper) return; - const attrs = context - ? computeDeployAttrs(context.projectSpec, 'diff') - : { ...DEFAULT_DEPLOY_ATTRS, deploy_mode: 'diff' as const }; + const attrs = withDepSyncAttrs( + context + ? computeDeployAttrs(context.projectSpec, 'diff') + : { ...DEFAULT_DEPLOY_ATTRS, deploy_mode: 'diff' as const }, + preflight.dependencySyncResult + ); const run = async (): Promise<{ success: true } | { success: false; error: Error }> => { setDiffStep(prev => ({ ...prev, status: 'running' })); @@ -1233,6 +1259,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState numStacksWithChanges, deployNotes, managedMemoryNotice, + dependencySyncNotice: preflight.dependencySyncResult?.notice ?? null, + dependencySyncWarnings: preflight.dependencySyncResult?.warnings ?? [], postDeployWarnings, postDeployHasError, isDiffLoading, diff --git a/src/cli/tui/screens/dev/DevScreen.tsx b/src/cli/tui/screens/dev/DevScreen.tsx index bf51899cf..29e5d232c 100644 --- a/src/cli/tui/screens/dev/DevScreen.tsx +++ b/src/cli/tui/screens/dev/DevScreen.tsx @@ -264,6 +264,8 @@ export function DevScreen(props: DevScreenProps) { error: deployError, logPath: deployLogPath, managedMemoryNotice, + dependencySyncNotice, + dependencySyncWarnings, } = useDevDeploy({ skip: props.skipDeploy, ready: mode === 'deploying' }); const hasTransitionedFromDeployRef = useRef(false); @@ -273,6 +275,15 @@ export function DevScreen(props: DevScreenProps) { queueMicrotask(() => { if (onLaunchBrowser) { onLaunchBrowser({ harnessName: selectedHarness }); + // onLaunchBrowser exits the alt screen and unmounts synchronously, so anything rendered + // in 'deploying' mode is gone. Print the one-shot dep-sync notice and warnings — e.g. the + // "we rewrote your package.json" migration explanation — to the normal buffer instead. + if (dependencySyncNotice) { + console.log(`\n${dependencySyncNotice}\n`); + } + for (const warning of dependencySyncWarnings) { + console.warn(`⚠ ${warning}`); + } } else if (selectedHarness) { setMode('harness'); } else { @@ -517,6 +528,24 @@ export function DevScreen(props: DevScreenProps) { return null; } + // Dependency sync notice and warnings get their own block — the multi-line notice doesn't fit + // the single "Note:" line in the deploying view. Rendered during the deploy AND in the + // post-deploy modes (harness / select-agent): the deploy completes and the mode transitions in + // the same render batch, so a block gated on mode === 'deploying' would be visible for at most + // one frame. The dev TUI runs in the alt screen, so persisting it in the UI is the only way + // the one-time "we rewrote your package.json" explanation is actually readable. + const dependencySyncSummary = + dependencySyncNotice || dependencySyncWarnings.length > 0 ? ( + + {dependencySyncNotice && {dependencySyncNotice}} + {dependencySyncWarnings.map((warning, i) => ( + + ⚠ {warning} + + ))} + + ) : null; + // Show error screen if no agents are defined if (noAgentsError) { return ( @@ -548,6 +577,7 @@ export function DevScreen(props: DevScreenProps) { Note: {managedMemoryNotice} )} + {dependencySyncSummary && {dependencySyncSummary}} {hasStartedCfn && ( @@ -564,9 +594,19 @@ export function DevScreen(props: DevScreenProps) { ); } - // If harness mode, render the InvokeScreen with the pre-selected harness + // If harness mode, render the InvokeScreen with the pre-selected harness. The dep-sync + // summary stays visible above it — this mode is entered right after the deploy completes. if (mode === 'harness') { - return ; + return ( + + {dependencySyncSummary && ( + + {dependencySyncSummary} + + )} + + + ); } const statusColor = { starting: 'yellow', running: 'green', error: 'red', stopped: 'gray' }[status]; @@ -616,6 +656,8 @@ export function DevScreen(props: DevScreenProps) { return ( + {/* Post-deploy chooser: keep the dep-sync summary visible (see dependencySyncSummary). */} + {dependencySyncSummary && {dependencySyncSummary}} 0 ? 'Select Target' : 'Select Agent'} fullWidth> diff --git a/src/lib/dependency-management/__tests__/plan.test.ts b/src/lib/dependency-management/__tests__/plan.test.ts new file mode 100644 index 000000000..e683fa250 --- /dev/null +++ b/src/lib/dependency-management/__tests__/plan.test.ts @@ -0,0 +1,166 @@ +import { computeSyncPlan } from '../plan'; +import type { PackageManifest } from '../types'; +import { describe, expect, it } from 'vitest'; + +const VENDED: PackageManifest = { + dependencies: { + '@aws/agentcore-cdk': '0.1.0-alpha.45', + 'aws-cdk-lib': '~2.261.0', + constructs: '~10.7.0', + }, + devDependencies: { + 'aws-cdk': '~2.1126.0', + typescript: '~5.9.3', + }, +}; + +function project(overrides: Partial = {}): PackageManifest { + return { + dependencies: { ...VENDED.dependencies }, + devDependencies: { ...VENDED.devDependencies }, + ...overrides, + }; +} + +describe('computeSyncPlan', () => { + it('produces an empty plan when the project already matches', () => { + const plan = computeSyncPlan(VENDED, project()); + expect(plan).toEqual({ skew: [], changes: [], restored: [], skipped: [], migratedFromCaret: false }); + }); + + it('migrates caret ranges to the vended pin and flags the migration', () => { + const plan = computeSyncPlan( + VENDED, + project({ + dependencies: { + '@aws/agentcore-cdk': '^0.1.0-alpha.19', + 'aws-cdk-lib': '^2.248.0', + constructs: '^10.0.0', + }, + }) + ); + expect(plan.migratedFromCaret).toBe(true); + expect(plan.skew).toEqual([]); + expect(plan.changes).toEqual([ + { name: '@aws/agentcore-cdk', section: 'dependencies', from: '^0.1.0-alpha.19', to: '0.1.0-alpha.45' }, + { name: 'aws-cdk-lib', section: 'dependencies', from: '^2.248.0', to: '~2.261.0' }, + { name: 'constructs', section: 'dependencies', from: '^10.0.0', to: '~10.7.0' }, + ]); + }); + + it('syncs older tilde ranges without flagging migration', () => { + const plan = computeSyncPlan( + VENDED, + project({ dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.250.0' } }) + ); + expect(plan.migratedFromCaret).toBe(false); + expect(plan.changes).toEqual([{ name: 'aws-cdk-lib', section: 'dependencies', from: '~2.250.0', to: '~2.261.0' }]); + }); + + it('flags skew when the project declares a higher minor', () => { + const plan = computeSyncPlan( + VENDED, + project({ dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.290.0' } }) + ); + expect(plan.skew).toEqual([{ name: 'aws-cdk-lib', declared: '~2.290.0', expected: '~2.261.0' }]); + expect(plan.changes).toEqual([]); + }); + + it('flags skew when the project declares a higher major', () => { + const plan = computeSyncPlan( + VENDED, + project({ dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~3.0.0' } }) + ); + expect(plan.skew).toHaveLength(1); + }); + + it('treats a higher patch as syncable, not skew', () => { + const plan = computeSyncPlan( + VENDED, + project({ dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.261.5' } }) + ); + expect(plan.skew).toEqual([]); + expect(plan.changes).toEqual([{ name: 'aws-cdk-lib', section: 'dependencies', from: '~2.261.5', to: '~2.261.0' }]); + }); + + it('detects prerelease skew on the exact-pinned construct (alpha.50 > alpha.45)', () => { + const plan = computeSyncPlan( + VENDED, + project({ dependencies: { ...VENDED.dependencies, '@aws/agentcore-cdk': '0.1.0-alpha.50' } }) + ); + expect(plan.skew).toEqual([{ name: '@aws/agentcore-cdk', declared: '0.1.0-alpha.50', expected: '0.1.0-alpha.45' }]); + }); + + it('upgrades an older exact-pinned prerelease', () => { + const plan = computeSyncPlan( + VENDED, + project({ dependencies: { ...VENDED.dependencies, '@aws/agentcore-cdk': '0.1.0-alpha.19' } }) + ); + expect(plan.skew).toEqual([]); + expect(plan.changes).toEqual([ + { name: '@aws/agentcore-cdk', section: 'dependencies', from: '0.1.0-alpha.19', to: '0.1.0-alpha.45' }, + ]); + }); + + it('syncs v-prefixed specifiers instead of skipping them as non-semver', () => { + const plan = computeSyncPlan(VENDED, project({ dependencies: { ...VENDED.dependencies, constructs: 'v10.7.0' } })); + expect(plan.skipped).toEqual([]); + expect(plan.skew).toEqual([]); + expect(plan.changes).toEqual([{ name: 'constructs', section: 'dependencies', from: 'v10.7.0', to: '~10.7.0' }]); + }); + + it('never touches user-added dependencies', () => { + const plan = computeSyncPlan( + VENDED, + project({ dependencies: { ...VENDED.dependencies, lodash: '^4.17.21', 'left-pad': '*' } }) + ); + expect(plan.changes).toEqual([]); + expect(plan.skipped).toEqual([]); + }); + + it('restores a deleted managed dependency', () => { + const proj = project(); + delete proj.dependencies!.constructs; + const plan = computeSyncPlan(VENDED, proj); + expect(plan.restored).toEqual([{ name: 'constructs', section: 'dependencies', to: '~10.7.0' }]); + }); + + it('skips managed deps with non-semver specifiers (bundled tarball override)', () => { + const plan = computeSyncPlan( + VENDED, + project({ + dependencies: { ...VENDED.dependencies, '@aws/agentcore-cdk': 'file:bundled-agentcore-cdk.tgz' }, + }) + ); + expect(plan.changes).toEqual([]); + expect(plan.skew).toEqual([]); + expect(plan.skipped).toEqual([ + { + name: '@aws/agentcore-cdk', + raw: 'file:bundled-agentcore-cdk.tgz', + reason: 'uses a non-semver specifier and was left unmanaged', + }, + ]); + }); + + it('manages devDependencies and respects the section the user has the dep in', () => { + const proj = project({ + dependencies: { ...VENDED.dependencies, 'aws-cdk': '~2.1000.0' }, + devDependencies: { typescript: '~5.8.0' }, + }); + delete proj.devDependencies!['aws-cdk']; + const plan = computeSyncPlan(VENDED, proj); + expect(plan.changes).toContainEqual({ + name: 'aws-cdk', + section: 'dependencies', + from: '~2.1000.0', + to: '~2.1126.0', + }); + expect(plan.changes).toContainEqual({ + name: 'typescript', + section: 'devDependencies', + from: '~5.8.0', + to: '~5.9.3', + }); + }); +}); diff --git a/src/lib/dependency-management/__tests__/semver.test.ts b/src/lib/dependency-management/__tests__/semver.test.ts new file mode 100644 index 000000000..c0f13dd11 --- /dev/null +++ b/src/lib/dependency-management/__tests__/semver.test.ts @@ -0,0 +1,86 @@ +import { parseSpecifier, parseVersion } from '../semver'; +import { describe, expect, it } from 'vitest'; + +describe('parseVersion', () => { + it('parses release versions', () => { + expect(parseVersion('2.261.0')).toMatchObject({ major: 2, minor: 261, patch: 0, prerelease: [] }); + }); + + it('parses prerelease versions with numeric identifiers', () => { + expect(parseVersion('0.1.0-alpha.19')).toMatchObject({ major: 0, minor: 1, patch: 0, prerelease: ['alpha', 19] }); + }); + + it('ignores build metadata for precedence fields', () => { + expect(parseVersion('1.2.3+build.5')).toMatchObject({ major: 1, minor: 2, patch: 3, prerelease: [] }); + }); + + it('accepts a single leading v or V, like npm', () => { + expect(parseVersion('v1.2.3')).toMatchObject({ major: 1, minor: 2, patch: 3, prerelease: [] }); + expect(parseVersion('V10.7.0')).toMatchObject({ major: 10, minor: 7, patch: 0, prerelease: [] }); + expect(parseVersion('vv1.2.3')).toBeNull(); + }); + + it('rejects non-versions', () => { + expect(parseVersion('latest')).toBeNull(); + expect(parseVersion('1.2')).toBeNull(); + expect(parseVersion('')).toBeNull(); + }); + + it('rejects wildcards and ranges (no loose or coerce parsing)', () => { + expect(parseVersion('1.x')).toBeNull(); + expect(parseVersion('*')).toBeNull(); + expect(parseVersion('>=1.2.3')).toBeNull(); + }); +}); + +describe('parseSpecifier', () => { + it('classifies exact, tilde, and caret', () => { + expect(parseSpecifier('2.1126.0').kind).toBe('exact'); + expect(parseSpecifier('~2.261.0').kind).toBe('tilde'); + expect(parseSpecifier('^0.1.0-alpha.19').kind).toBe('caret'); + expect(parseSpecifier('=1.2.3').kind).toBe('exact'); + }); + + it('parses the base version of a ranged specifier', () => { + expect(parseSpecifier('~2.261.0')).toMatchObject({ + kind: 'tilde', + version: { major: 2, minor: 261, patch: 0, prerelease: [] }, + raw: '~2.261.0', + }); + expect(parseSpecifier('^0.1.0-alpha.19')).toMatchObject({ + kind: 'caret', + version: { major: 0, minor: 1, patch: 0, prerelease: ['alpha', 19] }, + raw: '^0.1.0-alpha.19', + }); + }); + + it('accepts v-prefixed versions, bare and ranged, like npm', () => { + expect(parseSpecifier('v1.2.3')).toMatchObject({ + kind: 'exact', + version: { major: 1, minor: 2, patch: 3, prerelease: [] }, + raw: 'v1.2.3', + }); + expect(parseSpecifier('~v1.2.3')).toMatchObject({ + kind: 'tilde', + version: { major: 1, minor: 2, patch: 3, prerelease: [] }, + raw: '~v1.2.3', + }); + expect(parseSpecifier('^v10.7.0').kind).toBe('caret'); + }); + + it('marks non-semver specifiers unsupported', () => { + for (const raw of [ + 'file:bundled-agentcore-cdk.tgz', + 'git+https://github.com/aws/agentcore-cdk.git', + 'workspace:*', + '*', + 'latest', + '>=1.2.3', + '>=1.0.0 <2.0.0', + '1.x', + 'https://example.com/pkg.tgz', + ]) { + expect(parseSpecifier(raw)).toEqual({ kind: 'unsupported', raw }); + } + }); +}); diff --git a/src/lib/dependency-management/__tests__/sync.test.ts b/src/lib/dependency-management/__tests__/sync.test.ts new file mode 100644 index 000000000..9b59195b2 --- /dev/null +++ b/src/lib/dependency-management/__tests__/sync.test.ts @@ -0,0 +1,263 @@ +import { CliVersionTooOldError, DependencySyncError } from '../../errors/types'; +import { syncManagedDependencies } from '../sync'; +import type { SyncManagedDependenciesOptions } from '../types'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockRunSubprocessCapture = vi.hoisted(() => vi.fn()); +vi.mock('../../utils/subprocess', () => ({ + runSubprocessCapture: mockRunSubprocessCapture, +})); + +const VENDED = { + name: 'agentcore-cdk-app', + dependencies: { + '@aws/agentcore-cdk': '0.1.0-alpha.45', + 'aws-cdk-lib': '~2.261.0', + }, + devDependencies: { + typescript: '~5.9.3', + }, +}; + +describe('syncManagedDependencies', () => { + let tempDir: string; + let projectDir: string; + let vendedPath: string; + + const writeProject = (manifest: object) => { + writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n'); + }; + const readProject = () => JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf-8')); + const run = (options: Partial = {}) => + syncManagedDependencies({ vendedPackageJsonPath: vendedPath, projectDir, ...options }); + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'dep-sync-test-')); + projectDir = path.join(tempDir, 'agentcore', 'cdk'); + mkdirSync(projectDir, { recursive: true }); + // Most tests exercise the manifest-diff path, not install self-healing — pre-create + // node_modules so a missing tree doesn't trigger the recovery install. + mkdirSync(path.join(projectDir, 'node_modules'), { recursive: true }); + vendedPath = path.join(tempDir, 'vended-package.json'); + writeFileSync(vendedPath, JSON.stringify(VENDED, null, 2)); + mockRunSubprocessCapture.mockResolvedValue({ stdout: '', stderr: '', code: 0, signal: null }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it('is a no-op when the project already matches (no write, no reinstall, no notice)', async () => { + writeProject({ dependencies: { ...VENDED.dependencies }, devDependencies: { ...VENDED.devDependencies } }); + const result = await run(); + expect(result.outcome).toBe('synced'); + expect(result.changes).toEqual([]); + expect(result.reinstalled).toBe(false); + expect(result.notice).toBeNull(); + expect(mockRunSubprocessCapture).not.toHaveBeenCalled(); + }); + + it('migrates caret ranges, reinstalls incrementally, and reports', async () => { + writeFileSync(path.join(projectDir, 'package-lock.json'), '{}'); + writeProject({ + dependencies: { '@aws/agentcore-cdk': '^0.1.0-alpha.19', 'aws-cdk-lib': '^2.248.0', lodash: '^4.17.21' }, + devDependencies: { typescript: '~5.9.3' }, + }); + + const result = await run(); + + expect(result.migratedFromCaret).toBe(true); + expect(result.reinstalled).toBe(true); + expect(result.changes).toHaveLength(2); + expect(result.notice).toContain('created before the AgentCore CLI managed dependency versions'); + expect(result.notice).toContain('Dependencies you added yourself were not changed.'); + expect(result.notice).toContain('disableDependencyManagement'); + + const written = readProject(); + expect(written.dependencies['@aws/agentcore-cdk']).toBe('0.1.0-alpha.45'); + expect(written.dependencies['aws-cdk-lib']).toBe('~2.261.0'); + expect(written.dependencies.lodash).toBe('^4.17.21'); + + // The install is incremental: node_modules and the lockfile are never deleted, so + // user-added deps keep their resolved versions and installs stay warm. + expect(existsSync(path.join(projectDir, 'node_modules'))).toBe(true); + expect(existsSync(path.join(projectDir, 'package-lock.json'))).toBe(true); + expect(mockRunSubprocessCapture).toHaveBeenCalledWith('npm', ['install'], { cwd: projectDir }); + }); + + it('installs when node_modules is missing even if the manifest already matches (self-healing)', async () => { + rmSync(path.join(projectDir, 'node_modules'), { recursive: true, force: true }); + writeProject({ dependencies: { ...VENDED.dependencies }, devDependencies: { ...VENDED.devDependencies } }); + + const result = await run(); + + expect(result.changes).toEqual([]); + expect(result.reinstalled).toBe(true); + expect(mockRunSubprocessCapture).toHaveBeenCalledWith('npm', ['install'], { cwd: projectDir }); + }); + + it('preserves key order and unknown fields when rewriting', async () => { + writeProject({ + zeta: 'kept', + dependencies: { lodash: '1.0.0', 'aws-cdk-lib': '~2.250.0', '@aws/agentcore-cdk': '0.1.0-alpha.45' }, + scripts: { build: 'tsc' }, + devDependencies: { typescript: '~5.9.3' }, + }); + await run(); + const raw = readFileSync(path.join(projectDir, 'package.json'), 'utf-8'); + const written = JSON.parse(raw); + expect(Object.keys(written)).toEqual(['zeta', 'dependencies', 'scripts', 'devDependencies']); + expect(Object.keys(written.dependencies)).toEqual(['lodash', 'aws-cdk-lib', '@aws/agentcore-cdk']); + expect(written.zeta).toBe('kept'); + }); + + it('throws CliVersionTooOldError when the project declares a higher minor', async () => { + writeProject({ dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.300.0' }, devDependencies: {} }); + await expect(run()).rejects.toThrow(CliVersionTooOldError); + await expect(run()).rejects.toThrow(/newer version of the AgentCore CLI/); + }); + + it('names the skewed dep, uses the provided install command, and mentions the opt-out in the skew error', async () => { + writeProject({ dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.300.0' }, devDependencies: {} }); + const err = await run({ installCommand: 'npm install -g @aws/agentcore@preview' }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliVersionTooOldError); + const message = (err as Error).message; + expect(message).toContain('aws-cdk-lib'); + expect(message).toContain('~2.300.0'); + expect(message).toContain('npm install -g @aws/agentcore@preview'); + expect(message).toContain('disableDependencyManagement'); + }); + + it('defaults the install command to the public package name', async () => { + writeProject({ dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.300.0' }, devDependencies: {} }); + await expect(run()).rejects.toThrow(/npm install -g @aws\/agentcore@latest/); + }); + + it('downgrades skew to a warning and touches nothing when disabled', async () => { + const manifest = { + dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.300.0', old: '^1.0.0' }, + devDependencies: { typescript: '~5.8.0' }, + }; + writeProject(manifest); + const result = await run({ disabled: true }); + expect(result.outcome).toBe('opted-out'); + expect(result.optedOut).toBe(true); + expect(result.skewWarning).toBe(true); + expect(result.warnings.some(w => w.includes('newer than this CLI was tested with'))).toBe(true); + expect(result.notice).toBeNull(); + expect(readProject()).toEqual(manifest); + expect(mockRunSubprocessCapture).not.toHaveBeenCalled(); + }); + + it('downgrades skew to a warning but still syncs when treatSkewAsWarning is set', async () => { + writeProject({ + dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.300.0', '@aws/agentcore-cdk': '^0.1.0-alpha.19' }, + devDependencies: { typescript: '~5.9.3' }, + }); + const result = await run({ treatSkewAsWarning: true }); + expect(result.skewWarning).toBe(true); + expect(result.warnings.some(w => w.includes('newer than this CLI was tested with'))).toBe(true); + // The skewed dep is left alone; the other managed dep still syncs. + expect(readProject().dependencies['aws-cdk-lib']).toBe('~2.300.0'); + expect(readProject().dependencies['@aws/agentcore-cdk']).toBe('0.1.0-alpha.45'); + }); + + it('check mode computes the plan and a future-tense notice without writing or installing', async () => { + const manifest = { + dependencies: { '@aws/agentcore-cdk': '^0.1.0-alpha.19', 'aws-cdk-lib': '^2.248.0' }, + devDependencies: { typescript: '~5.9.3' }, + }; + writeProject(manifest); + const result = await run({ mode: 'check' }); + expect(result.outcome).toBe('check-only'); + expect(result.checkOnly).toBe(true); + expect(result.changes).toHaveLength(2); + expect(result.reinstalled).toBe(false); + expect(result.notice).toContain('will be updated on the next deploy'); + expect(result.notice).not.toContain("We've updated"); + expect(readProject()).toEqual(manifest); + expect(mockRunSubprocessCapture).not.toHaveBeenCalled(); + }); + + it('disabled wins over check mode in the outcome (opted-out, with checkOnly preserved)', async () => { + const manifest = { + dependencies: { '@aws/agentcore-cdk': '^0.1.0-alpha.19', 'aws-cdk-lib': '~2.261.0' }, + devDependencies: { typescript: '~5.9.3' }, + }; + writeProject(manifest); + const result = await run({ disabled: true, mode: 'check' }); + expect(result.outcome).toBe('opted-out'); + expect(result.optedOut).toBe(true); + // The overlapping detail survives: the run was also check-only. + expect(result.checkOnly).toBe(true); + expect(result.notice).toBeNull(); + expect(readProject()).toEqual(manifest); + expect(mockRunSubprocessCapture).not.toHaveBeenCalled(); + }); + + it('check mode reports skew as a warning instead of throwing', async () => { + const manifest = { dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.300.0' }, devDependencies: {} }; + writeProject(manifest); + const result = await run({ mode: 'check' }); + expect(result.skewWarning).toBe(true); + expect(result.warnings.some(w => w.includes('newer than this CLI was tested with'))).toBe(true); + expect(readProject()).toEqual(manifest); + expect(mockRunSubprocessCapture).not.toHaveBeenCalled(); + }); + + it('restores a deleted managed dependency', async () => { + writeProject({ + dependencies: { '@aws/agentcore-cdk': '0.1.0-alpha.45' }, + devDependencies: { typescript: '~5.9.3' }, + }); + const result = await run(); + expect(result.restored).toEqual([{ name: 'aws-cdk-lib', section: 'dependencies', to: '~2.261.0' }]); + expect(readProject().dependencies['aws-cdk-lib']).toBe('~2.261.0'); + expect(result.notice).toContain('aws-cdk-lib'); + }); + + it('skips a file: override with a warning and no rewrite', async () => { + writeProject({ + dependencies: { ...VENDED.dependencies, '@aws/agentcore-cdk': 'file:bundled-agentcore-cdk.tgz' }, + devDependencies: { ...VENDED.devDependencies }, + }); + const result = await run(); + expect(result.skipped).toHaveLength(1); + expect(result.warnings[0]).toContain('file:bundled-agentcore-cdk.tgz'); + expect(readProject().dependencies['@aws/agentcore-cdk']).toBe('file:bundled-agentcore-cdk.tgz'); + }); + + it('wraps npm install failure in DependencySyncError and restores the original manifest', async () => { + mockRunSubprocessCapture.mockResolvedValue({ stdout: '', stderr: 'E404', code: 1, signal: null }); + const original = { dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.250.0' }, devDependencies: {} }; + writeProject(original); + await expect(run()).rejects.toThrow(DependencySyncError); + // The rewrite is rolled back on install failure. Otherwise a retry would see a manifest that + // already matches the pins plus a still-present node_modules, skip the install, and deploy + // against the stale installed tree — the exact skew this sync exists to prevent. + expect(readProject()).toEqual(original); + }); + + it('recomputes the same plan and re-attempts the install on retry after an install failure', async () => { + mockRunSubprocessCapture.mockResolvedValueOnce({ stdout: '', stderr: 'E404', code: 1, signal: null }); + writeProject({ dependencies: { ...VENDED.dependencies, 'aws-cdk-lib': '~2.250.0' }, devDependencies: {} }); + await expect(run()).rejects.toThrow(DependencySyncError); + + // Second run: the restored manifest yields the same plan, and this time npm succeeds. + const result = await run(); + expect(result.changes).toEqual([ + { name: 'aws-cdk-lib', section: 'dependencies', from: '~2.250.0', to: '~2.261.0' }, + ]); + expect(result.reinstalled).toBe(true); + expect(readProject().dependencies['aws-cdk-lib']).toBe('~2.261.0'); + expect(mockRunSubprocessCapture).toHaveBeenCalledTimes(2); + }); + + it('wraps unreadable project package.json in DependencySyncError', async () => { + await expect(run()).rejects.toThrow(DependencySyncError); + }); +}); diff --git a/src/lib/dependency-management/index.ts b/src/lib/dependency-management/index.ts new file mode 100644 index 000000000..85a40573c --- /dev/null +++ b/src/lib/dependency-management/index.ts @@ -0,0 +1,12 @@ +export { syncManagedDependencies } from './sync'; +export { computeSyncPlan } from './plan'; +export type { SkewFinding, SyncPlan } from './plan'; +export type { + DependencyChange, + DependencySyncOutcome, + DependencySyncResult, + PackageManifest, + RestoredDependency, + SkippedDependency, + SyncManagedDependenciesOptions, +} from './types'; diff --git a/src/lib/dependency-management/messages.ts b/src/lib/dependency-management/messages.ts new file mode 100644 index 000000000..159d50852 --- /dev/null +++ b/src/lib/dependency-management/messages.ts @@ -0,0 +1,107 @@ +import type { SkewFinding } from './plan'; +import type { DependencyChange, RestoredDependency, SkippedDependency } from './types'; + +/** + * Single source of user-facing wording for dependency management, so the + * headless and TUI deploy paths (and any future CLI) can't drift apart. + */ + +/** + * Fallback CLI install command when the caller doesn't thread one in. The CLI layer + * passes `getDistroConfig().installCommand` (dist-tag/registry aware); this default + * keeps the lib module usable standalone. + */ +export const DEFAULT_CLI_INSTALL_COMMAND = 'npm install -g @aws/agentcore@latest'; + +export const OPT_OUT_HINT = 'To manage these versions yourself: agentcore config disableDependencyManagement true'; + +const MIGRATION_PREAMBLE = + 'Your project was created before the AgentCore CLI managed dependency versions.\n' + + "We've updated agentcore/cdk/package.json so the CLI keeps these dependencies\n" + + 'on versions it has been tested with (patch updates still apply automatically):'; + +// Check-mode variant: nothing was written, so the wording must promise rather than claim. +const MIGRATION_PREAMBLE_PENDING = + 'Your project was created before the AgentCore CLI managed dependency versions.\n' + + 'agentcore/cdk/package.json will be updated on the next deploy so the CLI keeps these\n' + + 'dependencies on versions it has been tested with (patch updates still apply automatically):'; + +function formatSkewList(skew: SkewFinding[]): string { + return skew.map(s => `${s.name} (${s.declared}, CLI expects ${s.expected})`).join(', '); +} + +/** + * Error text for newer-than-CLI skew. Names the skewed dependencies, gives the + * distro-correct upgrade command, and — because the skew may be a deliberate manual + * bump rather than a newer CLI — points at the opt-out as the alternative. + */ +export function formatCliUpgradeError(skew: SkewFinding[], installCommand: string): string { + const plural = skew.length > 1; + return ( + `This project requires a newer version of the AgentCore CLI: ${formatSkewList(skew)} ` + + `${plural ? 'are' : 'is'} newer than this CLI was tested with. ` + + `Run \`${installCommand}\` and retry.\n` + + `If you intentionally updated ${plural ? 'these dependencies' : 'this dependency'} yourself, ` + + 'you can disable managed dependency versions instead:\n' + + OPT_OUT_HINT + ); +} + +function formatChangeTable(changes: DependencyChange[], restored: RestoredDependency[]): string { + const rows: { name: string; from: string; to: string }[] = [ + ...changes.map(c => ({ name: c.name, from: c.from, to: c.to })), + ...restored.map(r => ({ name: r.name, from: '(removed)', to: r.to })), + ]; + const nameWidth = Math.max(...rows.map(r => r.name.length)); + const fromWidth = Math.max(...rows.map(r => r.from.length)); + return rows.map(r => ` ${r.name.padEnd(nameWidth)} ${r.from.padEnd(fromWidth)} → ${r.to}`).join('\n'); +} + +export function formatSyncNotice(options: { + migratedFromCaret: boolean; + changes: DependencyChange[]; + restored: RestoredDependency[]; + reinstalled: boolean; + /** False in check mode — nothing was written, so speak in the future tense. */ + applied: boolean; +}): string | null { + const { migratedFromCaret, changes, restored, reinstalled, applied } = options; + if (changes.length === 0 && restored.length === 0) return null; + + const lines: string[] = []; + if (migratedFromCaret) { + lines.push(applied ? MIGRATION_PREAMBLE : MIGRATION_PREAMBLE_PENDING); + } else { + lines.push( + applied + ? 'Updated managed dependencies in agentcore/cdk/package.json:' + : 'Managed dependencies in agentcore/cdk/package.json will be updated on the next deploy:' + ); + } + lines.push(''); + lines.push(formatChangeTable(changes, restored)); + lines.push(''); + if (reinstalled) { + lines.push('Ran npm install in agentcore/cdk to apply the updates.'); + } + lines.push( + applied + ? 'Dependencies you added yourself were not changed.' + : 'Dependencies you added yourself will not be changed.' + ); + if (migratedFromCaret) { + lines.push(OPT_OUT_HINT); + } + return lines.join('\n'); +} + +export function formatSkewWarning(skew: SkewFinding[], installCommand: string): string { + return ( + `${formatSkewList(skew)} ${skew.length === 1 ? 'is' : 'are'} newer than this CLI was tested with; ` + + `deploy may fail — upgrade the CLI with \`${installCommand}\`. ${OPT_OUT_HINT}` + ); +} + +export function formatSkippedWarning(skipped: SkippedDependency): string { + return `${skipped.name} (${skipped.raw}) ${skipped.reason}.`; +} diff --git a/src/lib/dependency-management/plan.ts b/src/lib/dependency-management/plan.ts new file mode 100644 index 000000000..84519de8e --- /dev/null +++ b/src/lib/dependency-management/plan.ts @@ -0,0 +1,101 @@ +import { parseSpecifier } from './semver'; +import type { ParsedSpecifier } from './semver'; +import type { + DependencyChange, + DependencySection, + PackageManifest, + RestoredDependency, + SkippedDependency, +} from './types'; +import { compare } from 'semver'; + +/** + * Pure policy computation for managed-dependency syncing. No I/O. + * + * A dependency is "managed" iff its name appears in the vended manifest + * (dependencies or devDependencies). The vended specifier is authoritative: + * the sync copies it verbatim, so the pinning policy (tilde for stable deps, + * exact for L3 constructs) lives entirely in the vended asset. + */ + +export interface SkewFinding { + name: string; + declared: string; + expected: string; +} + +export interface SyncPlan { + /** Managed deps where the project declares a newer minor/major than the CLI expects. */ + skew: SkewFinding[]; + changes: DependencyChange[]; + restored: RestoredDependency[]; + skipped: SkippedDependency[]; + /** True when any managed dep had a caret range — the project predates pinning. */ + migratedFromCaret: boolean; +} + +const SECTIONS: DependencySection[] = ['dependencies', 'devDependencies']; + +function findDeclared(manifest: PackageManifest, name: string): { section: DependencySection; raw: string } | null { + for (const section of SECTIONS) { + const raw = manifest[section]?.[name]; + if (raw !== undefined) return { section, raw }; + } + return null; +} + +/** + * Skew exists when the project's declared base version is ahead of the CLI's + * expectation in a way patch-floating can't explain: a higher major.minor for + * ranged deps, or any prerelease-aware greater-than for exact pins (so an + * alpha.20 project is never silently downgraded to an alpha.19 CLI's pin). + */ +function isSkewed(declared: ParsedSpecifier, expected: ParsedSpecifier): boolean { + if (declared.kind === 'unsupported' || expected.kind === 'unsupported') return false; + const d = declared.version; + const e = expected.version; + if (expected.kind === 'exact') return compare(d, e) > 0; + if (d.major !== e.major) return d.major > e.major; + return d.minor > e.minor; +} + +export function computeSyncPlan(vended: PackageManifest, project: PackageManifest): SyncPlan { + const plan: SyncPlan = { skew: [], changes: [], restored: [], skipped: [], migratedFromCaret: false }; + + for (const section of SECTIONS) { + for (const [name, expectedRaw] of Object.entries(vended[section] ?? {})) { + const expected = parseSpecifier(expectedRaw); + const declared = findDeclared(project, name); + + if (!declared) { + // Managed dep deleted by the user — the vended CDK app can't build without it. + plan.restored.push({ name, section, to: expectedRaw }); + continue; + } + + if (declared.raw === expectedRaw) continue; + + const declaredSpec = parseSpecifier(declared.raw); + if (declaredSpec.kind === 'unsupported') { + // file:/git:/tag/range specifiers (incl. the bundled-tarball override) are + // deliberate local overrides — never rewrite, never skew-check. + plan.skipped.push({ + name, + raw: declared.raw, + reason: 'uses a non-semver specifier and was left unmanaged', + }); + continue; + } + + if (isSkewed(declaredSpec, expected)) { + plan.skew.push({ name, declared: declared.raw, expected: expectedRaw }); + continue; + } + + if (declaredSpec.kind === 'caret') plan.migratedFromCaret = true; + plan.changes.push({ name, section: declared.section, from: declared.raw, to: expectedRaw }); + } + } + + return plan; +} diff --git a/src/lib/dependency-management/semver.ts b/src/lib/dependency-management/semver.ts new file mode 100644 index 000000000..ab818f8ea --- /dev/null +++ b/src/lib/dependency-management/semver.ts @@ -0,0 +1,57 @@ +/** + * Semver parsing and comparison for managed-dependency syncing. + * + * Version parsing and comparison are delegated to node-semver (the `semver` + * package), which implements full semver precedence including prerelease + * ordering (0.1.0-alpha.19 < 0.1.0-alpha.20 < 0.1.0) — the dependency-check + * parser in src/cli/external-requirements/versions.ts does not, as it drops + * prerelease tags, which here would make an alpha downgrade look like a no-op. + * The specifier classifier stays hand-rolled because the sync policy needs to + * know whether a declared range was written as tilde, caret, or an exact pin, + * and node-semver's Range API erases that distinction (it expands `~`/`^` into + * plain comparators). + */ +import { parse } from 'semver'; +import type { SemVer } from 'semver'; + +/** A parsed version: node-semver's SemVer (major / minor / patch / prerelease). */ +export type ParsedVersion = SemVer; + +export type SpecifierKind = 'exact' | 'tilde' | 'caret'; + +export type ParsedSpecifier = + | { kind: SpecifierKind; version: ParsedVersion; raw: string } + | { kind: 'unsupported'; raw: string }; + +export function parseVersion(version: string): ParsedVersion | null { + // npm accepts a single leading 'v' or 'V' on version strings (v1.2.3, ~v1.2.3); + // node-semver only recognizes the lowercase form, so normalize before parsing. + const normalized = version.trim().replace(/^V/, 'v'); + return parse(normalized, { loose: false }); +} + +/** + * Parse an npm dependency specifier into exact / tilde / caret + base version. + * Anything else (file:, git:, workspace:, URLs, wildcards, compound ranges, tags) + * is 'unsupported' — the sync skips those rather than guessing. Strict parsing + * (no loose/coerce) keeps `1.x`, `*`, `latest`, and `>=1.2.3` unsupported. + */ +export function parseSpecifier(raw: string): ParsedSpecifier { + const trimmed = raw.trim(); + let kind: SpecifierKind = 'exact'; + let versionPart = trimmed; + + if (trimmed.startsWith('~')) { + kind = 'tilde'; + versionPart = trimmed.slice(1); + } else if (trimmed.startsWith('^')) { + kind = 'caret'; + versionPart = trimmed.slice(1); + } else if (trimmed.startsWith('=')) { + versionPart = trimmed.slice(1); + } + + const version = parseVersion(versionPart); + if (!version) return { kind: 'unsupported', raw }; + return { kind, version, raw }; +} diff --git a/src/lib/dependency-management/sync.ts b/src/lib/dependency-management/sync.ts new file mode 100644 index 000000000..091a60818 --- /dev/null +++ b/src/lib/dependency-management/sync.ts @@ -0,0 +1,170 @@ +import { CliVersionTooOldError, DependencySyncError } from '../errors/types'; +import { runSubprocessCapture } from '../utils/subprocess'; +import { + DEFAULT_CLI_INSTALL_COMMAND, + formatCliUpgradeError, + formatSkewWarning, + formatSkippedWarning, + formatSyncNotice, +} from './messages'; +import { computeSyncPlan } from './plan'; +import type { DependencySyncResult, PackageManifest, SyncManagedDependenciesOptions } from './types'; +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +async function readManifest( + path: string, + fileDescription: string +): Promise<{ manifest: PackageManifest; raw: string }> { + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch (err) { + throw new DependencySyncError(`Could not read ${fileDescription} at ${path}: ${String(err)}`, { cause: err }); + } + try { + return { manifest: JSON.parse(raw) as PackageManifest, raw }; + } catch (err) { + throw new DependencySyncError(`Could not parse ${fileDescription} at ${path}: ${String(err)}`, { cause: err }); + } +} + +/** + * Sync the managed dependencies of a vended CDK project to the versions this CLI + * release was tested with. + * + * The vended asset package.json is the source of truth: every dependency named in it + * is managed and rewritten to its vended specifier (tilde for stable deps so patches + * float, exact for the L3 constructs); everything else in the user's file is untouched. + * Projects that predate pinning (caret ranges) are migrated automatically. If the + * project declares a managed dependency newer than this CLI expects, the project was + * updated by a newer CLI (or the user bumped it manually) and this throws + * CliVersionTooOldError (downgraded to a warning when `disabled` or + * `treatSkewAsWarning`). When anything changed — or node_modules is missing — + * `npm install` reconciles the edited package.json against the existing lockfile + * incrementally; node_modules and the lockfile are never deleted, so user-added deps + * keep their resolved versions. If the install fails, the pre-rewrite package.json is + * restored so a retry recomputes the same plan and re-attempts the install. + * + * `mode: 'check'` computes the same plan/warnings/notice without writing or + * installing, for preview flows that must not mutate the working tree. + * + * All user-facing wording is returned on the result (`notice`, `warnings`) — callers + * only display it. Throws CliVersionTooOldError | DependencySyncError; never exits. + */ +export async function syncManagedDependencies(options: SyncManagedDependenciesOptions): Promise { + const { + vendedPackageJsonPath, + projectDir, + disabled = false, + mode = 'apply', + treatSkewAsWarning = false, + installCommand = DEFAULT_CLI_INSTALL_COMMAND, + } = options; + const checkOnly = mode === 'check'; + const projectPackageJsonPath = join(projectDir, 'package.json'); + + const { manifest: vended } = await readManifest(vendedPackageJsonPath, 'the vended CDK package.json'); + // Keep the raw pre-rewrite text: it's restored verbatim if npm install fails, so a + // retry recomputes the same plan instead of seeing an already-matching manifest. + const { manifest: project, raw: projectRaw } = await readManifest( + projectPackageJsonPath, + "the project's CDK package.json" + ); + + const plan = computeSyncPlan(vended, project); + + // `disabled` wins over check mode: an opted-out sync never writes regardless of mode. + const outcome = disabled ? 'opted-out' : checkOnly ? 'check-only' : 'synced'; + const result: DependencySyncResult = { + outcome, + optedOut: disabled, + checkOnly, + migratedFromCaret: plan.migratedFromCaret, + reinstalled: false, + skewWarning: false, + changes: plan.changes, + restored: plan.restored, + skipped: plan.skipped, + warnings: plan.skipped.map(formatSkippedWarning), + notice: null, + }; + + if (plan.skew.length > 0) { + if (!disabled && !treatSkewAsWarning && !checkOnly) { + throw new CliVersionTooOldError(formatCliUpgradeError(plan.skew, installCommand)); + } + result.skewWarning = true; + result.warnings.push(formatSkewWarning(plan.skew, installCommand)); + } + + if (disabled) { + return result; + } + + const hasPlannedChanges = plan.changes.length > 0 || plan.restored.length > 0; + + if (checkOnly) { + // Report what WOULD change without touching the working tree (future-tense wording). + result.notice = formatSyncNotice({ + migratedFromCaret: result.migratedFromCaret, + changes: result.changes, + restored: result.restored, + reinstalled: false, + applied: false, + }); + return result; + } + + if (hasPlannedChanges) { + // Mutate the parsed manifest in place so user key order and unknown fields survive. + for (const change of plan.changes) { + (project[change.section] ??= {})[change.name] = change.to; + } + for (const restoredDep of plan.restored) { + const section = (project[restoredDep.section] ??= {}); + section[restoredDep.name] = restoredDep.to; + } + + try { + await writeFile(projectPackageJsonPath, JSON.stringify(project, null, 2) + '\n', 'utf-8'); + } catch (err) { + throw new DependencySyncError(`Failed to update the CDK project's dependencies: ${String(err)}`, { cause: err }); + } + } + + // Install when the manifest changed OR node_modules is missing (e.g. the user deleted it, or + // `create --no-install` never populated it). npm >=7 reconciles the edited package.json + // against the existing lockfile incrementally, so we never delete node_modules or the + // lockfile: user-added deps keep their resolved versions and installs stay warm. + const needsInstall = hasPlannedChanges || !existsSync(join(projectDir, 'node_modules')); + if (!needsInstall) { + return result; + } + + const installResult = await runSubprocessCapture('npm', ['install'], { cwd: projectDir }); + if (installResult.code !== 0) { + // Restore the pre-rewrite package.json before failing. Without this, the retry would see a + // manifest that already matches the pins and an existing node_modules (a failed install + // doesn't remove it), skip the install, and deploy against the stale installed tree — the + // exact skew this sync exists to prevent. Restoring makes the retry recompute the same plan + // and re-attempt the install. Best-effort: a restore failure must not mask the install error. + if (hasPlannedChanges) { + await writeFile(projectPackageJsonPath, projectRaw, 'utf-8').catch(() => undefined); + } + throw new DependencySyncError( + `npm install failed after updating managed dependencies (exit ${String(installResult.code)}): ${installResult.stderr}` + ); + } + result.reinstalled = true; + + result.notice = formatSyncNotice({ + migratedFromCaret: result.migratedFromCaret, + changes: result.changes, + restored: result.restored, + reinstalled: result.reinstalled, + applied: true, + }); + return result; +} diff --git a/src/lib/dependency-management/types.ts b/src/lib/dependency-management/types.ts new file mode 100644 index 000000000..8355d18f2 --- /dev/null +++ b/src/lib/dependency-management/types.ts @@ -0,0 +1,91 @@ +export type DependencySection = 'dependencies' | 'devDependencies'; + +/** A package.json shape that preserves unknown fields verbatim. */ +export interface PackageManifest { + dependencies?: Record; + devDependencies?: Record; + [key: string]: unknown; +} + +export interface DependencyChange { + name: string; + section: DependencySection; + from: string; + to: string; +} + +export interface RestoredDependency { + name: string; + section: DependencySection; + to: string; +} + +export interface SkippedDependency { + name: string; + raw: string; + reason: string; +} + +/** + * How the sync concluded: + * - 'synced': the sync ran in apply mode (rewrites/installs happened as needed). + * - 'check-only': `mode: 'check'` — plan computed, nothing written or installed. + * - 'opted-out': dependency management disabled via global config — nothing written. + * - 'skipped': the CLI layer skipped the sync entirely (AGENTCORE_SKIP_INSTALL). + * - 'failure-suppressed': the sync threw on a teardown deploy and the failure was + * downgraded to a warning so the teardown could proceed. + * - 'failed': the sync threw and the deploy failed with it (CliVersionTooOldError, or + * DependencySyncError on a non-teardown deploy). Attached by the CLI layer's failure + * path purely so dep_sync_* telemetry records that the sync itself was the failure. + */ +export type DependencySyncOutcome = 'synced' | 'check-only' | 'opted-out' | 'skipped' | 'failure-suppressed' | 'failed'; + +export interface DependencySyncResult { + /** + * Primary classification of the sync run. The booleans below preserve overlapping detail + * (e.g. an opted-out run in check mode also has `checkOnly: true`). + */ + outcome: DependencySyncOutcome; + /** True when dependency management is disabled via global config — nothing was written. */ + optedOut: boolean; + /** True when this was a check-only run (`mode: 'check'`) — plan computed, nothing written or installed. */ + checkOnly: boolean; + /** True when the project predated pinning (caret ranges on managed deps were rewritten). */ + migratedFromCaret: boolean; + /** True when `npm install` was run to reconcile the installed tree (manifest changed, or node_modules was missing). */ + reinstalled: boolean; + /** True when a newer-than-CLI managed dep was found but downgraded to a warning (opted out or skew-as-warning). */ + skewWarning: boolean; + changes: DependencyChange[]; + restored: RestoredDependency[]; + skipped: SkippedDependency[]; + /** Ready-to-print warning lines (downgraded skew, skipped specifiers). */ + warnings: string[]; + /** Ready-to-print user notice (migration message and/or change summary), null if nothing to say. */ + notice: string | null; +} + +export interface SyncManagedDependenciesOptions { + /** Path to the CLI's vended CDK package.json — source of truth for managed names + versions. */ + vendedPackageJsonPath: string; + /** The user project's CDK directory (contains package.json, node_modules, lockfile). */ + projectDir: string; + /** From global config `disableDependencyManagement`. When true: no writes, skew becomes a warning. */ + disabled?: boolean; + /** + * 'apply' (default) rewrites package.json and installs; 'check' only computes the plan and + * user-facing wording — no writes, no install. Used by preview modes (--dry-run / --diff), + * which must never mutate the working tree. + */ + mode?: 'apply' | 'check'; + /** + * Downgrade newer-than-CLI skew from CliVersionTooOldError to a warning even when managed. + * Used by teardown deploys, where skew must not block destroying a cost-incurring stack. + */ + treatSkewAsWarning?: boolean; + /** + * CLI upgrade command used in skew errors/warnings. The CLI layer passes the distro-aware + * `getDistroConfig().installCommand`; defaults keep the lib module usable standalone. + */ + installCommand?: string; +} diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index 93345263d..6186ea39c 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -139,6 +139,27 @@ export class StaleCdkConstructError extends BaseError { } } +/** + * Error thrown during deploy preflight when the project's agentcore/cdk/package.json + * declares a managed dependency newer (minor/major, or prerelease-aware for exact pins) + * than this CLI release expects — i.e. the project was updated by a newer CLI. + */ +export class CliVersionTooOldError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'user', ...options }); + } +} + +/** + * Error thrown when the managed-dependency sync fails to rewrite package.json or + * reinstall the CDK project's dependencies (fs error, npm install failure). + */ +export class DependencySyncError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'client', ...options }); + } +} + /** * Error thrown when AWS credentials are not configured or invalid. * Supports both a short message (for interactive mode) and detailed message (for CLI mode). diff --git a/src/lib/schemas/io/global-config.ts b/src/lib/schemas/io/global-config.ts index 3046db8b4..a7b92657c 100644 --- a/src/lib/schemas/io/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -17,6 +17,7 @@ const GlobalConfigSchemaStrict = z installationId: z.string().uuid().optional(), uvDefaultIndex: z.string().optional(), uvIndex: z.string().optional(), + disableDependencyManagement: z.boolean().optional(), disableTransactionSearch: z.boolean().optional(), transactionSearchIndexPercentage: z.number().int().min(0).max(100).optional(), telemetry: z