diff --git a/package.json b/package.json index 0a7b63d3..d26866a7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "activationEvents": [ "workspaceContains:**/pom.xml", "workspaceContains:**/build.gradle", + "onCommand:liberty.starterProject", "onCommand:liberty.dev.start", "onCommand:liberty.dev.stop", "onCommand:liberty.dev.custom", @@ -55,6 +56,11 @@ ] }, "commands": [ + { + "command": "liberty.starterProject", + "category": "Open Liberty", + "title": "Open Liberty Starter Project" + }, { "command": "liberty.explorer.refresh", "title": "%contributes.commands.liberty.explorer.refresh%", @@ -114,7 +120,7 @@ { "command": "liberty.dev.start", "when": "viewItem == libertyMavenProject || viewItem == libertyGradleProject || viewItem == libertyMavenProjectContainer || viewItem == libertyGradleProjectContainer", - "group": "libertyCore@1" + "group": "libertyCore@2" }, { "command": "liberty.dev.stop", @@ -214,7 +220,7 @@ "devDependencies": { "@types/glob": "^7.1.1", "@types/mocha": "^7.0.2", - "@types/node": "^13.13.36", + "@types/node": "^13.13.52", "@types/vscode": "^1.36.0", "@typescript-eslint/eslint-plugin": "^2.24.0", "@typescript-eslint/parser": "^2.24.0", @@ -228,11 +234,16 @@ "webpack-cli": "^4.6.0" }, "dependencies": { + "@microsoft/vscode-file-downloader-api": "^1.0.1", "@types/fs-extra": "^8.1.0", "@types/xml2js": "^0.4.5", + "axios": "^0.27.2", "fs-extra": "^9.0.0", "gradle-to-js": "^2.0.0", "semver": "^7.3.2", + "unzip-stream": "^0.3.1", + "update": "^0.7.4", + "vsce": "^2.9.2", "xml2js": "^0.4.23" } } diff --git a/src/definitions/starterOptions.ts b/src/definitions/starterOptions.ts new file mode 100644 index 00000000..f439b9d7 --- /dev/null +++ b/src/definitions/starterOptions.ts @@ -0,0 +1,9 @@ +import axios from "axios"; + +export async function getProjectOptions(): Promise { + const response = await axios({ + method: "get", + url: 'https://start.openliberty.io/api/start/info', + }) + return response.data; + } \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 6c02ca54..b7901068 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode"; import * as devCommands from "./liberty/devCommands"; +import { starterProject } from './liberty/starterProject'; import { LibertyProject, ProjectProvider } from "./liberty/libertyProject"; @@ -20,6 +21,10 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push( vscode.commands.registerCommand("extension.open.project", (pomPath) => devCommands.openProject(pomPath)), ); + context.subscriptions.push( + vscode.commands.registerCommand('liberty.starterProject', () => starterProject(context)) + ); + context.subscriptions.push( vscode.commands.registerCommand("liberty.dev.start", (libProject?: LibertyProject) => devCommands.startDevMode(libProject)), ); diff --git a/src/liberty/devCommands.ts b/src/liberty/devCommands.ts index 869bf3f5..874a13f5 100644 --- a/src/liberty/devCommands.ts +++ b/src/liberty/devCommands.ts @@ -1,13 +1,14 @@ - import * as fs from "fs"; import * as Path from "path"; import * as vscode from "vscode"; +import axios from "axios"; import { LibertyProject } from "./libertyProject"; +import * as starterProject from "./starterProject"; import { getReport } from "../util/helperUtil"; import { LIBERTY_MAVEN_PROJECT, LIBERTY_GRADLE_PROJECT, LIBERTY_MAVEN_PROJECT_CONTAINER, LIBERTY_GRADLE_PROJECT_CONTAINER } from "../definitions/constants"; import { getGradleTestReport } from "../util/gradleUtil"; import { pathExists } from "fs-extra"; - + export const terminals: { [libProjectId: number]: LibertyProject } = {}; let _customParameters = ""; @@ -145,6 +146,58 @@ export async function startContainerDevMode(libProject?: LibertyProject | undefi } } +/** + * Downloads a starter project from https://start.openliberty.io/ + * @param state see {@link starterProject.State} + * @param libProject see {@link LibertyProject} + */ +export async function buildStarterProject( state?: any, libProject?: LibertyProject | undefined): Promise { + var apiURL = `https://start.openliberty.io/api/start?a=${state.a}&b=${state.b}&e=${state.e}&g=${state.g}&j=${state.j}&m=${state.m}`; + var targetDir = `${state.dir}/${state.a}`; + const targetUri = vscode.Uri.file(targetDir); + + /** + * Decides what window to use when opening the project + */ + async function toBeHereOrNotToBeHere() { + var newWin = false; + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0].uri.fsPath != targetDir) { + await vscode.window.showInformationMessage("Where would you like to open the project?", "Current Window", "New Window") + .then(selection => { + if (selection != "Current Window") { newWin = true; } + }); + } + vscode.commands.executeCommand(`vscode.openFolder`, targetUri, newWin); + } + + /** + * gets zip -> unzips zip -> removes zip + */ + (async function(): Promise { + let downloadLocation = `${targetDir}.zip`; + axios({ + method: "get", + url: apiURL, + responseType: "stream" + }).then( function (response){ + response.data.pipe(fs.createWriteStream(downloadLocation)) + .on("close", () => { + var unzip = require("unzip-stream"); + fs.createReadStream(downloadLocation).pipe(unzip.Extract({ path: targetDir })); + fs.unlink(downloadLocation, async (err) => { toBeHereOrNotToBeHere() }) + }) + }); + }()) + + /** + * TODO: Convert to async/await + * Waits 3 seconds and refreshes the file explorer. + */ + await new Promise((resolve) => { + setTimeout(() => { resolve(true); }, 3000); + }).then(function() {vscode.commands.executeCommand("workbench.files.action.refreshFilesExplorer");}); +} + // run tests on dev mode export async function runTests(libProject?: LibertyProject | undefined): Promise { if (libProject !== undefined) { diff --git a/src/liberty/starterProject.ts b/src/liberty/starterProject.ts new file mode 100644 index 00000000..00104c12 --- /dev/null +++ b/src/liberty/starterProject.ts @@ -0,0 +1,403 @@ +import { QuickPickItem, window, Disposable, QuickInputButton, QuickInput, ExtensionContext, QuickInputButtons } from 'vscode'; +import * as devCommands from "./devCommands"; +import { getProjectOptions } from '../definitions/starterOptions'; +import * as vscode from "vscode"; +import * as fs from "fs"; + +export interface State { + a: QuickPickItem | string, + b: QuickPickItem | string, + e: QuickPickItem | string, + g: QuickPickItem | string, + j: QuickPickItem | string, + m: QuickPickItem | string, + step: number; + totalSteps: number; + dir: QuickPickItem | string; +} + +export async function starterProject(context: ExtensionContext) { + + const projectOptions = await getProjectOptions(); + + const comparator = (a: any, b: any) => { + return b - a; + }; + + const buildTools: QuickPickItem[] = projectOptions.b.options + .map((label: any) => ({ label })); + + const javaSEVersions: QuickPickItem[] = projectOptions.j.options + .sort(comparator) + .map((label: any) => ({ label })); + + const javaEEVersions: QuickPickItem[] = projectOptions.e.options + .sort(comparator) + .map((label: any) => ({ label })); + + async function collectInputs() { + const state = {} as Partial; + await MultiStepInput.run(input => inputGroupName(input, state)); + return state as State; + } + + const title = 'Create Open Liberty Starter Code'; + + async function inputGroupName(input: MultiStepInput, state: Partial) :Promise { + state.g = await input.showInputBox({ + title, + step: 1, + totalSteps: 6, + value: state.g || projectOptions.g.default, + prompt: projectOptions.g.name, + validate: validGroupName, + shouldResume: shouldResume + }); + return (input: MultiStepInput) => inputArtifactName(input, state); + } + + async function inputArtifactName(input: MultiStepInput, state: Partial) :Promise { + state.a = await input.showInputBox({ + title, + step: 2, + totalSteps: 6, + value: state.a || projectOptions.a.default, + prompt: projectOptions.a.name, + validate: validArtifactName, + shouldResume: shouldResume + }); + return (input: MultiStepInput) => pickResourceGroup(input, state); + } + + async function pickResourceGroup(input: MultiStepInput, state: Partial) { + state.b = await input.showQuickPick({ + title, + step: 3, + totalSteps: 6, + placeholder: projectOptions.b.name, + activeItem: buildTools[buildTools.findIndex(item => item.label === projectOptions.b.default)], + items: buildTools, + value: state.b, + shouldResume: shouldResume + }); + state.b = state.b.label; + return (input: MultiStepInput) => pickJavaSE(input, state); + } + + async function pickJavaSE(input: MultiStepInput, state: Partial) { + state.j = await input.showQuickPick({ + title, + step: 4, + totalSteps: 6, + placeholder: projectOptions.j.name, + activeItem: javaSEVersions[javaSEVersions.findIndex(item => item.label === projectOptions.j.default)], + items: javaSEVersions, + value: state.j, + shouldResume: shouldResume + }); + state.j = state.j.label; + return (input: MultiStepInput) => pickJavaEE(input, state); + } + + async function pickJavaEE(input: MultiStepInput, state: Partial) { + state.e = await input.showQuickPick({ + title, + step: 5, + totalSteps: 6, + placeholder: projectOptions.e.name, + activeItem: javaEEVersions[javaEEVersions.findIndex(item => item.label === projectOptions.e.default)], + items: javaEEVersions, + value: state.e, + shouldResume: shouldResume + }); + state.e = state.e.label; + var MPVersions: QuickPickItem[] = projectOptions.e.constraints[state.e].m + .sort(comparator) + .map((label: any) => ({ label })); + return (input: MultiStepInput) => pickMP(input, state, MPVersions); + } + + async function pickMP(input: MultiStepInput, state: Partial, MPVersions: QuickPickItem[]) { + state.m = await input.showQuickPick({ + title, + step: 6, + totalSteps: 6, + placeholder: projectOptions.m.name, + activeItem: MPVersions[MPVersions.findIndex(item => item.label === projectOptions.m.default)], + items: MPVersions, + value: state.m, + shouldResume: shouldResume + }); + state.m = state.m.label; + return (input: MultiStepInput) => pickDirectory(input, state); + } + + async function pickDirectory(input: MultiStepInput, state: Partial) { + const folder = await window.showOpenDialog({ + canSelectMany: false, + openLabel: 'Select', + canSelectFiles: false, + canSelectFolders: true + }) + .then(async response => { + if (response) { + if (fs.existsSync(`${response[0].path}/${state.a}`)) { + await window.showErrorMessage(`${state.a} already exists in ${response[0].path}. ${state.a} will be replaced, are you sure you want to continue?`, "yes", "no") + .then(selection => { + if (selection == "yes") { + fs.rmdirSync(`${response[0].path}/${state.a}`, { recursive: true }); + state.dir = response[0].path; + } else { + return pickNewName(input, state); + } + }); + } else { + state.dir = response[0].path; + } + } else { + const res = await shouldResume(); + if (res) { + return pickDirectory(input, state); + } + } + }); + } + + async function pickNewName(input: MultiStepInput, state: Partial) { + state.a = await input.showInputBox({ + title, + step: 2, + totalSteps: 6, + value: state.a || projectOptions.a.default, + prompt: "Enter a different artifact name", + validate: validArtifactName, + shouldResume: shouldResume + }); + return pickDirectory(input, state); + } + + async function shouldResume() { + return new Promise((resolve) => { + window.showInformationMessage("Would you like to resume Liberty project generation?", "yes", "no") + .then(selection => { + if (selection == "yes") { + return resolve(true); + } else { + return resolve(false); + } + }); + }); + } + + async function validArtifactName(name: string) { + const regexp = new RegExp("^([a-z]+-)*[a-z]+$", "i"); + if (! regexp.test(name) ) { + return("App name must be a-z characters separated by dashes"); + } else { + return undefined; + } + } + + async function validGroupName(name: string) { + const regexp = new RegExp("^([a-z]+\\.)*[a-z]+$", "i"); + if (! regexp.test(name) ) { + return("Group name must be a-z separated by periods"); + } else { + return undefined; + } + } + + const state = await collectInputs(); + + window.withProgress({ + location: vscode.ProgressLocation.Window, + cancellable: false, + title: `Creating starter code for ${state.a}` + }, async (progress) => { + progress.report({ increment: 0 }); + await new Promise((resolve) => { + setTimeout(() => { resolve(true); }, 3000); + }); + progress.report({ increment: 100 }); + }); + + devCommands.buildStarterProject(state); +} + + +// ------------------------------------------------------- +// Helper code that wraps the API for the multi-step case. +// ------------------------------------------------------- + + +class InputFlowAction { + static back = new InputFlowAction(); + static cancel = new InputFlowAction(); + static resume = new InputFlowAction(); +} + +type InputStep = (input: MultiStepInput) => Thenable; + +interface QuickPickParameters { + title: string; + step: number; + totalSteps: number; + items: T[]; + activeItem?: T; + placeholder: string; + buttons?: QuickInputButton[]; + shouldResume: () => Thenable; +} + +interface InputBoxParameters { + title: string; + step: number; + totalSteps: number; + value: string; + prompt: string; + validate: (value: string) => Promise; + buttons?: QuickInputButton[]; + shouldResume: () => Thenable; +} + +class MultiStepInput { + + static async run(start: InputStep) { + const input = new MultiStepInput(); + return input.stepThrough(start); + } + + private current?: QuickInput; + private steps: InputStep[] = []; + + private async stepThrough(start: InputStep) { + let step: InputStep | void = start; + while (step) { + this.steps.push(step); + if (this.current) { + this.current.enabled = false; + this.current.busy = true; + } + try { + step = await step(this); + } catch (err) { + if (err === InputFlowAction.back) { + this.steps.pop(); + step = this.steps.pop(); + } else if (err === InputFlowAction.resume) { + step = this.steps.pop(); + } else if (err === InputFlowAction.cancel) { + step = undefined; + } else { + throw err; + } + } + } + if (this.current) { + this.current.dispose(); + } + } + + async showQuickPick>({ title, step, totalSteps, items, activeItem, placeholder, buttons, shouldResume }: P) { + const disposables: Disposable[] = []; + try { + return await new Promise((resolve, reject) => { + const input = window.createQuickPick(); + input.title = title; + input.step = step; + input.totalSteps = totalSteps; + input.placeholder = placeholder; + input.items = items; + if (activeItem) { + input.activeItems = [activeItem]; + } + input.buttons = [ + ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), + ...(buttons || []) + ]; + disposables.push( + input.onDidTriggerButton(item => { + if (item === QuickInputButtons.Back) { + reject(InputFlowAction.back); + } else { + resolve(item); + } + }), + input.onDidChangeSelection(items => resolve(items[0])), + input.onDidHide(() => { + (async () => { + reject(shouldResume && await shouldResume() ? InputFlowAction.resume : InputFlowAction.cancel); + })() + .catch(reject); + }) + ); + if (this.current) { + this.current.dispose(); + } + this.current = input; + this.current.show(); + }); + } finally { + disposables.forEach(d => d.dispose()); + } + } + + async showInputBox

({ title, step, totalSteps, value, prompt, validate, buttons, shouldResume }: P) { + const disposables: Disposable[] = []; + try { + return await new Promise((resolve, reject) => { + const input = window.createInputBox(); + input.title = title; + input.step = step; + input.totalSteps = totalSteps; + input.value = value || ''; + input.prompt = prompt; + input.buttons = [ + ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), + ...(buttons || []) + ]; + let validating = validate(''); + disposables.push( + input.onDidTriggerButton(item => { + if (item === QuickInputButtons.Back) { + reject(InputFlowAction.back); + } else { + resolve(item); + } + }), + input.onDidAccept(async () => { + const value = input.value; + input.enabled = false; + input.busy = true; + if (!(await validate(value))) { + resolve(value); + } + input.enabled = true; + input.busy = false; + }), + input.onDidChangeValue(async text => { + const current = validate(text); + validating = current; + const validationMessage = await current; + if (current === validating) { + input.validationMessage = validationMessage; + } + }), + input.onDidHide(() => { + (async () => { + reject(shouldResume && await shouldResume() ? InputFlowAction.resume : InputFlowAction.cancel); + })() + .catch(reject); + }) + ); + if (this.current) { + this.current.dispose(); + } + this.current = input; + this.current.show(); + }); + } finally { + disposables.forEach(d => d.dispose()); + } + } +} diff --git a/src/util/gradleUtil.ts b/src/util/gradleUtil.ts index 744d313f..408cf660 100644 --- a/src/util/gradleUtil.ts +++ b/src/util/gradleUtil.ts @@ -11,7 +11,7 @@ import { BuildFile, GradleBuildFile } from "./buildFile"; * * @param buildFile JS object representation of the build.gradle */ -export function validGradleBuild(buildFile: any): GradleBuildFile { + export function validGradleBuild(buildFile: any): GradleBuildFile { if (buildFile !== undefined && buildFile.apply !== undefined && buildFile.buildscript !== undefined && buildFile.buildscript.dependencies !== undefined) { // check that "apply plugin: 'liberty'" is specified in the build.gradle let libertyPlugin = false;