diff --git a/LOCAL_DEV_SETUP.md b/LOCAL_DEV_SETUP.md new file mode 100644 index 000000000..66116d90c --- /dev/null +++ b/LOCAL_DEV_SETUP.md @@ -0,0 +1,122 @@ +# Local Development Setup + +This guide explains how to set up the agentcore-cli for local development when the L3 constructs package is not yet published to npm. + +## Prerequisites + +- Node.js >= 20 +- npm +- Both packages cloned as siblings: + ``` + workspace/ + ├── agentcore-cli/ + └── agentcore-l3-cdk-constructs/ + ``` + +## Setup Steps + +### 1. Install and Build the L3 Constructs Package + +```bash +cd agentcore-l3-cdk-constructs +npm install +npm run build +``` + +### 2. Create Global npm Link + +```bash +npm link +``` + +This creates a global symlink that makes `@aws/agentcore-l3-cdk-constructs` available to other local projects. + +### 3. Build the CLI + +```bash +cd ../agentcore-cli +npm install +npm run build +``` + +### 4. Create a Test Project + +```bash +npm run cli create +``` + +Follow the prompts to create a new project. + +### 5. Link the L3 Constructs in the Generated Project + +The generated CDK project includes a postinstall script that automatically attempts to link `@aws/agentcore-l3-cdk-constructs`. However, if npm install was run before you created the global link, you may need to manually link it: + +```bash +cd /agentcore/cdk +npm link @aws/agentcore-l3-cdk-constructs +``` + +Alternatively, you can re-run npm install to trigger the postinstall script: + +```bash +npm install +``` + +### 6. Build and Test + +```bash +npm run build +``` + +## How npm link Works + +1. `npm link` in the L3 package creates a global symlink +2. `npm link @aws/agentcore-l3-cdk-constructs` in the CDK project creates a local symlink to the global one +3. Changes to the L3 package are immediately reflected (after rebuilding) + +## Troubleshooting + +### "Cannot find module" errors + +Make sure you've built the L3 constructs package: +```bash +cd agentcore-l3-cdk-constructs +npm run build +``` + +### Link not working + +Re-create the links: +```bash +# In L3 constructs +npm unlink +npm link + +# In generated CDK project +npm unlink @aws/agentcore-l3-cdk-constructs +npm link @aws/agentcore-l3-cdk-constructs +``` + +### Changes not reflected + +Rebuild the L3 constructs package: +```bash +cd agentcore-l3-cdk-constructs +npm run build +``` + +## Alternative: Using LOCAL_L3_PATH + +If you prefer, you can set the `LOCAL_L3_PATH` environment variable before running create: + +```bash +# Windows PowerShell +$env:LOCAL_L3_PATH = "C:\path\to\agentcore-l3-cdk-constructs" +npm run cli create + +# Windows CMD +set LOCAL_L3_PATH=C:\path\to\agentcore-l3-cdk-constructs +npm run cli create +``` + +This will automatically use `file:` protocol in package.json instead of requiring npm link. diff --git a/package-lock.json b/package-lock.json index 895b2c479..48e023728 100644 --- a/package-lock.json +++ b/package-lock.json @@ -209,6 +209,7 @@ "semver" ], "license": "Apache-2.0", + "peer": true, "dependencies": { "jsonschema": "~1.4.1", "semver": "^7.7.3" @@ -827,6 +828,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.975.0.tgz", "integrity": "sha512-xPFcBlpTDuTod9zAAnEsbezFOOqMfQfcd9RCl1LL4Q+qjmazuBSqlnzGE3Djr8Ax/PTV0TR3H2LuepO/ygXwsA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -1494,6 +1496,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.975.0.tgz", "integrity": "sha512-aF1M/iMD29BPcpxjqoym0YFa4WR9Xie1/IhVumwOGH6TB45DaqYO7vLwantDBcYNRn/cZH6DFHksO7RmwTFBhw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -2720,6 +2723,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -5397,6 +5401,7 @@ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5414,6 +5419,7 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5453,6 +5459,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -6038,6 +6045,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7020,6 +7028,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7294,6 +7303,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=20" } @@ -7326,7 +7336,8 @@ "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.5.tgz", "integrity": "sha512-fOoP70YLevMZr5avJHx2DU3LNYmC6wM8OwdrNewMZou1kZnPGOeVzBrRjZNgFDHUlulYUjkpFRSpTE3D+n+ZSg==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -7873,6 +7884,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8059,6 +8071,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9244,6 +9257,7 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.6.0.tgz", "integrity": "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", @@ -11007,6 +11021,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11092,6 +11107,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11178,6 +11194,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12619,6 +12636,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -12742,6 +12760,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12842,6 +12861,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -13065,6 +13085,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -13134,24 +13155,6 @@ } } }, - "node_modules/vitest/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13478,6 +13481,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index e23386abf..36e502436 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "build": "npm run build:lib && npm run build:cli && npm run build:assets", "build:lib": "tsc -p tsconfig.build.json", "build:cli": "node esbuild.config.mjs", - "build:assets": "rsync -a --exclude='/AGENTS.md' src/assets/ dist/assets/", + "build:assets": "node scripts/copy-assets.mjs", "cli": "npx tsx src/cli/index.ts", "typecheck": "tsc --noEmit", "lint": "eslint src/", @@ -29,7 +29,7 @@ "format:check": "prettier --check .", "secrets:check": "secretlint '**/*'", "security:audit": "npm audit --audit-level=high", - "clean": "rm -rf dist", + "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"", "prepare": "husky", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs new file mode 100644 index 000000000..a58b3ea48 --- /dev/null +++ b/scripts/copy-assets.mjs @@ -0,0 +1,50 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const srcDir = path.join(__dirname, '..', 'src', 'assets'); +const destDir = path.join(__dirname, '..', 'dist', 'assets'); + +/** + * Recursively copy directory contents, excluding specified files at root level only + * @param {string} src - Source directory + * @param {string} dest - Destination directory + * @param {string[]} excludeAtRoot - Files to exclude only at the root level (e.g., 'AGENTS.md') + * @param {boolean} isRoot - Whether this is the root level call + */ +function copyDir(src, dest, excludeAtRoot = [], isRoot = true) { + // Create destination directory if it doesn't exist + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + // Skip excluded files only at root level + if (isRoot && excludeAtRoot.includes(entry.name)) { + continue; + } + + if (entry.isDirectory()) { + copyDir(srcPath, destPath, excludeAtRoot, false); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +try { + console.log('Copying assets...'); + copyDir(srcDir, destDir, ['AGENTS.md']); + console.log('Assets copied successfully!'); +} catch (error) { + console.error('Error copying assets:', error); + process.exit(1); +} diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index b1e288767..f606eea78 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -78,3 +78,46 @@ There should not be significant logic defined in the `commands/` directory. Bias towards initial values over placeholders. Unless a field is optional, the initial value allows the user to just accept the value and keep moving. For something like an AWS accountID, an initial value would be inappropriate. + +## Cross-Platform Development + +The CLI is designed to work seamlessly on both Windows and Unix-like systems (Linux, macOS). All code should be +cross-platform compatible. + +### Platform Abstraction + +Use utilities from `lib/utils/platform.ts` to handle platform differences: + +```typescript +import { getVenvExecutable, isWindows } from '../../lib/utils/platform'; + +// Get correct path to Python venv executables +const uvicorn = getVenvExecutable('.venv', 'uvicorn'); +// Unix: .venv/bin/uvicorn +// Windows: .venv\Scripts\uvicorn.exe +``` + +### Cross-Platform Guidelines + +1. **Never hardcode Unix-specific paths or commands** + - ❌ `.venv/bin/python`, `rm -rf`, `rsync` + - ✅ Use `getVenvExecutable()`, Node.js `fs` APIs, or cross-platform npm packages + +2. **Use platform utilities instead of direct checks** + - ❌ `process.platform === 'win32'` + - ✅ `import { isWindows } from '../../lib/utils/platform'` + +3. **Test on both platforms** + - Windows has different path separators, executable extensions, and shell commands + - Python venv structure differs (bin/ vs Scripts/) + - PTY/terminal features may not be available on Windows + +4. **Handle platform-specific features gracefully** + - Example: PTY via `script` command is Unix-only, fall back to one-shot execution on Windows + - Document platform limitations in code comments + +5. **Use Node.js built-ins for file operations** + - Prefer `fs`, `path`, `child_process` over shell commands + - These are cross-platform by design + +See `src/lib/AGENTS.md` for detailed documentation on platform utilities and examples. diff --git a/src/cli/operations/dev/server.ts b/src/cli/operations/dev/server.ts index 2d4361b95..5ab9e92ea 100644 --- a/src/cli/operations/dev/server.ts +++ b/src/cli/operations/dev/server.ts @@ -2,6 +2,7 @@ import { type ChildProcess, spawn, spawnSync } from 'child_process'; import { existsSync } from 'fs'; import { createServer } from 'net'; import { join } from 'path'; +import { getVenvExecutable } from '../../../lib/utils/platform'; export type LogLevel = 'info' | 'warn' | 'error' | 'system'; @@ -35,7 +36,7 @@ function convertEntrypointToModule(entrypoint: string): string { */ function ensurePythonVenv(cwd: string, onLog: (level: LogLevel, message: string) => void): boolean { const venvPath = join(cwd, '.venv'); - const uvicornPath = join(venvPath, 'bin', 'uvicorn'); + const uvicornPath = getVenvExecutable(venvPath, 'uvicorn'); // Check if venv and uvicorn already exist if (existsSync(uvicornPath)) { @@ -92,7 +93,7 @@ export function spawnDevServer(options: SpawnDevServerOptions): ChildProcess | n } // For Python, use the venv's uvicorn directly to avoid PATH issues - const cmd = isPython ? join(cwd, '.venv', 'bin', 'uvicorn') : 'npx'; + const cmd = isPython ? getVenvExecutable(join(cwd, '.venv'), 'uvicorn') : 'npx'; const args = isPython ? [convertEntrypointToModule(module), '--reload', '--host', '127.0.0.1', '--port', String(port)] : ['tsx', 'watch', (module.split(':')[0] ?? module).replace(/\./g, '/') + '.ts']; diff --git a/src/cli/shell/persistent-shell.ts b/src/cli/shell/persistent-shell.ts new file mode 100644 index 000000000..f7ed4b2c6 --- /dev/null +++ b/src/cli/shell/persistent-shell.ts @@ -0,0 +1,308 @@ +/** + * Persistent Shell - Fast alias-capable execution path + * + * Uses 'script' command to allocate a PTY for proper output buffering. + * Without a PTY, falls back to one-shot command execution. + * + * Constraints: + * - Single command at a time (concurrent calls throw) + * - Ctrl-C kills shell entirely (next command re-warms) + */ +import { spawnShellCommand } from './executor'; +import type { ShellExecutor, ShellExecutorCallbacks } from './types'; +import { ChildProcess, spawn } from 'node:child_process'; +import { isWindows } from '../../../lib/utils/platform'; + +const MARKER = `__AGENTCORE_DONE_${process.pid}_${Date.now()}__`; + +/** Default timeout for shell commands (5 minutes) */ +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Extract environment exports from a command and mirror them to process.env. + * This allows env vars set in shell mode to be picked up by Node. + * + * Handles concatenated exports like: export FOO=xxxexport BAR=yyy + * (where exports run together without separators from pasting) + */ +function syncExports(cmd: string): void { + // Pre-process: insert newlines before 'export' keywords to handle concatenated pastes + const normalizedCmd = cmd.replace(/export\s+([A-Z_])/g, '\nexport $1'); + + // Match export statements - handle both quoted and unquoted values + // Supports: export KEY=value, export KEY="value", export KEY='value' + const regex = /export\s+([A-Z_][A-Z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s\n;]+))/gi; + let match; + while ((match = regex.exec(normalizedCmd)) !== null) { + const key = match[1]; + // Value is in group 2 (double quoted), 3 (single quoted), or 4 (unquoted) + const value = match[2] ?? match[3] ?? match[4]; + if (key && value) { + process.env[key] = value; + } + } +} + +let shell: ChildProcess | null = null; +let buffer = ''; +let activeCallback: ShellExecutorCallbacks | null = null; +let busy = false; +let timeoutHandle: ReturnType | null = null; +// PTY via 'script' command is not available on Windows +let ptyAvailable = !isWindows; + +// Pending command info for retry on PTY failure +let pendingCommand: { cmd: string; callbacks: ShellExecutorCallbacks; timeoutMs: number } | null = null; + +function onData(data: Buffer) { + const text = data.toString(); + buffer += text; + + // Detect PTY failure - this error means script command isn't working + if (text.includes('tcgetattr') || text.includes('ioctl') || text.includes('not supported on socket')) { + ptyAvailable = false; + + // If we have a pending command, retry it with one-shot mode + if (busy && pendingCommand) { + const { cmd, callbacks, timeoutMs } = pendingCommand; + pendingCommand = null; + + // Clean up the failed persistent shell + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + shell?.kill(); + shell = null; + busy = false; + activeCallback = null; + buffer = ''; + + // Retry with one-shot execution (don't call callbacks.onComplete yet) + // Note: executor is intentionally unused here - the callbacks handle completion + spawnOneShotCommand(cmd, callbacks, timeoutMs); + } + return; + } + + if (!busy || !activeCallback) return; + + // Check for completion marker first + const markerIdx = buffer.indexOf(MARKER); + if (markerIdx >= 0) { + // Extract any output before the marker + const out = buffer.slice(0, markerIdx); + const rest = buffer.slice(markerIdx + MARKER.length); + const code = parseInt(/^(\d+)/.exec(rest)?.[0] ?? '0', 10); + buffer = ''; + busy = false; + pendingCommand = null; + + // Clear timeout + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + + // Emit any remaining output + const lines = out.split(/\r?\n/).filter(Boolean); + if (lines.length) activeCallback.onOutput(lines); + activeCallback.onComplete(code); + activeCallback = null; + return; + } + + // Stream output incrementally: emit complete lines as they arrive + // Keep incomplete lines (without trailing newline) in buffer + const lastNewline = buffer.lastIndexOf('\n'); + if (lastNewline >= 0) { + const completeData = buffer.slice(0, lastNewline); + buffer = buffer.slice(lastNewline + 1); + + const lines = completeData.split(/\r?\n/).filter(Boolean); + if (lines.length) { + activeCallback.onOutput(lines); + } + } +} + +function ensureShell(): ChildProcess { + if (shell && !shell.killed) return shell; + + // PTY is not available on Windows - this should never be called on Windows + // but if it is, throw a clear error + if (!ptyAvailable) { + throw new Error('PTY not available on this platform - use one-shot execution instead'); + } + + const sh = process.env.SHELL ?? '/bin/sh'; + const home = process.env.HOME ?? ''; + + // Use 'script' to allocate a PTY, which forces line-buffered output. + // On macOS: script -q /dev/null + // On Linux: script -q /dev/null -c + const platform = process.platform; + const scriptArgs = platform === 'darwin' ? ['-q', '/dev/null', sh] : ['-q', '/dev/null', '-c', sh]; + + shell = spawn('script', scriptArgs, { + cwd: process.cwd(), + env: { ...process.env, PS1: '', PS2: '', TERM: 'dumb' }, + }); + + shell.stdout?.on('data', onData); + shell.stderr?.on('data', (data: Buffer) => { + const msg = data.toString(); + // Detect PTY failures + if (msg.includes('tcgetattr') || msg.includes('ioctl') || msg.includes('not supported')) { + ptyAvailable = false; + } + onData(data); + }); + shell.on('close', () => { + shell = null; + }); + + // Source config (output goes to buffer, will be cleared on first command) + const rc = sh.includes('zsh') + ? `source "${home}/.zshrc" 2>/dev/null` + : sh.includes('bash') + ? `[ -f ~/.bashrc ] && . ~/.bashrc; shopt -s expand_aliases` + : ''; + + if (rc) { + shell.stdin?.write(`${rc}\n`); + } + + return shell; +} + +/** Call on app start to pre-warm shell during idle time */ +export function warmup(): void { + // Skip warmup on Windows where PTY is not available + if (!ptyAvailable) { + return; + } + // Try to spawn the shell - if PTY fails, ptyAvailable will be set to false + // within the first few hundred ms via the stderr handler + ensureShell(); +} + +export interface PersistentShellOptions { + /** Timeout in milliseconds. Default: 5 minutes. Set to 0 to disable. */ + timeoutMs?: number; +} + +/** Execute command in persistent shell. Falls back to one-shot if PTY unavailable. */ +export function spawnPersistentShellCommand( + cmd: string, + callbacks: ShellExecutorCallbacks, + options?: PersistentShellOptions +): ShellExecutor { + if (busy) { + throw new Error('Shell busy: concurrent commands not supported'); + } + + // Sync exports to process.env regardless of execution mode + syncExports(cmd); + + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + // If PTY is known to be unavailable, use one-shot execution + if (!ptyAvailable) { + return spawnOneShotCommand(cmd, callbacks, timeoutMs); + } + + // Try persistent shell with PTY + const s = ensureShell(); + + // Check if PTY failed during shell creation (detected via stderr) + if (!ptyAvailable) { + shell?.kill(); + shell = null; + return spawnOneShotCommand(cmd, callbacks, timeoutMs); + } + + busy = true; + activeCallback = callbacks; + buffer = ''; + pendingCommand = { cmd, callbacks, timeoutMs }; + s.stdin?.write(`${cmd}; echo "${MARKER}$?"\n`); + + // Set up timeout if enabled + if (timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + if (busy && activeCallback) { + const minutes = Math.floor(timeoutMs / 60000); + callbacks.onError(`Command timed out after ${minutes} minute${minutes !== 1 ? 's' : ''}`); + shell?.kill(); + shell = null; + busy = false; + activeCallback = null; + timeoutHandle = null; + callbacks.onComplete(124); // 124 is standard timeout exit code + } + }, timeoutMs); + } + + const cleanup = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + shell?.kill(); + shell = null; + busy = false; + activeCallback = null; + pendingCommand = null; + }; + + return { + child: s, + kill: () => { + cleanup(); + callbacks.onComplete(130); // 130 is SIGINT exit code + }, + }; +} + +/** One-shot command execution - spawns a new shell for each command */ +function spawnOneShotCommand(cmd: string, callbacks: ShellExecutorCallbacks, timeoutMs: number): ShellExecutor { + let localTimeout: ReturnType | null = null; + + const executor = spawnShellCommand(cmd, { + onOutput: callbacks.onOutput, + onComplete: code => { + if (localTimeout) { + clearTimeout(localTimeout); + localTimeout = null; + } + callbacks.onComplete(code); + }, + onError: callbacks.onError, + }); + + // Set up timeout + if (timeoutMs > 0) { + localTimeout = setTimeout(() => { + const minutes = Math.floor(timeoutMs / 60000); + callbacks.onError(`Command timed out after ${minutes} minute${minutes !== 1 ? 's' : ''}`); + executor.kill(); + callbacks.onComplete(124); + }, timeoutMs); + } + + return executor; +} + +/** Destroy shell (cleanup on app exit) */ +export function destroyShell(): void { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + shell?.kill(); + shell = null; + busy = false; + activeCallback = null; + pendingCommand = null; +} diff --git a/src/cli/templates/CDKRenderer.ts b/src/cli/templates/CDKRenderer.ts index 39ed57900..206ba5b8f 100644 --- a/src/cli/templates/CDKRenderer.ts +++ b/src/cli/templates/CDKRenderer.ts @@ -157,13 +157,9 @@ export class CDKRenderer { delete pkg.scripts.postinstall; } } else { - // Production: use npm link - const distroConfig = getDistroConfig(); - const packageName = distroConfig.packageName; - - if (pkg.scripts?.postinstall) { - pkg.scripts.postinstall = `npm link ${packageName} 2>/dev/null || echo 'Note: If CDK synth fails, run: npm link ${packageName}'`; - } + // Production: use npm link with the L3 constructs package + // Note: The template already has the correct postinstall script, so we don't need to modify it + // Just leave it as-is from the template } await fs.writeFile(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8'); diff --git a/src/lib/AGENTS.md b/src/lib/AGENTS.md index 276516476..38434304a 100644 --- a/src/lib/AGENTS.md +++ b/src/lib/AGENTS.md @@ -14,6 +14,44 @@ Both the CLI and the CDK require this functionality. **Util**: Subprocess command utilities, zod utilities, and OS utilities. +## Cross-Platform Support + +The CLI is designed to work on both Windows and Unix-like systems (Linux, macOS). Platform-specific differences are +abstracted through utilities in `lib/utils/platform.ts`. + +### Platform Utilities + +**Key utilities for cross-platform development:** + +- `isWindows`, `isMacOS`, `isLinux` - Platform detection flags +- `getVenvExecutable(venvPath, executable)` - Get correct path to Python venv executables + - Unix: `.venv/bin/python`, `.venv/bin/uvicorn` + - Windows: `.venv\Scripts\python.exe`, `.venv\Scripts\uvicorn.exe` +- `getShellCommand()` - Get platform-appropriate shell command +- `getShellArgs(command)` - Get platform-appropriate shell arguments +- `normalizeCommand(command)` - Add .exe extension on Windows when needed + +### Guidelines for Cross-Platform Code + +1. **Never hardcode Unix paths** - Use `getVenvExecutable()` for Python venv paths +2. **Use platform utilities** - Import from `lib/utils/platform` instead of checking `process.platform` directly +3. **Test on both platforms** - Ensure features work on Windows and Unix +4. **Avoid Unix-specific commands** - Use Node.js APIs or cross-platform alternatives (e.g., Node.js fs instead of `rm -rf`) +5. **Document platform differences** - Add comments explaining platform-specific behavior + +### Example + +```typescript +import { getVenvExecutable, isWindows } from '../lib/utils/platform'; + +// ❌ BAD: Hardcoded Unix path +const uvicorn = join(venvPath, 'bin', 'uvicorn'); + +// ✅ GOOD: Cross-platform +const uvicorn = getVenvExecutable(venvPath, 'uvicorn'); +// Returns: .venv/bin/uvicorn on Unix, .venv\Scripts\uvicorn.exe on Windows +``` + ## Future Direction Functionality such as imperative code implementations to write secret values into the AgentCore Identity primitive would diff --git a/src/lib/utils/platform.ts b/src/lib/utils/platform.ts index 9c9ec8ee6..3bf2f1d36 100644 --- a/src/lib/utils/platform.ts +++ b/src/lib/utils/platform.ts @@ -1 +1,86 @@ +import { join } from 'node:path'; + +/** + * Platform detection utilities and cross-platform path helpers. + * + * This module provides utilities to handle platform-specific differences + * between Windows and Unix-like systems (Linux, macOS). + * + * Key differences handled: + * - Python venv structure: bin/ (Unix) vs Scripts/ (Windows) + * - Executable extensions: none (Unix) vs .exe, .cmd, .bat (Windows) + * - Shell commands: sh/bash (Unix) vs cmd/powershell (Windows) + */ + export const isWindows = process.platform === 'win32'; +export const isMacOS = process.platform === 'darwin'; +export const isLinux = process.platform === 'linux'; + +/** + * Get the path to an executable in a Python virtual environment. + * + * Python virtual environments have different structures on different platforms: + * - Unix (Linux/macOS): .venv/bin/python, .venv/bin/uvicorn + * - Windows: .venv\Scripts\python.exe, .venv\Scripts\uvicorn.exe + * + * @param venvPath - Path to the virtual environment directory (e.g., '.venv') + * @param executable - Name of the executable without extension (e.g., 'python', 'uvicorn') + * @returns Full path to the executable with correct directory and extension + * + * @example + * ```ts + * // On Unix: /path/to/project/.venv/bin/uvicorn + * // On Windows: C:\path\to\project\.venv\Scripts\uvicorn.exe + * const uvicornPath = getVenvExecutable('.venv', 'uvicorn'); + * ``` + */ +export function getVenvExecutable(venvPath: string, executable: string): string { + const binDir = isWindows ? 'Scripts' : 'bin'; + const ext = isWindows ? '.exe' : ''; + return join(venvPath, binDir, executable + ext); +} + +/** + * Get the appropriate shell command for the current platform. + * + * @returns The default shell command ('cmd' on Windows, 'sh' on Unix) + */ +export function getShellCommand(): string { + return isWindows ? 'cmd' : (process.env.SHELL ?? '/bin/sh'); +} + +/** + * Get the appropriate shell arguments for executing a command. + * + * @param command - The command to execute + * @returns Array of arguments to pass to the shell + * + * @example + * ```ts + * // On Unix: ['-c', 'echo hello'] + * // On Windows: ['/c', 'echo hello'] + * const args = getShellArgs('echo hello'); + * spawn(getShellCommand(), args); + * ``` + */ +export function getShellArgs(command: string): string[] { + return isWindows ? ['/c', command] : ['-c', command]; +} + +/** + * Normalize a command for cross-platform execution. + * Adds .exe extension on Windows if needed. + * + * @param command - The command name + * @returns The command with appropriate extension + */ +export function normalizeCommand(command: string): string { + if (isWindows && !command.endsWith('.exe') && !command.endsWith('.cmd') && !command.endsWith('.bat')) { + // Check if it's a known command that needs .exe + const exeCommands = ['python', 'node', 'npm', 'git', 'uvicorn', 'pip']; + if (exeCommands.some(cmd => command.endsWith(cmd))) { + return command + '.exe'; + } + } + return command; +}