Skip to content

Commit cd083a7

Browse files
feat: easy-add-app-functions [EXT-6022] (#2326)
* test: init Very basic (and broken) starting point to make adding functions easier * feat: basics * feat: mild progress - moving to app-script * feat: Mostly working - need to fix app-manifest merging and function renaming * fix: App manifest updates correctly * fix: Rename before copying over cloned files * fix: Non-interactive CLI works, scaffolded some tests, * fix: tests and lint 😅 * fix: Update tests and package * fix: update packagelock.json * fix: Suggested cleanup & does not override tsconfig.json * fix: Even more cleanup * fix: Move tests out of folder
1 parent f93c4eb commit cd083a7

19 files changed

+1516
-268
lines changed

packages/contentful--app-scripts/package-lock.json

Lines changed: 635 additions & 267 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/contentful--app-scripts/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
5252
"@segment/analytics-node": "^2.0.0",
5353
"adm-zip": "0.5.16",
54+
"axios": "^1.7.9",
5455
"bottleneck": "2.19.5",
5556
"chalk": "4.1.2",
5657
"commander": "12.1.0",
@@ -60,8 +61,10 @@
6061
"ignore": "7.0.3",
6162
"inquirer": "8.2.6",
6263
"lodash": "4.17.21",
64+
"merge-options": "^3.0.4",
6365
"open": "8.4.2",
6466
"ora": "5.4.1",
67+
"tiged": "^2.12.7",
6568
"zod": "^3.24.1"
6669
},
6770
"gitHead": "4c3506be3f52c7a8aae17deaa75acefc9a805b42",

packages/contentful--app-scripts/src/bin.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
track,
1212
install,
1313
buildFunctions,
14+
generateFunction,
1415
} from './index';
1516
import { feedback } from './feedback';
1617

@@ -21,7 +22,8 @@ type Command =
2122
| typeof cleanup
2223
| typeof open
2324
| typeof install
24-
| typeof buildFunctions;
25+
| typeof buildFunctions
26+
| typeof generateFunction;
2527

2628
async function runCommand(command: Command, options?: any) {
2729
const { ci } = program.opts();
@@ -114,6 +116,17 @@ async function runCommand(command: Command, options?: any) {
114116
await runCommand(buildFunctions, options);
115117
});
116118

