diff --git a/cmta/src/main/scala/com/lunatech/cmt/admin/Domain.scala b/cmta/src/main/scala/com/lunatech/cmt/admin/Domain.scala index ef74ad9e..391a785f 100644 --- a/cmta/src/main/scala/com/lunatech/cmt/admin/Domain.scala +++ b/cmta/src/main/scala/com/lunatech/cmt/admin/Domain.scala @@ -13,6 +13,7 @@ package com.lunatech.cmt.admin * See the License for the specific language governing permissions and limitations under the License. */ +import com.lunatech.cmt.Domain.InstallationSource.GithubProject import sbt.io.syntax.File object Domain: @@ -37,3 +38,13 @@ object Domain: final case class LinearizeBaseDirectory(value: File) final case class MainRepository(value: File) final case class ConfigurationFile(value: File) + + final case class CourseTemplate(value: GithubProject) + object CourseTemplate: + def fromString(str: String): CourseTemplate = + if (str.indexOf("/") > 0) { + val Array(organisation, project) = str.split("/").map(_.trim) + CourseTemplate(GithubProject(organisation, project)) + } else { + CourseTemplate(GithubProject("lunatech-labs", str)) + } diff --git a/cmta/src/main/scala/com/lunatech/cmt/admin/Main.scala b/cmta/src/main/scala/com/lunatech/cmt/admin/Main.scala index 16d4a26b..20405a16 100644 --- a/cmta/src/main/scala/com/lunatech/cmt/admin/Main.scala +++ b/cmta/src/main/scala/com/lunatech/cmt/admin/Main.scala @@ -13,11 +13,12 @@ package com.lunatech.cmt.admin * See the License for the specific language governing permissions and limitations under the License. */ -import caseapp.core.app.{CommandsEntryPoint} +import caseapp.core.app.CommandsEntryPoint import com.lunatech.cmt.admin.command.{ Delinearize, DuplicateInsertBefore, Linearize, + New, RenumberExercises, Studentify, Version @@ -26,6 +27,7 @@ import com.lunatech.cmt.admin.command.{ object Main extends CommandsEntryPoint: override def progName = "cmta" override def commands = Seq( + New.command, Delinearize.command, DuplicateInsertBefore.command, Linearize.command, diff --git a/cmta/src/main/scala/com/lunatech/cmt/admin/cli/ArgParsers.scala b/cmta/src/main/scala/com/lunatech/cmt/admin/cli/ArgParsers.scala index 48535d04..62bdbf81 100644 --- a/cmta/src/main/scala/com/lunatech/cmt/admin/cli/ArgParsers.scala +++ b/cmta/src/main/scala/com/lunatech/cmt/admin/cli/ArgParsers.scala @@ -69,4 +69,7 @@ object ArgParsers: given renumberOffsetArgParser: ArgParser[RenumberOffset] = intGreaterThanZero.xmap[RenumberOffset](_.value, RenumberOffset(_)) + given courseTemplateArgParser: ArgParser[CourseTemplate] = + SimpleArgParser.from[CourseTemplate]("Course Template")(str => CourseTemplate.fromString(str).asRight) + end ArgParsers diff --git a/cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala b/cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala index 71dcd51d..01196922 100644 --- a/cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala +++ b/cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala @@ -1,14 +1,34 @@ package com.lunatech.cmt.admin.command -import caseapp.RemainingArgs -import com.lunatech.cmt.CmtError -import com.lunatech.cmt.core.cli.CmtCommand -import com.lunatech.cmt.core.execution.Executable +import caseapp.{AppName, CommandName, ExtraName, HelpMessage, RemainingArgs, ValueDescription} +import com.lunatech.cmt.{CmtError, printResult} +import com.lunatech.cmt.admin.Domain.{ConfigurationFile, CourseTemplate} +import com.lunatech.cmt.client.command.{Executable, Install} import com.lunatech.cmt.core.validation.Validatable +import com.lunatech.cmt.admin.cli.ArgParsers.{configurationFileArgParser, courseTemplateArgParser} +import com.lunatech.cmt.client.Configuration +import com.lunatech.cmt.client.cli.CmtcCommand +import sbt.io.IO as sbtio +import sbt.io.syntax.* + +import java.io.FileFilter object New: - final case class Options() + @AppName("new") + @CommandName("new") + @HelpMessage( + "Create a new course from an existing course template in a Github repository - by default the `lunatech-labs` organisation is used.") + final case class Options( + @ExtraName("t") + @ValueDescription( + "the template course to use - provide in the format 'organisation/project' or just 'project' if the project is in the lunatech-labs organisation on Github") + template: CourseTemplate, + @ExtraName("c") + @ValueDescription("The (optional) configuration file to use during processing of the command") + @HelpMessage( + "if not specified will default to the config file present in the directory provided by the --main-repository argument") + maybeConfigFile: Option[ConfigurationFile] = None) given Validatable[New.Options] with extension (options: New.Options) @@ -16,15 +36,28 @@ object New: Right(options) end given - given Executable[Delinearize.Options] with - extension (options: Delinearize.Options) - def execute(): Either[CmtError, String] = { - ??? + given Executable[New.Options] with + extension (options: New.Options) + def execute(configuration: Configuration): Either[CmtError, String] = { + // list the contents of the ~/Courses directory, if there's anything already matching the name then get the count so we can append to the name and prevent conflicts + val existingFilesWithSameName = sbtio.listFiles( + configuration.coursesDirectory.value, + new FileFilter { + override def accept(file: File): Boolean = + file.name.startsWith(options.template.value.project) + }) + val discriminator = if (existingFilesWithSameName.size > 0) s"-${existingFilesWithSameName.size}" else "" + val targetDirectoryName = s"${options.template.value.project}$discriminator" + + // Install the course + Install + .Options(options.template.value, newName = Some(targetDirectoryName), studentifiedAsset = Some(false)) + .execute(configuration) } - val command = new CmtCommand[New.Options] { + val command = new CmtcCommand[New.Options] { def run(options: New.Options, args: RemainingArgs): Unit = - options.validated().flatMap(_.execute()).printResult() + options.validated().flatMap(_.execute(configuration)).printResult() } end New diff --git a/core/src/main/scala/com/lunatech/cmt/client/command/Install.scala b/core/src/main/scala/com/lunatech/cmt/client/command/Install.scala index a7fc10a6..a4318208 100644 --- a/core/src/main/scala/com/lunatech/cmt/client/command/Install.scala +++ b/core/src/main/scala/com/lunatech/cmt/client/command/Install.scala @@ -37,7 +37,11 @@ object Install: "Install a course - from either a local directory, a zip file on the local file system or a Github project") final case class Options( @ExtraName("s") - source: InstallationSource) + source: InstallationSource, + @ExtraName("d") + newName: Option[String] = None, + @ExtraName("a") + studentifiedAsset: Option[Boolean] = Some(true)) given Validatable[Install.Options] with extension (options: Install.Options) @@ -93,11 +97,13 @@ object Install: release: Release, configuration: Configuration)(implicit client: Client[IO]): Either[CmtError, String] = for { - studentAssetUrl <- getStudentAssetUrl(githubProject, release) - _ = printMessage(s"downloading studentified course from '$studentAssetUrl' to courses directory") - downloadedZipFile <- downloadStudentAsset(studentAssetUrl, githubProject, release.tag_name, configuration) + assetUrl <- getAssetUrl(githubProject, release) + _ = printMessage(s"downloading studentified course from '$assetUrl' to courses directory") + downloadedZipFile <- downloadStudentAsset(assetUrl, githubProject, release.tag_name, configuration) _ <- installFromZipFile(downloadedZipFile, configuration, deleteZipAfterInstall = true) - _ <- setCurrentCourse(githubProject, configuration) + _ <- + if (cmd.studentifiedAsset.getOrElse(true)) setCurrentCourse(githubProject, configuration) + else "successfully created new course".toExecuteCommandErrorMessage.asRight } yield s"${githubProject.project} (${release.tag_name}) successfully installed to ${configuration.coursesDirectory.value}/${githubProject.project}" private def setCurrentCourse( @@ -108,12 +114,24 @@ object Install: SetCurrentCourse.Options(studentifiedRepo).execute(configuration) } + private def getAssetUrl(githubProject: GithubProject, release: Release)(implicit + httpClient: Client[IO]): Either[CmtError, String] = { + if (cmd.studentifiedAsset.getOrElse(true)) { + getStudentAssetUrl(githubProject, release) + } else { + release.zipball_url.toRight( + s"latest release of ${githubProject.displayName} does not have a 'main' repo zip - unable to install without one".toExecuteCommandErrorMessage) + } + } + private def getStudentAssetUrl(githubProject: GithubProject, release: Release)(implicit httpClient: Client[IO]): Either[CmtError, String] = { val maybeAssetsFuture = httpClient .expect[List[Asset]](release.assets_url) .map { assets => - val requiredName = s"${githubProject.project}-student.zip" + val requiredName = + if (cmd.studentifiedAsset.getOrElse(true)) s"${githubProject.project}-student.zip" + else s"${githubProject.project}-${release.tag_name}.zip" assets.find(_.name == requiredName).map(_.browserDownloadUrl) } .unsafeToFuture()