Skip to content

Commit 657aaf0

Browse files
committed
feat(cli): git repository support for custom init templates
1 parent e46adaf commit 657aaf0

File tree

10 files changed

+890
-60
lines changed

10 files changed

+890
-60
lines changed

packages/@aws-cdk/user-input-gen/lib/yargs-gen.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,22 @@ function makeYargs(config: CliConfig, helpers: CliHelpers): Statement {
105105
}
106106
commandCallArgs.push(lit(commandFacts.description));
107107

108-
if (commandFacts.options) {
109-
commandCallArgs.push(optionsExpr);
110-
}
111-
112108
// Add implies calls if present
113109
if (commandFacts.implies) {
114110
for (const [key, value] of Object.entries(commandFacts.implies)) {
115111
optionsExpr = optionsExpr.callMethod('implies', lit(key), lit(value));
116112
}
117113
}
118114

115+
// Add check function if present
116+
if (commandFacts.check) {
117+
optionsExpr = optionsExpr.callMethod('check', code.expr.directCode(commandFacts.check.toString()));
118+
}
119+
120+
if (commandFacts.options || commandFacts.check) {
121+
commandCallArgs.push(optionsExpr);
122+
}
123+
119124
yargsExpr = yargsExpr.callMethod('command', ...commandCallArgs);
120125
}
121126

