From 657aaf064ff2415cdbba8842b2b9dffa23683e91 Mon Sep 17 00:00:00 2001 From: Ian Hou <45278651+iankhou@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:46:21 -0400 Subject: [PATCH] feat(cli): git repository support for custom init templates --- .../@aws-cdk/user-input-gen/lib/yargs-gen.ts | 13 +- .../user-input-gen/lib/yargs-types.ts | 1 + packages/aws-cdk/lib/cli/cli-config.ts | 23 +- .../aws-cdk/lib/cli/cli-type-registry.json | 12 +- packages/aws-cdk/lib/cli/cli.ts | 3 +- .../aws-cdk/lib/cli/convert-to-user-input.ts | 2 + .../lib/cli/parse-command-line-arguments.ts | 17 + packages/aws-cdk/lib/cli/user-input.ts | 7 + packages/aws-cdk/lib/commands/init/init.ts | 157 +++- packages/aws-cdk/test/commands/init.test.ts | 715 +++++++++++++++++- 10 files changed, 890 insertions(+), 60 deletions(-) diff --git a/packages/@aws-cdk/user-input-gen/lib/yargs-gen.ts b/packages/@aws-cdk/user-input-gen/lib/yargs-gen.ts index a40f0c789..af492bca8 100644 --- a/packages/@aws-cdk/user-input-gen/lib/yargs-gen.ts +++ b/packages/@aws-cdk/user-input-gen/lib/yargs-gen.ts @@ -105,10 +105,6 @@ function makeYargs(config: CliConfig, helpers: CliHelpers): Statement { } commandCallArgs.push(lit(commandFacts.description)); - if (commandFacts.options) { - commandCallArgs.push(optionsExpr); - } - // Add implies calls if present if (commandFacts.implies) { for (const [key, value] of Object.entries(commandFacts.implies)) { @@ -116,6 +112,15 @@ function makeYargs(config: CliConfig, helpers: CliHelpers): Statement { } } + // Add check function if present + if (commandFacts.check) { + optionsExpr = optionsExpr.callMethod('check', code.expr.directCode(commandFacts.check.toString())); + } + + if (commandFacts.options || commandFacts.check) { + commandCallArgs.push(optionsExpr); + } + yargsExpr = yargsExpr.callMethod('command', ...commandCallArgs); } diff --git a/packages/@aws-cdk/user-input-gen/lib/yargs-types.ts b/packages/@aws-cdk/user-input-gen/lib/yargs-types.ts index a19fff94e..fc36c7866 100644 --- a/packages/@aws-cdk/user-input-gen/lib/yargs-types.ts +++ b/packages/@aws-cdk/user-input-gen/lib/yargs-types.ts @@ -8,6 +8,7 @@ interface YargsCommand { export interface CliAction extends YargsCommand { options?: { [optionName: string]: CliOption }; implies?: { [key: string]: string }; + check?: (argv: any) => boolean | undefined; } interface YargsArg { diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index fbe1aefcb..9ad534989 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -8,6 +8,13 @@ import { getLanguageAlias } from '../commands/language'; export const YARGS_HELPERS = new CliHelpers('./util/yargs-helpers'); +interface InitCommandArgs { + 'template-path'?: string; + 'from-path'?: string; + 'from-git-url'?: string; + [key: string]: unknown; +} + /** * Source of truth for all CDK CLI commands. `user-input-gen` translates this into: * @@ -406,8 +413,22 @@ export async function makeConfig(): Promise { 'lib-version': { type: 'string', alias: 'V', default: undefined, desc: 'The version of the CDK library (aws-cdk-lib) to initialize built-in templates with. Defaults to the version that was current when this CLI was built.' }, 'from-path': { type: 'string', desc: 'Path to a local custom template directory or multi-template repository', requiresArg: true, conflicts: ['lib-version'] }, 'template-path': { type: 'string', desc: 'Path to a specific template within a multi-template repository', requiresArg: true }, + 'from-git-url': { type: 'string', desc: 'Git repository URL to clone custom template from', requiresArg: true, conflicts: ['lib-version', 'from-path'] }, + }, + check: (argv: InitCommandArgs) => { + const hasTemplatePath = Boolean(argv['template-path']); + const hasValidSource = Boolean(argv['from-path'] || argv['from-git-url']); + + if (hasTemplatePath && !hasValidSource) { + const e = new Error( + '--template-path can only be used with --from-path or --from-git-url', + ); + e.name = 'ValidationError'; + throw e; + } + + return true; }, - implies: { 'template-path': 'from-path' }, }, 'migrate': { description: 'Migrate existing AWS resources into a CDK app', diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 3d7cbb488..32db2bb34 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -897,10 +897,16 @@ "type": "string", "desc": "Path to a specific template within a multi-template repository", "requiresArg": true + }, + "from-git-url": { + "type": "string", + "desc": "Git repository URL to clone custom template from", + "requiresArg": true, + "conflicts": [ + "lib-version", + "from-path" + ] } - }, - "implies": { - "template-path": "from-path" } }, "migrate": { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index b60fc69ec..a8cc836b2 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -547,7 +547,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { type: 'string', desc: 'Path to a specific template within a multi-template repository', requiresArg: true, + }) + .option('from-git-url', { + default: undefined, + type: 'string', + desc: 'Git repository URL to clone custom template from', + requiresArg: true, + conflicts: ['lib-version', 'from-path'], + }) + .check((argv) => { + const hasTemplatePath = Boolean(argv['template-path']); + const hasValidSource = Boolean(argv['from-path'] || argv['from-git-url']); + if (hasTemplatePath && !hasValidSource) { + const e = new Error('--template-path can only be used with --from-path or --from-git-url'); + e.name = 'ValidationError'; + throw e; + } + return true; }), ) .command('migrate', 'Migrate existing AWS resources into a CDK app', (yargs: Argv) => diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 6a18b5873..4fd4e00be 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -1401,6 +1401,13 @@ export interface InitOptions { */ readonly templatePath?: string; + /** + * Git repository URL to clone custom template from + * + * @default - undefined + */ + readonly fromGitUrl?: string; + /** * Positional argument for init */ diff --git a/packages/aws-cdk/lib/commands/init/init.ts b/packages/aws-cdk/lib/commands/init/init.ts index 76ac95c26..a985ca5cd 100644 --- a/packages/aws-cdk/lib/commands/init/init.ts +++ b/packages/aws-cdk/lib/commands/init/init.ts @@ -1,4 +1,5 @@ import * as childProcess from 'child_process'; +import * as os from 'os'; import * as path from 'path'; import { ToolkitError } from '@aws-cdk/toolkit-lib'; import * as chalk from 'chalk'; @@ -70,8 +71,14 @@ export interface CliInitOptions { readonly fromPath?: string; /** - * Path to a specific template within a multi-template repository. - * This parameter requires --from-path to be specified. + * Git repository URL to clone and use as template source + * @default undefined + */ + readonly fromGitUrl?: string; + + /** + * Path to a template within a multi-template repository. + * This parameter requires an origin to be specified using --from-path or --from-git-url. * @default undefined */ readonly templatePath?: string; @@ -89,34 +96,49 @@ export async function cliInit(options: CliInitOptions) { const workDir = options.workDir ?? process.cwd(); // Show available templates only if no fromPath, type, or language provided - if (!options.fromPath && !options.type && !options.language) { + if (!options.fromPath && !options.fromGitUrl && !options.type && !options.language) { await printAvailableTemplates(ioHelper); return; } - // Step 1: Load template - let template: InitTemplate; - if (options.fromPath) { - template = await loadLocalTemplate(options.fromPath, options.templatePath); - } else { - template = await loadBuiltinTemplate(ioHelper, options.type, options.language); - } - - // Step 2: Resolve language - const language = await resolveLanguage(ioHelper, template, options.language, options.type); - - // Step 3: Initialize project following standard process - await initializeProject( - ioHelper, - template, - language, - canUseNetwork, - generateOnly, - workDir, - options.stackName, - options.migrate, - options.libVersion, - ); + // temporarily store git repo if pulling from remote + let gitTempDir: string | undefined; + + try { + // Step 1: Load template + let template: InitTemplate; + if (options.fromPath) { + template = await loadLocalTemplate(options.fromPath, options.templatePath); + } else if (options.fromGitUrl) { + gitTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-init-git-')); + template = await loadGitTemplate(gitTempDir, options.fromGitUrl, options.templatePath); + } else { + template = await loadBuiltinTemplate(ioHelper, options.type, options.language); + } + + // Step 2: Resolve language + const language = await resolveLanguage(ioHelper, template, options.language, options.type); + + // Step 3: Initialize project following standard process + await initializeProject( + ioHelper, + template, + language, + canUseNetwork, + generateOnly, + workDir, + options.stackName, + options.migrate, + options.libVersion, + ); + } finally { + // Clean up temporary directory after everything is done + if (gitTempDir) { + await fs.remove(gitTempDir).catch(async (error: any) => { + await ioHelper.defaults.warn(`Could not remove temporary directory ${gitTempDir}: ${error.message}`); + }); + } + } } /** @@ -160,6 +182,28 @@ async function loadLocalTemplate(fromPath: string, templatePath?: string): Promi } } +/** + * Load a template from a Git repository URL + * @param gitUrl - Git repository URL to clone + * @param templatePath - Optional path to a specific template within the repository + * @returns Promise resolving to the InitTemplate + */ +async function loadGitTemplate(tempDir: string, gitUrl: string, templatePath?: string): Promise { + try { + await executeGitCommand('git', ['clone', '--depth', '1', gitUrl, tempDir]); + + let fullTemplatePath = tempDir; + if (templatePath) { + fullTemplatePath = path.join(tempDir, templatePath); + } + const template = await InitTemplate.fromPath(fullTemplatePath); + return template; + } catch (error: any) { + const displayPath = templatePath ? `${gitUrl}/${templatePath}` : gitUrl; + throw new ToolkitError(`Failed to load template from Git repository: ${displayPath}. ${error.message}`); + } +} + /** * Load a built-in template by name */ @@ -188,8 +232,9 @@ async function resolveLanguage(ioHelper: IoHelper, template: InitTemplate, reque return (async () => { if (requestedLanguage) { return requestedLanguage; - } - if (template.languages.length === 1) { + } else if (template.languages.length === 0) { + throw new ToolkitError('Custom template must contain at least one language directory'); + } else if (template.languages.length === 1) { const templateLanguage = template.languages[0]; // Only show auto-detection message for built-in templates if (template.templateType !== TemplateType.CUSTOM) { @@ -892,6 +937,62 @@ function isRoot(dir: string) { return path.dirname(dir) === dir; } +/** + * Execute a Git command with timeout + * @param cmd - Git command to execute + * @param args - Command arguments + * @returns Promise resolving to stdout + */ +async function executeGitCommand(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = childProcess.spawn(cmd, args, { + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let killed = false; + + // Handle process errors + child.on('error', (err) => { + reject(new ToolkitError(`Failed to execute Git command: ${err.message}`)); + }); + + // Collect stdout + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + // Collect stderr + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + // Handle process completion + child.on('exit', (code, signal) => { + if (killed) { + return; + } + + if (code === 0) { + resolve(stdout.trim()); + } else { + const errorMessage = stderr.trim() || stdout.trim(); + reject(new ToolkitError( + `Git command failed with ${signal ? `signal ${signal}` : `code ${code}`}: ${errorMessage}`, + )); + } + }); + + child.on('close', () => { + child.stdout.removeAllListeners(); + child.stderr.removeAllListeners(); + child.removeAllListeners(); + }); + }); +} + /** * Executes `command`. STDERR is emitted in real-time. * diff --git a/packages/aws-cdk/test/commands/init.test.ts b/packages/aws-cdk/test/commands/init.test.ts index 863ed89a9..93d35e822 100644 --- a/packages/aws-cdk/test/commands/init.test.ts +++ b/packages/aws-cdk/test/commands/init.test.ts @@ -1,3 +1,4 @@ +import * as childProcess from 'child_process'; import * as os from 'os'; import * as path from 'path'; import * as cxapi from '@aws-cdk/cx-api'; @@ -9,7 +10,25 @@ import { TestIoHost } from '../_helpers/io-host'; const ioHost = new TestIoHost(); const ioHelper = ioHost.asHelper('init'); -describe('constructs version', () => { +function createMockChildProcess(exitCode?: number, signal?: string, stderr?: string) { + return { + stdout: { on: jest.fn() }, + stderr: { + on: jest.fn((event, callback) => { + if (event === 'data' && stderr) { + callback(stderr); + } + }), + }, + on: jest.fn((event, callback) => { + if (event === 'exit') { + setTimeout(() => callback(exitCode, signal), 10); + } + }), + }; +} + +describe('cdk init', () => { cliTest('shows available templates when no parameters provided', async (workDir) => { // Test that calling cdk init without any parameters shows available templates await cliInit({ @@ -729,30 +748,51 @@ describe('constructs version', () => { })).rejects.toThrow(); }); - cliTest('template-path implies from-path validation works', async (workDir) => { - // Test that the implication is properly configured + cliTest('template-path validation requires from-path or from-git-url', async () => { + // Test that the check function properly validates template-path usage const { makeConfig } = await import('../../lib/cli/cli-config'); const config = await makeConfig(); - expect(config.commands.init.implies).toEqual({ 'template-path': 'from-path' }); - - // Test that template-path functionality works when from-path is provided - const repoDir = await createMultiTemplateRepository(workDir, [ - { name: 'implies-test', languages: ['typescript'] }, - ]); - const projectDir = path.join(workDir, 'my-project'); - await fs.mkdirp(projectDir); - - await cliInit({ - ioHelper, - fromPath: repoDir, - templatePath: 'implies-test', - language: 'typescript', - canUseNetwork: false, - generateOnly: true, - workDir: projectDir, - }); - - expect(await fs.pathExists(path.join(projectDir, 'app.ts'))).toBeTruthy(); + const checkFunction = config.commands.init.check; + + // Ensure check function exists + expect(checkFunction).toBeDefined(); + + // Test that template-path without from-path or from-git-url throws error + expect(() => checkFunction!({ + 'template-path': 'some-template', + })).toThrow('--template-path can only be used with --from-path or --from-git-url'); + + // Test that template-path with from-path is valid + expect(checkFunction!({ + 'template-path': 'some-template', + 'from-path': '/some/path', + })).toBe(true); + + // Test that template-path with from-git-url is valid + expect(checkFunction!({ + 'template-path': 'some-template', + 'from-git-url': 'https://github.com/user/repo.git', + })).toBe(true); + + // Test that template-path with both from-path and from-git-url is valid + expect(checkFunction!({ + 'template-path': 'some-template', + 'from-path': '/some/path', + 'from-git-url': 'https://github.com/user/repo.git', + })).toBe(true); + + // Test that no template-path is valid (no validation needed) + expect(checkFunction!({ + 'from-path': '/some/path', + })).toBe(true); + + // Test that no template-path is valid (no validation needed) + expect(checkFunction!({ + 'from-git-url': 'https://github.com/user/repo.git', + })).toBe(true); + + // Test that empty args is valid + expect(checkFunction!({})).toBe(true); }); cliTest('hook files are ignored during template copy', async (workDir) => { @@ -1310,6 +1350,635 @@ describe('constructs version', () => { expect(cdkJson.context).toHaveProperty('cdk-migrate', true); }); + cliTest('loads template from git repository URL', async (workDir) => { + // Mock git clone command to simulate successful git repository cloning + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + // Simulate successful git clone by creating template files in the target directory + const targetDir = args[args.length - 1]; // Last argument is the target directory + const tsDir = path.join(targetDir, 'typescript'); + fs.mkdirpSync(tsDir); + fs.writeFileSync(path.join(tsDir, 'app.ts'), 'console.log("Git template");'); + fs.writeFileSync(path.join(tsDir, 'package.json'), '{}'); + + // Return a mock child process + const mockChild = { + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + on: jest.fn((event, callback) => { + if (event === 'exit') { + setTimeout(() => callback(0), 10); // Simulate successful exit + } + }), + }; + return mockChild; + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/cdk-templates.git', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir, + }); + + expect(await fs.pathExists(path.join(workDir, 'app.ts'))).toBeTruthy(); + expect(spawnSpy).toHaveBeenCalledWith('git', ['clone', '--depth', '1', 'https://github.com/user/cdk-templates.git', expect.any(String)], expect.any(Object)); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('loads template from git repository with template path', async (workDir) => { + // Mock git clone command to simulate multi-template git repository + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + const targetDir = args[args.length - 1]; + + // Create multi-template structure + const template1Dir = path.join(targetDir, 'web-template', 'typescript'); + const template2Dir = path.join(targetDir, 'api-template', 'python'); + fs.mkdirpSync(template1Dir); + fs.mkdirpSync(template2Dir); + + fs.writeFileSync(path.join(template1Dir, 'app.ts'), 'console.log("Web template");'); + fs.writeFileSync(path.join(template2Dir, 'app.py'), 'print("API template")'); + + return createMockChildProcess(0); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/multi-templates.git', + templatePath: 'web-template', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir, + }); + + expect(await fs.pathExists(path.join(workDir, 'app.ts'))).toBeTruthy(); + expect(spawnSpy).toHaveBeenCalledWith( + 'git', + ['clone', '--depth', '1', 'https://github.com/user/multi-templates.git', expect.any(String)], + expect.any(Object), + ); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('handles git clone failure gracefully', async (workDir) => { + // Mock git clone command to simulate failure + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + return createMockChildProcess(128, undefined, 'fatal: repository not found'); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await expect(cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/nonexistent/repo.git', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir, + })).rejects.toThrow(/Failed to load template from Git repository.*fatal: repository not found/); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('handles git command spawn error', async (workDir) => { + // Mock git clone command to simulate spawn error + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + const mockChild = { + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + on: jest.fn((event, callback) => { + if (event === 'error') { + setTimeout(() => callback(new Error('ENOENT: git command not found')), 10); + } + }), + kill: jest.fn(), + }; + return mockChild; + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await expect(cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/repo.git', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir, + })).rejects.toThrow(/Failed to execute Git command: ENOENT: git command not found/); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('cleans up git temporary directory on success', async (workDir) => { + // Test that temporary directory is cleaned up after successful git template loading + let tempDirPath: string = ''; + let removeCalled = false; + + // Mock fs.remove to track cleanup calls + const mockRemove = jest.spyOn(fs, 'remove').mockImplementation(async (my_path: string) => { + if (my_path === tempDirPath) { + removeCalled = true; + } + return Promise.resolve(); + }); + + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + tempDirPath = args[args.length - 1]; + const tsDir = path.join(tempDirPath, 'typescript'); + fs.mkdirpSync(tsDir); + fs.writeFileSync(path.join(tsDir, 'app.ts'), 'console.log("Git template");'); + + return createMockChildProcess(0); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/repo.git', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir, + }); + + expect(removeCalled).toBeTruthy(); + expect(mockRemove).toHaveBeenCalledWith(tempDirPath); + } finally { + spawnSpy.mockRestore(); + mockRemove.mockRestore(); + } + }); + + cliTest('cleans up git temporary directory on failure', async (workDir) => { + // Test that temporary directory is cleaned up even when template loading fails + let tempDirPath: string = ''; + let removeCalled = false; + + // Mock fs.remove to track cleanup calls + const mockRemove = jest.spyOn(fs, 'remove').mockImplementation(async (my_path: string) => { + if (my_path === tempDirPath) { + removeCalled = true; + } + return Promise.resolve(); + }); + + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + tempDirPath = args[args.length - 1]; + // Create temp dir but don't create valid template files to cause failure + fs.mkdirpSync(tempDirPath); + + return createMockChildProcess(0); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await expect(cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/repo.git', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir, + })).rejects.toThrow(); + + expect(removeCalled).toBeTruthy(); + expect(mockRemove).toHaveBeenCalledWith(tempDirPath); + } finally { + spawnSpy.mockRestore(); + mockRemove.mockRestore(); + } + }); + + cliTest('handles git temporary directory cleanup failure', async (workDir) => { + // Test that cleanup failure is handled gracefully and doesn't prevent completion + // Mock fs.remove to simulate cleanup failure + const mockRemove = jest.spyOn(fs, 'remove') + .mockRejectedValue(new Error('Permission denied: cannot remove directory')); + + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + const tempDirPath = args[args.length - 1]; + const tsDir = path.join(tempDirPath, 'typescript'); + fs.mkdirpSync(tsDir); + fs.writeFileSync(path.join(tsDir, 'app.ts'), 'console.log("Git template");'); + + return createMockChildProcess(0); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + // Should complete successfully even if cleanup fails + await cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/repo.git', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir, + }); + + expect(await fs.pathExists(path.join(workDir, 'app.ts'))).toBeTruthy(); + } finally { + spawnSpy.mockRestore(); + mockRemove.mockRestore(); + } + }); + + cliTest('handles git clone with signal termination', async (workDir) => { + // Test git command terminated by signal + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + // Using createMockChildProcess with signal termination + return createMockChildProcess(undefined, 'SIGTERM', 'Interrupted by signal'); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await expect(cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/repo.git', + // Remove the language parameter since we want to test git clone failure + canUseNetwork: false, + generateOnly: true, + workDir, + })).rejects.toThrow(/Git command failed with signal SIGTERM: Interrupted by signal/); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('handles git repository initialization failure', async (workDir) => { + // Test git init failure during repository initialization + let gitInitCalled = false; + + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'init') { + gitInitCalled = true; + return createMockChildProcess(1); // Simulate git init failure + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + // Should complete successfully even if git init fails + await cliInit({ + ioHelper, + type: 'app', + language: 'typescript', + canUseNetwork: false, + generateOnly: false, + workDir, + }); + + expect(gitInitCalled).toBeTruthy(); + expect(await fs.pathExists(path.join(workDir, 'package.json'))).toBeTruthy(); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('skips git initialization when already in git repository', async (workDir) => { + // Test that git init is skipped when already in a git repository + let gitInitCalled = false; + + // Create a .git directory to simulate existing git repository + await fs.mkdirp(path.join(workDir, '.git')); + + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'init') { + gitInitCalled = true; + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await cliInit({ + ioHelper, + type: 'app', + language: 'typescript', + canUseNetwork: false, + generateOnly: false, + workDir, + }); + + expect(gitInitCalled).toBeFalsy(); // Git init should be skipped + expect(await fs.pathExists(path.join(workDir, 'package.json'))).toBeTruthy(); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('handles git add failure during repository initialization', async (workDir) => { + // Test git add failure during repository initialization + let commandCount = 0; + + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git') { + commandCount++; + if (args[0] === 'init') { + return createMockChildProcess(0); // Git init succeeds + } else if (args[0] === 'add') { + return createMockChildProcess(1); // Git add fails + } + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + // Should complete successfully even if git add fails + await cliInit({ + ioHelper, + type: 'app', + language: 'typescript', + canUseNetwork: false, + generateOnly: false, + workDir, + }); + + expect(commandCount).toBeGreaterThan(0); + expect(await fs.pathExists(path.join(workDir, 'package.json'))).toBeTruthy(); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('handles git commit failure during repository initialization', async (workDir) => { + // Test git commit failure during repository initialization + let commandCount = 0; + + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git') { + commandCount++; + if (args[0] === 'init' || args[0] === 'add') { + return createMockChildProcess(0); // Git init and add succeed + } else if (args[0] === 'commit') { + return createMockChildProcess(1); // Git commit fails + } + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + // Should complete successfully even if git commit fails + await cliInit({ + ioHelper, + type: 'app', + language: 'typescript', + canUseNetwork: false, + generateOnly: false, + workDir, + }); + + expect(commandCount).toBeGreaterThan(0); + expect(await fs.pathExists(path.join(workDir, 'package.json'))).toBeTruthy(); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('handles git commit failure during repository initialization', async (workDir) => { + // Test git commit failure during repository initialization + let commandCount = 0; + + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git') { + commandCount++; + if (args[0] === 'init' || args[0] === 'add') { + return createMockChildProcess(0); // Git init and add succeed + } else if (args[0] === 'commit') { + return createMockChildProcess(1); // Git commit fails + } + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + // Should complete successfully even if git commit fails + await cliInit({ + ioHelper, + type: 'app', + language: 'typescript', + canUseNetwork: false, + generateOnly: false, + workDir, + }); + + expect(commandCount).toBeGreaterThan(0); + expect(await fs.pathExists(path.join(workDir, 'package.json'))).toBeTruthy(); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('handles invalid git repository URL format', async (workDir) => { + // Test invalid git URL handling + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + return createMockChildProcess(128, undefined, 'fatal: not a git repository'); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await expect(cliInit({ + ioHelper, + fromGitUrl: 'invalid-url-format', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir, + })).rejects.toThrow(/Failed to load template from Git repository.*fatal: not a git repository/); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('handles git template with invalid template path', async (workDir) => { + // Test git template with non-existent template path + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + const tempDirPath = args[args.length - 1]; + // Create temp dir but not the requested template path + fs.mkdirpSync(tempDirPath); + const tsDir = path.join(tempDirPath, 'other-template', 'typescript'); + fs.mkdirpSync(tsDir); + fs.writeFileSync(path.join(tsDir, 'app.ts'), 'console.log("Other template");'); + + return createMockChildProcess(0); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await expect(cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/repo.git', + templatePath: 'nonexistent-template', + language: 'typescript', + canUseNetwork: false, + generateOnly: true, + workDir, + })).rejects.toThrow(/Template path does not exist/); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('auto-detects language for single-language git template', async (workDir) => { + // Test language auto-detection for git templates with single language + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + const tempDirPath = args[args.length - 1]; + const tsDir = path.join(tempDirPath, 'typescript'); + fs.mkdirpSync(tsDir); + fs.writeFileSync(path.join(tsDir, 'app.ts'), 'console.log("Single language git template");'); + + return createMockChildProcess(0); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/single-lang-repo.git', + // No language specified - should auto-detect + canUseNetwork: false, + generateOnly: true, + workDir, + }); + + expect(await fs.pathExists(path.join(workDir, 'app.ts'))).toBeTruthy(); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('fails when git template has multiple languages but no language specified', async (workDir) => { + // Test that multi-language git templates require language specification + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + const tempDirPath = args[args.length - 1]; + + // Create multi-language template + const tsDir = path.join(tempDirPath, 'typescript'); + const pyDir = path.join(tempDirPath, 'python'); + fs.mkdirpSync(tsDir); + fs.mkdirpSync(pyDir); + fs.writeFileSync(path.join(tsDir, 'app.ts'), 'console.log("TypeScript");'); + fs.writeFileSync(path.join(pyDir, 'app.py'), 'print("Python")'); + + return createMockChildProcess(0); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await expect(cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/multi-lang-repo.git', + // No language specified for multi-language template + canUseNetwork: false, + generateOnly: true, + workDir, + })).rejects.toThrow(/No language was selected/); + } finally { + spawnSpy.mockRestore(); + } + }); + + cliTest('handles git template loading with empty repository', async (workDir) => { + // Test git template loading when repository is empty or has no valid templates + const mockSpawn = jest.fn().mockImplementation((cmd, args, options) => { + if (cmd === 'git' && args[0] === 'clone') { + const tempDirPath = args[args.length - 1]; + // Create empty directory (no language directories) + fs.mkdirpSync(tempDirPath); + fs.writeFileSync(path.join(tempDirPath, 'README.md'), 'Empty repository'); + + return createMockChildProcess(0); + } + return childProcess.spawn(cmd, args, options); + }); + + const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(mockSpawn); + + try { + await expect(cliInit({ + ioHelper, + fromGitUrl: 'https://github.com/user/empty-repo.git', + canUseNetwork: false, + generateOnly: true, + workDir, + })).rejects.toThrow(/Custom template must contain at least one language directory/); + } finally { + spawnSpy.mockRestore(); + } + }); + cliTest('handles migrate context when no cdk.json exists', async (workDir) => { // Test that addMigrateContext handles missing cdk.json gracefully const templateDir = path.join(workDir, 'no-cdk-json-template');