fix(tools): prevent create-new-x overwriting old projects (#62621)

This commit is contained in:
Oliver Eyton-Williams
2025-10-10 11:58:26 +02:00
committed by GitHub
parent f5361f4341
commit 6fc3684049
5 changed files with 336 additions and 286 deletions
@@ -19,7 +19,12 @@ import {
import { superBlockToFilename } from '../../curriculum/build-curriculum';
import { getBaseMeta } from './helpers/get-base-meta';
import { createIntroMD } from './helpers/create-intro';
import { createDialogueFile, createQuizFile, validateBlockName } from './utils';
import {
createDialogueFile,
createQuizFile,
getAllBlocks,
validateBlockName
} from './utils';
import {
updateSimpleSuperblockStructure,
updateChapterModuleSuperblockStructure
@@ -222,118 +227,122 @@ function withTrace<Args extends unknown[], Result>(
});
}
void prompt([
{
name: 'superBlock',
message: 'Which certification does this belong to?',
default: SuperBlocks.A2English,
type: 'list',
choices: Object.values(languageSuperBlocks)
},
{
name: 'block',
message: 'What is the dashed name (in kebab-case) for this block?',
validate: validateBlockName,
filter: (block: string) => {
return block.toLowerCase().trim();
}
},
{
name: 'title',
default: ({ block }: { block: string }) => block
},
{
name: 'helpCategory',
message: 'Choose a help category',
default: 'English',
type: 'list',
choices: helpCategories
},
{
name: 'blockType',
message: 'Choose a block type',
default: BlockTypes.learn,
type: 'list',
choices: Object.values(BlockTypes),
when: (answers: CreateBlockArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'blockLayout',
message: 'Choose a block layout',
default: BlockLayouts.DialogueGrid,
type: 'list',
choices: Object.values(BlockLayouts),
when: (answers: CreateBlockArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock) &&
answers.blockType !== BlockTypes.quiz
},
{
name: 'questionCount',
message: 'Choose a question count',
default: 20,
type: 'list',
choices: [10, 20],
when: (answers: CreateBlockArgs) => answers.blockType === BlockTypes.quiz
},
{
name: 'chapter',
message: 'What chapter should this language block go in?',
type: 'list',
choices: (answers: CreateBlockArgs) => {
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[answers.superBlock];
const structure = getSuperblockStructure(superblockFilename) as {
chapters: {
dashedName: string;
modules: { dashedName: string; blocks: string[] }[];
}[];
};
return structure.chapters.map(chapter => chapter.dashedName);
},
when: (answers: CreateBlockArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'module',
message: 'What module should this language block go in?',
type: 'list',
choices: (answers: CreateBlockArgs) => {
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[answers.superBlock];
const structure = getSuperblockStructure(superblockFilename) as {
chapters: {
dashedName: string;
modules: { dashedName: string; blocks: string[] }[];
}[];
};
return (
structure.chapters
.find(chapter => chapter.dashedName === answers.chapter)
?.modules.map(module => module.dashedName) ?? []
);
},
when: (answers: CreateBlockArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'position',
message: 'At which position does this appear in the module?',
default: 1,
validate: (position: string) => {
return parseInt(position, 10) > 0
? true
: 'Position must be an number greater than zero.';
},
when: (answers: CreateBlockArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock),
filter: (position: string) => {
return parseInt(position, 10);
}
}
])
void getAllBlocks()
.then(existingBlocks =>
prompt([
{
name: 'superBlock',
message: 'Which certification does this belong to?',
default: SuperBlocks.A2English,
type: 'list',
choices: Object.values(languageSuperBlocks)
},
{
name: 'block',
message: 'What is the dashed name (in kebab-case) for this block?',
validate: (block: string) => validateBlockName(block, existingBlocks),
filter: (block: string) => {
return block.toLowerCase().trim();
}
},
{
name: 'title',
default: ({ block }: { block: string }) => block
},
{
name: 'helpCategory',
message: 'Choose a help category',
default: 'English',
type: 'list',
choices: helpCategories
},
{
name: 'blockType',
message: 'Choose a block type',
default: BlockTypes.learn,
type: 'list',
choices: Object.values(BlockTypes),
when: (answers: CreateBlockArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'blockLayout',
message: 'Choose a block layout',
default: BlockLayouts.DialogueGrid,
type: 'list',
choices: Object.values(BlockLayouts),
when: (answers: CreateBlockArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock) &&
answers.blockType !== BlockTypes.quiz
},
{
name: 'questionCount',
message: 'Choose a question count',
default: 20,
type: 'list',
choices: [10, 20],
when: (answers: CreateBlockArgs) =>
answers.blockType === BlockTypes.quiz
},
{
name: 'chapter',
message: 'What chapter should this language block go in?',
type: 'list',
choices: (answers: CreateBlockArgs) => {
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[answers.superBlock];
const structure = getSuperblockStructure(superblockFilename) as {
chapters: {
dashedName: string;
modules: { dashedName: string; blocks: string[] }[];
}[];
};
return structure.chapters.map(chapter => chapter.dashedName);
},
when: (answers: CreateBlockArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'module',
message: 'What module should this language block go in?',
type: 'list',
choices: (answers: CreateBlockArgs) => {
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[answers.superBlock];
const structure = getSuperblockStructure(superblockFilename) as {
chapters: {
dashedName: string;
modules: { dashedName: string; blocks: string[] }[];
}[];
};
return (
structure.chapters
.find(chapter => chapter.dashedName === answers.chapter)
?.modules.map(module => module.dashedName) ?? []
);
},
when: (answers: CreateBlockArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'position',
message: 'At which position does this appear in the module?',
default: 1,
validate: (position: string) => {
return parseInt(position, 10) > 0
? true
: 'Position must be an number greater than zero.';
},
when: (answers: CreateBlockArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock),
filter: (position: string) => {
return parseInt(position, 10);
}
}
])
)
.then(
async ({
superBlock,
+140 -129
View File
@@ -15,7 +15,12 @@ import {
writeBlockStructure
} from '../../curriculum/file-handler';
import { superBlockToFilename } from '../../curriculum/build-curriculum';
import { createQuizFile, createStepFile, validateBlockName } from './utils';
import {
createQuizFile,
createStepFile,
validateBlockName,
getAllBlocks
} from './utils';
import { getBaseMeta } from './helpers/get-base-meta';
import { createIntroMD } from './helpers/create-intro';
import {
@@ -293,145 +298,151 @@ async function getModules(superBlock: string, chapterName: string) {
return modifiedChapter?.modules;
}
void prompt([
{
name: 'superBlock',
message: 'Which certification does this belong to?',
default: SuperBlocks.FullStackDeveloper,
type: 'list',
choices: Object.values(SuperBlocks)
},
{
name: 'block',
message: 'What is the dashed name (in kebab-case) for this project?',
validate: validateBlockName,
filter: (block: string) => {
return block.toLowerCase().trim();
}
},
{
name: 'title',
default: ({ block }: { block: string }) => block
},
{
name: 'helpCategory',
message: 'Choose a help category',
default: 'HTML-CSS',
type: 'list',
choices: helpCategories
},
{
name: 'blockType',
message: 'Choose a block type',
default: BlockTypes.lab,
type: 'list',
choices: Object.values(BlockTypes),
when: (answers: CreateProjectArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'blockLayout',
message: 'Choose a block layout',
void getAllBlocks()
.then(existingBlocks =>
prompt([
{
name: 'superBlock',
message: 'Which certification does this belong to?',
default: SuperBlocks.FullStackDeveloper,
type: 'list',
choices: Object.values(SuperBlocks)
},
{
name: 'block',
message: 'What is the dashed name (in kebab-case) for this project?',
validate: (block: string) => validateBlockName(block, existingBlocks),
filter: (block: string) => {
return block.toLowerCase().trim();
}
},
{
name: 'title',
default: ({ block }: { block: string }) => block
},
{
name: 'helpCategory',
message: 'Choose a help category',
default: 'HTML-CSS',
type: 'list',
choices: helpCategories
},
{
name: 'blockType',
message: 'Choose a block type',
default: BlockTypes.lab,
type: 'list',
choices: Object.values(BlockTypes),
when: (answers: CreateProjectArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'blockLayout',
message: 'Choose a block layout',
default: (answers: { blockType: BlockTypes }) =>
answers.blockType == BlockTypes.quiz
? BlockLayouts.Link
: BlockLayouts.ChallengeList,
type: 'list',
choices: Object.values(BlockLayouts),
when: (answers: CreateProjectArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'questionCount',
message: 'Choose a question count',
default: 20,
type: 'list',
choices: [10, 20],
when: (answers: CreateProjectArgs) => answers.blockType === BlockTypes.quiz
},
{
name: 'chapter',
message: 'What chapter should this project go in?',
default: 'html',
type: 'list',
choices: async (answers: CreateProjectArgs) => {
const chapters = await getChapters(answers.superBlock);
return chapters.map(x => x.dashedName);
},
when: (answers: CreateProjectArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'module',
message: 'What module should this project go in?',
default: 'html',
type: 'list',
choices: async (answers: CreateProjectArgs) => {
const modules = await getModules(answers.superBlock, answers.chapter!);
return modules!.map(x => x.dashedName);
},
when: (answers: CreateProjectArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'position',
message: 'At which position does this appear in the module?',
default: 1,
validate: (position: string) => {
return parseInt(position, 10) > 0
? true
: 'Position must be an number greater than zero.';
},
when: (answers: CreateProjectArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock),
filter: (position: string) => {
return parseInt(position, 10);
}
},
{
name: 'order',
message: 'Which position does this appear in the certificate?',
default: 42,
validate: (order: string) => {
return parseInt(order, 10) > 0
? true
: 'Order must be an number greater than zero.';
},
when: (answers: CreateProjectArgs) =>
!chapterBasedSuperBlocks.includes(answers.superBlock),
filter: (order: string) => {
return parseInt(order, 10);
}
}
])
.then(
async ({
superBlock,
block,
title,
helpCategory,
blockType,
blockLayout,
questionCount,
chapter,
module,
position,
order
}: CreateProjectArgs) =>
await createProject({
default: (answers: { blockType: BlockTypes }) =>
answers.blockType == BlockTypes.quiz
? BlockLayouts.Link
: BlockLayouts.ChallengeList,
type: 'list',
choices: Object.values(BlockLayouts),
when: (answers: CreateProjectArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'questionCount',
message: 'Choose a question count',
default: 20,
type: 'list',
choices: [10, 20],
when: (answers: CreateProjectArgs) =>
answers.blockType === BlockTypes.quiz
},
{
name: 'chapter',
message: 'What chapter should this project go in?',
default: 'html',
type: 'list',
choices: async (answers: CreateProjectArgs) => {
const chapters = await getChapters(answers.superBlock);
return chapters.map(x => x.dashedName);
},
when: (answers: CreateProjectArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'module',
message: 'What module should this project go in?',
default: 'html',
type: 'list',
choices: async (answers: CreateProjectArgs) => {
const modules = await getModules(
answers.superBlock,
answers.chapter!
);
return modules!.map(x => x.dashedName);
},
when: (answers: CreateProjectArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock)
},
{
name: 'position',
message: 'At which position does this appear in the module?',
default: 1,
validate: (position: string) => {
return parseInt(position, 10) > 0
? true
: 'Position must be an number greater than zero.';
},
when: (answers: CreateProjectArgs) =>
chapterBasedSuperBlocks.includes(answers.superBlock),
filter: (position: string) => {
return parseInt(position, 10);
}
},
{
name: 'order',
message: 'Which position does this appear in the certificate?',
default: 42,
validate: (order: string) => {
return parseInt(order, 10) > 0
? true
: 'Order must be an number greater than zero.';
},
when: (answers: CreateProjectArgs) =>
!chapterBasedSuperBlocks.includes(answers.superBlock),
filter: (order: string) => {
return parseInt(order, 10);
}
}
]).then(
async ({
superBlock,
block,
title,
helpCategory,
blockType,
blockLayout,
questionCount,
title,
chapter,
module,
position,
order
})
}: CreateProjectArgs) =>
await createProject({
superBlock,
block,
helpCategory,
blockType,
blockLayout,
questionCount,
title,
chapter,
module,
position,
order
})
)
)
.then(() =>
console.log(
+39 -36
View File
@@ -10,7 +10,7 @@ import {
writeBlockStructure
} from '../../curriculum/file-handler';
import { superBlockToFilename } from '../../curriculum/build-curriculum';
import { createQuizFile, validateBlockName } from './utils';
import { createQuizFile, getAllBlocks, validateBlockName } from './utils';
import { getBaseMeta } from './helpers/get-base-meta';
import { createIntroMD } from './helpers/create-intro';
import { updateSimpleSuperblockStructure } from './helpers/create-project';
@@ -145,41 +145,44 @@ function withTrace<Args extends unknown[], Result>(
});
}
void prompt([
{
name: 'superBlock',
message: 'Which certification does this belong to?',
default: SuperBlocks.FullStackDeveloper,
type: 'list',
choices: Object.values(SuperBlocks)
},
{
name: 'block',
message: 'What is the dashed name (in kebab-case) for this quiz?',
validate: validateBlockName,
filter: (block: string) => {
return block.toLowerCase().trim();
}
},
{
name: 'title',
default: ({ block }: { block: string }) => block
},
{
name: 'helpCategory',
message: 'Choose a help category',
default: 'HTML-CSS',
type: 'list',
choices: helpCategories
},
{
name: 'questionCount',
message: 'Should this quiz have either ten or twenty questions?',
default: 20,
type: 'list',
choices: [20, 10]
}
])
void getAllBlocks()
.then(existingBlocks =>
prompt([
{
name: 'superBlock',
message: 'Which certification does this belong to?',
default: SuperBlocks.FullStackDeveloper,
type: 'list',
choices: Object.values(SuperBlocks)
},
{
name: 'block',
message: 'What is the dashed name (in kebab-case) for this quiz?',
validate: (block: string) => validateBlockName(block, existingBlocks),
filter: (block: string) => {
return block.toLowerCase().trim();
}
},
{
name: 'title',
default: ({ block }: { block: string }) => block
},
{
name: 'helpCategory',
message: 'Choose a help category',
default: 'HTML-CSS',
type: 'list',
choices: helpCategories
},
{
name: 'questionCount',
message: 'Should this quiz have either ten or twenty questions?',
default: 20,
type: 'list',
choices: [20, 10]
}
])
)
.then(
async ({
superBlock,
+12 -7
View File
@@ -98,24 +98,29 @@ describe('Challenge utils helper scripts', () => {
describe('createProject util', () => {
it('should allow alphanumerical names with trailing whitespace', () => {
expect(
validateBlockName('learn-callbacks-by-creating-a-bookshelf ')
validateBlockName('learn-callbacks-by-creating-a-bookshelf ', [])
).toBe(true);
});
it('should allow alphanumerical names with no trailing whitespace', () => {
expect(validateBlockName('learn-callbacks-by-creating-a-bookshelf')).toBe(
true
);
expect(
validateBlockName('learn-callbacks-by-creating-a-bookshelf', [])
).toBe(true);
});
it('should not allow non-kebab case names', () => {
expect(validateBlockName('learnCallbacksBetter')).toBe(
expect(validateBlockName('learnCallbacksBetter', [])).toBe(
'please use alphanumerical characters and kebab case'
);
});
it('should not allow white space names', () => {
expect(validateBlockName(' ')).toBe('please enter a dashed name');
expect(validateBlockName(' ', [])).toBe('please enter a dashed name');
});
it('should not allow empty names', () => {
expect(validateBlockName('')).toBe('please enter a dashed name');
expect(validateBlockName('', [])).toBe('please enter a dashed name');
});
it('should not allow names that already exist', () => {
expect(validateBlockName('name', ['name'])).toBe(
'a block with this name already exists'
);
});
});
+23 -1
View File
@@ -2,7 +2,10 @@ import fs from 'fs';
import path from 'path';
import ObjectID from 'bson-objectid';
import matter from 'gray-matter';
import { uniq } from 'lodash';
import { challengeTypes } from '../../shared/config/challenge-types';
import { parseCurriculumStructure } from '../../curriculum/build-curriculum';
import { parseMDSync } from '../challenge-parser/parser';
import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { getProjectPath } from './helpers/get-project-info';
@@ -28,6 +31,19 @@ interface QuizOptions {
questionCount: number;
}
export async function getAllBlocks() {
const { fullSuperblockList } = (await parseCurriculumStructure()) as {
fullSuperblockList: {
blocks: { dashedName: string }[];
}[];
};
const existingBlocks = fullSuperblockList.flatMap(({ blocks }) =>
blocks.map(({ dashedName }) => dashedName)
);
return uniq(existingBlocks);
}
const createStepFile = ({
stepNum,
challengeType,
@@ -259,7 +275,13 @@ const getChallenge = (challengeId: string): Challenge => {
return challenge;
};
const validateBlockName = (block: string): boolean | string => {
const validateBlockName = (
block: string,
existingBlocks: string[]
): true | string => {
if (existingBlocks.includes(block.trim())) {
return 'a block with this name already exists';
}
if (!block.trim().length) {
return 'please enter a dashed name';
}