119+
program
120+
.command('generate-function')
121+
.description('Generate a new Contentful Function')
122+
.option('--name <name>', 'Name of the function')
123+
.option('--template <language>', 'Select a template and language for the function')
124+
.option('--example <example_name>', 'Select an example function to generate')
125+
.option('--language <language>', 'Select a language for the function')
126+
.action(async (options) => {
127+
await runCommand(generateFunction, options);
128+
});
129+
117130
program.hook('preAction', (thisCommand) => {
118131
track({ command: thisCommand.args[0], ci: thisCommand.opts().ci });
119132
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { GenerateFunctionOptions, GenerateFunctionSettings } from "../types";
2+
import assert from 'node:assert'
3+
import { buildGenerateFunctionSettingsFromOptions } from './build-generate-function-settings';
4+
5+
describe('buildGenerateFunctionSettingsFromOptions', () => {
6+
it('should return GenerateFunctionSettings - using minimum template', async () => {
7+
const options = {
8+
name: 'test',
9+
template: 'typescript',
10+
} as GenerateFunctionOptions
11+
const expected = {
12+
name: 'test',
13+
sourceType: 'template',
14+
sourceName: 'typescript',
15+
language: 'typescript',
16+
} as GenerateFunctionSettings
17+
18+
const settings = await buildGenerateFunctionSettingsFromOptions(options);
19+
assert.deepEqual(expected, settings);
20+
});
21+
22+
it('should return GenerateFunctionSettings - using minimum example', async () => {
23+
const options = {
24+
name: 'test',
25+
example: 'appevent-handler',
26+
language: 'typescript'
27+
} as GenerateFunctionOptions
28+
const expected = {
29+
name: 'test',
30+
sourceType: 'example',
31+
sourceName: 'appevent-handler',
32+
language: 'typescript',
33+
}
34+
const settings = await buildGenerateFunctionSettingsFromOptions(options);
35+
assert.deepEqual(expected, settings);
36+
});
37+
})
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import inquirer from 'inquirer';
2+
import path from 'path';
3+
import { getGithubFolderNames } from './get-github-folder-names';
4+
import { ACCEPTED_EXAMPLE_FOLDERS, ACCEPTED_LANGUAGES, BANNED_FUNCTION_NAMES } from './constants';
5+
import { GenerateFunctionSettings, AcceptedFunctionExamples, SourceName, GenerateFunctionOptions, Language } from '../types';
6+
import ora from 'ora';
7+
import chalk from 'chalk';
8+
import { warn } from './logger';
9+
10+
export async function buildGenerateFunctionSettings() : Promise<GenerateFunctionSettings> {
11+
const baseSettings = await inquirer.prompt<GenerateFunctionSettings>([
12+
{
13+
name: 'name',
14+
message: `Function name (${path.basename(process.cwd())}):`,
15+
},
16+
{
17+
name: 'sourceType',
18+
message: 'Do you want to start with a blank template or use one of our examples?',
19+
type: 'list',
20+
choices: [
21+
{ name: 'Template', value: 'template' },
22+
{ name: 'Example', value: 'example' },
23+
],
24+
default: 'template',
25+
}
26+
]);
27+
if (BANNED_FUNCTION_NAMES.includes(baseSettings.name)) {
28+
throw new Error(`Invalid function name: ${baseSettings.name}`);
29+
}
30+
let sourceSpecificSettings : GenerateFunctionSettings;
31+
if (baseSettings.sourceType === 'template') {
32+
sourceSpecificSettings = await inquirer.prompt<GenerateFunctionSettings>([
33+
{
34+
name: 'language',
35+
message: 'Pick a template',
36+
type: 'list',
37+
choices: [
38+
{ name: 'TypeScript', value: 'typescript' },
39+
{ name: 'JavaScript', value: 'javascript' },
40+
],
41+
default: 'typescript',
42+
}
43+
])
44+
sourceSpecificSettings.sourceName = sourceSpecificSettings.language.toLowerCase() as SourceName
45+
} else {
46+
const availableExamples = await getGithubFolderNames();
47+
const filteredExamples = availableExamples.filter(
48+
(template) =>
49+
ACCEPTED_EXAMPLE_FOLDERS.includes(template as (typeof ACCEPTED_EXAMPLE_FOLDERS)[number])
50+
);
51+
52+
sourceSpecificSettings = await inquirer.prompt<GenerateFunctionSettings>([
53+
{
54+
name: 'sourceName',
55+
message: 'Select an example:',
56+
type: 'list',
57+
choices: filteredExamples,
58+
},
59+
{
60+
name: 'language',
61+
message: 'Pick a template',
62+
type: 'list',
63+
choices: [
64+
{ name: 'TypeScript', value: 'typescript' },
65+
{ name: 'JavaScript', value: 'javascript' },
66+
],
67+
default: 'typescript',
68+
}
69+
])
70+
}
71+
baseSettings.sourceName = sourceSpecificSettings.sourceName
72+
baseSettings.language = sourceSpecificSettings.language
73+
return baseSettings
74+
}
75+
76+
function validateArguments(options: GenerateFunctionOptions) {
77+
const templateRequired = ['name', 'template'];
78+
const exampleRequired = ['name', 'example', 'language'];
79+
if (BANNED_FUNCTION_NAMES.includes(options.name)) {
80+
throw new Error(`Invalid function name: ${options.name}`);
81+
}
82+
if ('template' in options) {
83+
if (!templateRequired.every((key) => key in options)) {
84+
throw new Error('You must specify a function name and a template');
85+
}
86+
} else if ('example' in options) {
87+
if (!exampleRequired.every((key) => key in options)) {
88+
throw new Error('You must specify a function name, an example, and a language');
89+
}
90+
} else {
91+
throw new Error('You must specify either --template or --example');
92+
}
93+
}
94+
95+
export async function buildGenerateFunctionSettingsFromOptions(options: GenerateFunctionOptions) : Promise<GenerateFunctionSettings> {
96+
const validateSpinner = ora('Validating your input\n').start();
97+
const settings: GenerateFunctionSettings = {} as GenerateFunctionSettings;
98+
try {
99+
validateArguments(options);
100+
for (const key in options) { // convert all options to lowercase and trim
101+
const optionKey = key as keyof GenerateFunctionOptions;
102+
options[optionKey] = options[optionKey].toLowerCase().trim();
103+
}
104+
105+
if ('example' in options) {
106+
if ('template' in options) {
107+
throw new Error('Cannot specify both --template and --example');
108+
}
109+
110+
if (!ACCEPTED_EXAMPLE_FOLDERS.includes(options.example as AcceptedFunctionExamples)) {
111+
throw new Error(`Invalid example name: ${options.example}`);
112+
}
113+
114+
if (!ACCEPTED_LANGUAGES.includes(options.language)) {
115+
warn(`Invalid language: ${options.language}. Defaulting to TypeScript.`);
116+
settings.language = 'typescript';
117+
} else {
118+
settings.language = options.language;
119+
}
120+
settings.sourceType = 'example';
121+
settings.sourceName = options.example;
122+
settings.name = options.name;
123+
124+
} else if ('template' in options) {
125+
if ('language' in options && options.language && options.language != options.template) {
126+
console.warn(`Ignoring language option: ${options.language}. Defaulting to ${options.template}.`);
127+
}
128+
if (!ACCEPTED_LANGUAGES.includes(options.template as Language)) {
129+
console.warn(`Invalid language: ${options.template}. Defaulting to TypeScript.`);
130+
settings.language = 'typescript';
131+
settings.sourceName = 'typescript';
132+
} else {
133+
settings.language = options.template as Language;
134+
settings.sourceName = options.template;
135+
}
136+
settings.sourceType = 'template';
137+
settings.name = options.name;
138+
}
139+
140+
return settings;
141+
} catch (err: any) {
142+
console.log(`
143+
${chalk.red('Validation failed')}
144+
${err.message}
145+
`);
146+
// eslint-disable-next-line no-process-exit
147+
process.exit(1);
148+
} finally {
149+
validateSpinner.stop();
150+
}
151+
}

0 commit comments

Comments
 (0)