packages/@aws-cdk/user-input-gen/lib/yargs-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface YargsCommand {
88
export interface CliAction extends YargsCommand {
99
options?: { [optionName: string]: CliOption };
1010
implies?: { [key: string]: string };
11+
check?: (argv: any) => boolean | undefined;
1112
}
1213

1314
interface YargsArg {

packages/aws-cdk/lib/cli/cli-config.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ import { getLanguageAlias } from '../commands/language';
88

99
export const YARGS_HELPERS = new CliHelpers('./util/yargs-helpers');
1010

11+
interface InitCommandArgs {
12+
'template-path'?: string;
13+
'from-path'?: string;
14+
'from-git-url'?: string;
15+
[key: string]: unknown;
16+
}
17+
1118
/**
1219
* Source of truth for all CDK CLI commands. `user-input-gen` translates this into:
1320
*
@@ -406,8 +413,22 @@ export async function makeConfig(): Promise<CliConfig> {
406413
'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.' },
407414
'from-path': { type: 'string', desc: 'Path to a local custom template directory or multi-template repository', requiresArg: true, conflicts: ['lib-version'] },
408415
'template-path': { type: 'string', desc: 'Path to a specific template within a multi-template repository', requiresArg: true },
416+
'from-git-url': { type: 'string', desc: 'Git repository URL to clone custom template from', requiresArg: true, conflicts: ['lib-version', 'from-path'] },
417+
},
418+
check: (argv: InitCommandArgs) => {
419+
const hasTemplatePath = Boolean(argv['template-path']);
420+
const hasValidSource = Boolean(argv['from-path'] || argv['from-git-url']);
421+
422+
if (hasTemplatePath && !hasValidSource) {
423+
const e = new Error(
424+
'--template-path can only be used with --from-path or --from-git-url',
425+
);
426+
e.name = 'ValidationError';
427+
throw e;
428+
}
429+
430+
return true;
409431
},
410-
implies: { 'template-path': 'from-path' },
411432
},
412433
'migrate': {
413434
description: 'Migrate existing AWS resources into a CDK app',

packages/aws-cdk/lib/cli/cli-type-registry.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -897,10 +897,16 @@
897897
"type": "string",
898898
"desc": "Path to a specific template within a multi-template repository",
899899
"requiresArg": true
900+
},
901+
"from-git-url": {
902+
"type": "string",
903+
"desc": "Git repository URL to clone custom template from",
904+
"requiresArg": true,
905+
"conflicts": [
906+
"lib-version",
907+
"from-path"
908+
]
900909
}
901-
},
902-
"implies": {
903-
"template-path": "from-path"
904910
}
905911
},
906912
"migrate": {

packages/aws-cdk/lib/cli/cli.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
547547
return printAvailableTemplates(ioHelper, language);
548548
} else {
549549
// Gate custom template support with unstable flag
550-
if (args['from-path'] && !configuration.settings.get(['unstable']).includes('init')) {
550+
if ((args['from-path'] || args['from-git-url']) && !configuration.settings.get(['unstable']).includes('init')) {
551551
throw new ToolkitError('Unstable feature use: \'init\' with custom templates is unstable. It must be opted in via \'--unstable\', e.g. \'cdk init --from-path=./my-template --unstable=init\'');
552552
}
553553
return cliInit({
@@ -558,6 +558,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
558558
generateOnly: args.generateOnly,
559559
libVersion: args.libVersion,
560560
fromPath: args['from-path'],
561+
fromGitUrl: args['from-git-url'],
561562
templatePath: args['template-path'],
562563
});
563564
}

packages/aws-cdk/lib/cli/convert-to-user-input.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export function convertYargsToUserInput(args: any): UserInput {
249249
libVersion: args.libVersion,
250250
fromPath: args.fromPath,
251251
templatePath: args.templatePath,
252+
fromGitUrl: args.fromGitUrl,
252253
TEMPLATE: args.TEMPLATE,
253254
};
254255
break;
@@ -491,6 +492,7 @@ export function convertConfigToUserInput(config: any): UserInput {
491492
libVersion: config.init?.libVersion,
492493
fromPath: config.init?.fromPath,
493494
templatePath: config.init?.templatePath,
495+
fromGitUrl: config.init?.fromGitUrl,
494496
};
495497
const migrateOptions = {
496498
stackName: config.migrate?.stackName,

packages/aws-cdk/lib/cli/parse-command-line-arguments.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,23 @@ export function parseCommandLineArguments(args: Array<string>): any {
888888
type: 'string',
889889
desc: 'Path to a specific template within a multi-template repository',
890890
requiresArg: true,
891+
})
892+
.option('from-git-url', {
893+
default: undefined,
894+
type: 'string',
895+
desc: 'Git repository URL to clone custom template from',
896+
requiresArg: true,
897+
conflicts: ['lib-version', 'from-path'],
898+
})
899+
.check((argv) => {
900+
const hasTemplatePath = Boolean(argv['template-path']);
901+
const hasValidSource = Boolean(argv['from-path'] || argv['from-git-url']);
902+
if (hasTemplatePath && !hasValidSource) {
903+
const e = new Error('--template-path can only be used with --from-path or --from-git-url');
904+
e.name = 'ValidationError';
905+
throw e;
906+
}
907+
return true;
891908
}),
892909
)
893910
.command('migrate', 'Migrate existing AWS resources into a CDK app', (yargs: Argv) =>

packages/aws-cdk/lib/cli/user-input.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,6 +1401,13 @@ export interface InitOptions {
14011401
*/
14021402
readonly templatePath?: string;
14031403

1404+
/**
1405+
* Git repository URL to clone custom template from
1406+
*
1407+
* @default - undefined
1408+
*/
1409+
readonly fromGitUrl?: string;
1410+
14041411
/**
14051412
* Positional argument for init
14061413
*/

packages/aws-cdk/lib/commands/init/init.ts

Lines changed: 129 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as childProcess from 'child_process';
2+
import * as os from 'os';
23
import * as path from 'path';
34
import { ToolkitError } from '@aws-cdk/toolkit-lib';
45
import * as chalk from 'chalk';
@@ -70,8 +71,14 @@ export interface CliInitOptions {
7071
readonly fromPath?: string;
7172

7273
/**
73-
* Path to a specific template within a multi-template repository.
74-
* This parameter requires --from-path to be specified.
74+
* Git repository URL to clone and use as template source
75+
* @default undefined
76+
*/
77+
readonly fromGitUrl?: string;
78+
79+
/**
80+
* Path to a template within a multi-template repository.
81+
* This parameter requires an origin to be specified using --from-path or --from-git-url.
7582
* @default undefined
7683
*/
7784
readonly templatePath?: string;
@@ -89,34 +96,49 @@ export async function cliInit(options: CliInitOptions) {
8996
const workDir = options.workDir ?? process.cwd();
9097

9198
// Show available templates only if no fromPath, type, or language provided
92-
if (!options.fromPath && !options.type && !options.language) {
99+
if (!options.fromPath && !options.fromGitUrl && !options.type && !options.language) {
93100
await printAvailableTemplates(ioHelper);
94101
return;
95102
}
96103

97-
// Step 1: Load template
98-
let template: InitTemplate;
99-
if (options.fromPath) {
100-
template = await loadLocalTemplate(options.fromPath, options.templatePath);
101-
} else {
102-
template = await loadBuiltinTemplate(ioHelper, options.type, options.language);
103-
}
104-
105-
// Step 2: Resolve language
106-
const language = await resolveLanguage(ioHelper, template, options.language, options.type);
107-
108-
// Step 3: Initialize project following standard process
109-
await initializeProject(
110-
ioHelper,
111-
template,
112-
language,
113-
canUseNetwork,
114-
generateOnly,
115-
workDir,
116-
options.stackName,
117-
options.migrate,
118-
options.libVersion,
119-
);
104+
// temporarily store git repo if pulling from remote
105+
let gitTempDir: string | undefined;
106+
107+
try {
108+
// Step 1: Load template
109+
let template: InitTemplate;
110+
if (options.fromPath) {
111+
template = await loadLocalTemplate(options.fromPath, options.templatePath);
112+
} else if (options.fromGitUrl) {
113+
gitTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-init-git-'));
114+
template = await loadGitTemplate(gitTempDir, options.fromGitUrl, options.templatePath);
115+
} else {
116+
template = await loadBuiltinTemplate(ioHelper, options.type, options.language);
117+
}
118+
119+
// Step 2: Resolve language
120+
const language = await resolveLanguage(ioHelper, template, options.language, options.type);
121+
122+
// Step 3: Initialize project following standard process
123+
await initializeProject(
124+
ioHelper,
125+
template,
126+
language,
127+
canUseNetwork,
128+
generateOnly,
129+
workDir,
130+
options.stackName,
131+
options.migrate,
132+
options.libVersion,
133+
);
134+
} finally {
135+
// Clean up temporary directory after everything is done
136+
if (gitTempDir) {
137+
await fs.remove(gitTempDir).catch(async (error: any) => {
138+
await ioHelper.defaults.warn(`Could not remove temporary directory ${gitTempDir}: ${error.message}`);
139+
});
140+
}
141+
}
120142
}
121143

122144
/**
@@ -160,6 +182,28 @@ async function loadLocalTemplate(fromPath: string, templatePath?: string): Promi
160182
}
161183
}
162184

185+
/**
186+
* Load a template from a Git repository URL
187+
* @param gitUrl - Git repository URL to clone
188+
* @param templatePath - Optional path to a specific template within the repository
189+
* @returns Promise resolving to the InitTemplate
190+
*/
191+
async function loadGitTemplate(tempDir: string, gitUrl: string, templatePath?: string): Promise<InitTemplate> {
192+
try {
193+
await executeGitCommand('git', ['clone', '--depth', '1', gitUrl, tempDir]);
194+
195+
let fullTemplatePath = tempDir;
196+
if (templatePath) {
197+
fullTemplatePath = path.join(tempDir, templatePath);
198+
}
199+
const template = await InitTemplate.fromPath(fullTemplatePath);
200+
return template;
201+
} catch (error: any) {
202+
const displayPath = templatePath ? `${gitUrl}/${templatePath}` : gitUrl;
203+
throw new ToolkitError(`Failed to load template from Git repository: ${displayPath}. ${error.message}`);
204+
}
205+
}
206+
163207
/**
164208
* Load a built-in template by name
165209
*/
@@ -188,8 +232,9 @@ async function resolveLanguage(ioHelper: IoHelper, template: InitTemplate, reque
188232
return (async () => {
189233
if (requestedLanguage) {
190234
return requestedLanguage;
191-
}
192-
if (template.languages.length === 1) {
235+
} else if (template.languages.length === 0) {
236+
throw new ToolkitError('Custom template must contain at least one language directory');
237+
} else if (template.languages.length === 1) {
193238
const templateLanguage = template.languages[0];
194239
// Only show auto-detection message for built-in templates
195240
if (template.templateType !== TemplateType.CUSTOM) {
@@ -892,6 +937,62 @@ function isRoot(dir: string) {
892937
return path.dirname(dir) === dir;
893938
}
894939

940+
/**
941+
* Execute a Git command with timeout
942+
* @param cmd - Git command to execute
943+
* @param args - Command arguments
944+
* @returns Promise resolving to stdout
945+
*/
946+
async function executeGitCommand(cmd: string, args: string[]): Promise<string> {
947+
return new Promise<string>((resolve, reject) => {
948+
const child = childProcess.spawn(cmd, args, {
949+
shell: true,
950+
stdio: ['ignore', 'pipe', 'pipe'],
951+
});
952+
953+
let stdout = '';
954+
let stderr = '';
955+
let killed = false;
956+
957+
// Handle process errors
958+
child.on('error', (err) => {
959+
reject(new ToolkitError(`Failed to execute Git command: ${err.message}`));
960+
});
961+
962+
// Collect stdout
963+
child.stdout.on('data', (chunk) => {
964+
stdout += chunk.toString();
965+
});
966+
967+
// Collect stderr
968+
child.stderr.on('data', (chunk) => {
969+
stderr += chunk.toString();
970+
});
971+
972+
// Handle process completion
973+
child.on('exit', (code, signal) => {
974+
if (killed) {
975+
return;
976+
}
977+
978+
if (code === 0) {
979+
resolve(stdout.trim());
980+
} else {
981+
const errorMessage = stderr.trim() || stdout.trim();
982+
reject(new ToolkitError(
983+
`Git command failed with ${signal ? `signal ${signal}` : `code ${code}`}: ${errorMessage}`,
984+
));
985+
}
986+
});
987+
988+
child.on('close', () => {
989+
child.stdout.removeAllListeners();
990+
child.stderr.removeAllListeners();
991+
child.removeAllListeners();
992+
});
993+
});
994+
}
995+
895996
/**
896997
* Executes `command`. STDERR is emitted in real-time.
897998
*

0 commit comments

Comments
 (0)