diff --git a/src/lib/packaging/__tests__/helpers.test.ts b/src/lib/packaging/__tests__/helpers.test.ts index efb8e88c8..a554edb0e 100644 --- a/src/lib/packaging/__tests__/helpers.test.ts +++ b/src/lib/packaging/__tests__/helpers.test.ts @@ -16,7 +16,9 @@ import { resolveProjectPaths, resolveProjectPathsSync, } from '../helpers.js'; +import { unzipSync } from 'fflate'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { readFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -388,3 +390,129 @@ describe('convertWindowsScriptsToLinux (shebang rewriting on non-Windows)', () = expect(existsSync(join(staging, 'bin'))).toBe(false); }); }); + +// ── Issue #843: nested agentcore directory exclusion ──────────────── + +describe('nested agentcore directory is preserved (issue #843)', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'helpers-nested-agentcore-')); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + /** + * Helper: build a source tree that mimics a real project with a + * top-level agentcore/ config dir AND a third-party dependency + * that ships its own agentcore/ sub-module. + * + * / + * main.py + * agentcore/ ← should be excluded (project config dir) + * config.yaml + * lib/ + * langgraph_checkpoint_aws/ + * __init__.py + * agentcore/ ← should be INCLUDED (dependency sub-module) + * __init__.py + * core.py + */ + function buildFixture(base: string): string { + const src = join(base, 'src'); + + // Top-level source file + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'main.py'), 'print("hello")'); + + // Top-level agentcore/ (project config — should be excluded) + mkdirSync(join(src, 'agentcore'), { recursive: true }); + writeFileSync(join(src, 'agentcore', 'config.yaml'), 'key: value'); + + // Nested dependency with its own agentcore/ sub-module + const nestedAgentcore = join(src, 'lib', 'langgraph_checkpoint_aws', 'agentcore'); + mkdirSync(nestedAgentcore, { recursive: true }); + writeFileSync(join(src, 'lib', 'langgraph_checkpoint_aws', '__init__.py'), '# init'); + writeFileSync(join(nestedAgentcore, '__init__.py'), '# agentcore init'); + writeFileSync(join(nestedAgentcore, 'core.py'), 'class Core: pass'); + + return src; + } + + // ── copySourceTree (async) ── + + it('excludes top-level agentcore/ but includes nested agentcore/', async () => { + const src = buildFixture(join(root, 'copy-async')); + const dest = join(root, 'copy-async-dest'); + mkdirSync(dest, { recursive: true }); + + await copySourceTree(src, dest); + + // Top-level agentcore/ excluded + expect(existsSync(join(dest, 'agentcore'))).toBe(false); + + // Source file preserved + expect(existsSync(join(dest, 'main.py'))).toBe(true); + + // Nested agentcore/ inside dependency preserved + expect(existsSync(join(dest, 'lib', 'langgraph_checkpoint_aws', 'agentcore', '__init__.py'))).toBe(true); + expect(existsSync(join(dest, 'lib', 'langgraph_checkpoint_aws', 'agentcore', 'core.py'))).toBe(true); + }); + + // ── copySourceTreeSync ── + + it('sync: excludes top-level agentcore/ but includes nested agentcore/', () => { + const src = buildFixture(join(root, 'copy-sync')); + const dest = join(root, 'copy-sync-dest'); + mkdirSync(dest, { recursive: true }); + + copySourceTreeSync(src, dest); + + expect(existsSync(join(dest, 'agentcore'))).toBe(false); + expect(existsSync(join(dest, 'main.py'))).toBe(true); + expect(existsSync(join(dest, 'lib', 'langgraph_checkpoint_aws', 'agentcore', '__init__.py'))).toBe(true); + expect(existsSync(join(dest, 'lib', 'langgraph_checkpoint_aws', 'agentcore', 'core.py'))).toBe(true); + }); + + // ── createZipFromDir (async) ── + + it('zip: excludes top-level agentcore/ but includes nested agentcore/', async () => { + const src = buildFixture(join(root, 'zip-async')); + const zipPath = join(root, 'zip-async.zip'); + + await createZipFromDir(src, zipPath); + + const zipBuffer = await readFile(zipPath); + const entries = Object.keys(unzipSync(new Uint8Array(zipBuffer))); + + // Top-level agentcore/ should NOT appear + expect(entries.some(e => e === 'agentcore/config.yaml')).toBe(false); + expect(entries.some(e => e.startsWith('agentcore/'))).toBe(false); + + // Nested agentcore/ SHOULD appear + expect(entries).toContain('lib/langgraph_checkpoint_aws/agentcore/__init__.py'); + expect(entries).toContain('lib/langgraph_checkpoint_aws/agentcore/core.py'); + + // Regular files present + expect(entries).toContain('main.py'); + }); + + // ── createZipFromDirSync ── + + it('sync zip: excludes top-level agentcore/ but includes nested agentcore/', () => { + const src = buildFixture(join(root, 'zip-sync')); + const zipPath = join(root, 'zip-sync.zip'); + + createZipFromDirSync(src, zipPath); + + const zipBuffer = readFileSync(zipPath); + const entries = Object.keys(unzipSync(new Uint8Array(zipBuffer))); + + expect(entries.some(e => e.startsWith('agentcore/'))).toBe(false); + expect(entries).toContain('lib/langgraph_checkpoint_aws/agentcore/__init__.py'); + expect(entries).toContain('lib/langgraph_checkpoint_aws/agentcore/core.py'); + expect(entries).toContain('main.py'); + }); +}); diff --git a/src/lib/packaging/helpers.ts b/src/lib/packaging/helpers.ts index b2a2fe902..31c74b298 100644 --- a/src/lib/packaging/helpers.ts +++ b/src/lib/packaging/helpers.ts @@ -51,15 +51,7 @@ interface ResolvedPaths { artifactsDir: string; } -const EXCLUDED_ENTRIES = new Set([ - 'agentcore', - '.git', - '.venv', - '__pycache__', - '.pytest_cache', - '.DS_Store', - 'node_modules', -]); +const EXCLUDED_ENTRIES = new Set(['.git', '.venv', '__pycache__', '.pytest_cache', '.DS_Store', 'node_modules']); export const MAX_ZIP_SIZE_BYTES = 250 * 1024 * 1024; @@ -147,7 +139,7 @@ export async function ensureDirClean(dir: string): Promise { await mkdir(dir, { recursive: true }); } -async function copyEntry(source: string, destination: string): Promise { +async function copyEntry(source: string, destination: string, rootDir: string): Promise { const stats = await stat(source); if (stats.isDirectory()) { await mkdir(destination, { recursive: true }); @@ -156,7 +148,10 @@ async function copyEntry(source: string, destination: string): Promise { if (EXCLUDED_ENTRIES.has(entry)) { continue; } - await copyEntry(join(source, entry), join(destination, entry)); + if (entry === CONFIG_DIR && resolve(source) === resolve(rootDir)) { + continue; + } + await copyEntry(join(source, entry), join(destination, entry), rootDir); } return; } @@ -170,7 +165,7 @@ export async function copySourceTree(srcDir: string, destination: string): Promi if (!(await pathExists(srcDir))) { throw new MissingProjectFileError(srcDir); } - await copyEntry(srcDir, destination); + await copyEntry(srcDir, destination, srcDir); } export async function ensureBinaryAvailable(binary: string, installHint?: string): Promise { @@ -197,23 +192,24 @@ export async function createZipFromDir(sourceDir: string, outputZip: string): Pr await rm(outputZip, { force: true }); await mkdir(dirname(outputZip), { recursive: true }); - const files = await collectFiles(sourceDir); + const files = await collectFiles(sourceDir, sourceDir); const zipped = zipSync(files); await writeFile(outputZip, zipped); } -async function collectFiles(directory: string, basePath = ''): Promise { +async function collectFiles(directory: string, rootDir: string, basePath = ''): Promise { const result: Zippable = {}; const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { if (EXCLUDED_ENTRIES.has(entry.name)) continue; + if (entry.name === CONFIG_DIR && resolve(directory) === resolve(rootDir)) continue; const fullPath = join(directory, entry.name); const zipPath = basePath ? `${basePath}/${entry.name}` : entry.name; if (entry.isDirectory()) { - Object.assign(result, await collectFiles(fullPath, zipPath)); + Object.assign(result, await collectFiles(fullPath, rootDir, zipPath)); } else if (entry.isFile()) { result[zipPath] = [await readFile(fullPath), { level: 6 }]; } @@ -286,7 +282,7 @@ export function ensureDirCleanSync(dir: string): void { mkdirSync(dir, { recursive: true }); } -function copyEntrySync(source: string, destination: string): void { +function copyEntrySync(source: string, destination: string, rootDir: string): void { const stats = statSync(source); if (stats.isDirectory()) { mkdirSync(destination, { recursive: true }); @@ -295,7 +291,10 @@ function copyEntrySync(source: string, destination: string): void { if (EXCLUDED_ENTRIES.has(entry)) { continue; } - copyEntrySync(join(source, entry), join(destination, entry)); + if (entry === CONFIG_DIR && resolve(source) === resolve(rootDir)) { + continue; + } + copyEntrySync(join(source, entry), join(destination, entry), rootDir); } return; } @@ -307,7 +306,7 @@ export function copySourceTreeSync(srcDir: string, destination: string): void { if (!pathExistsSync(srcDir)) { throw new MissingProjectFileError(srcDir); } - copyEntrySync(srcDir, destination); + copyEntrySync(srcDir, destination, srcDir); } export function ensureBinaryAvailableSync(binary: string, installHint?: string): void { @@ -326,18 +325,19 @@ export function ensureBinaryAvailableSync(binary: string, installHint?: string): throw new MissingDependencyError(binary, installHint); } -function collectFilesSync(directory: string, basePath = ''): Zippable { +function collectFilesSync(directory: string, rootDir: string, basePath = ''): Zippable { const result: Zippable = {}; const entries = readdirSync(directory, { withFileTypes: true }); for (const entry of entries) { if (EXCLUDED_ENTRIES.has(entry.name)) continue; + if (entry.name === CONFIG_DIR && resolve(directory) === resolve(rootDir)) continue; const fullPath = join(directory, entry.name); const zipPath = basePath ? `${basePath}/${entry.name}` : entry.name; if (entry.isDirectory()) { - Object.assign(result, collectFilesSync(fullPath, zipPath)); + Object.assign(result, collectFilesSync(fullPath, rootDir, zipPath)); } else if (entry.isFile()) { result[zipPath] = [readFileSync(fullPath), { level: 6 }]; } @@ -349,7 +349,7 @@ export function createZipFromDirSync(sourceDir: string, outputZip: string): void rmSync(outputZip, { force: true }); mkdirSync(dirname(outputZip), { recursive: true }); - const files = collectFilesSync(sourceDir); + const files = collectFilesSync(sourceDir, sourceDir); const zipped = zipSync(files); writeFileSync(outputZip, zipped); } diff --git a/src/lib/packaging/types/fflate.d.ts b/src/lib/packaging/types/fflate.d.ts deleted file mode 100644 index 32720ed88..000000000 --- a/src/lib/packaging/types/fflate.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare module 'fflate' { - export interface ZipOptions { - level?: number; - } - - export type ZippableFile = Uint8Array | string | [Uint8Array | string, ZipOptions]; - - export interface Zippable { - [path: string]: ZippableFile | Zippable; - } - - export function zipSync(data: Zippable, options?: ZipOptions): Uint8Array; -}