diff --git a/README.md b/README.md index ce991deff..ac7fa32b5 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ USAGE * [`mw autocomplete`](docs/autocomplete.md) - Display autocomplete installation instructions. * [`mw backup`](docs/backup.md) - Manage backups of your projects * [`mw context`](docs/context.md) - Save certain environment parameters for later use +* [`mw contributor`](docs/contributor.md) - Commands for mStudio marketplace contributors * [`mw conversation`](docs/conversation.md) - Manage your support cases * [`mw cronjob`](docs/cronjob.md) - Manage cronjobs of your projects * [`mw database`](docs/database.md) - Manage databases (like MySQL and Redis) in your projects diff --git a/docs/contributor.md b/docs/contributor.md new file mode 100644 index 000000000..7424ee19c --- /dev/null +++ b/docs/contributor.md @@ -0,0 +1,112 @@ +`mw contributor` +================ + +Commands for mStudio marketplace contributors + +* [`mw contributor extension deploy EXTENSION-MANIFEST`](#mw-contributor-extension-deploy-extension-manifest) +* [`mw contributor extension init EXTENSION-MANIFEST`](#mw-contributor-extension-init-extension-manifest) +* [`mw contributor extension publish EXTENSION-MANIFEST`](#mw-contributor-extension-publish-extension-manifest) +* [`mw contributor extension withdraw EXTENSION-MANIFEST`](#mw-contributor-extension-withdraw-extension-manifest) + +## `mw contributor extension deploy EXTENSION-MANIFEST` + +Deploy an extension manifest to the marketplace + +``` +USAGE + $ mw contributor extension deploy EXTENSION-MANIFEST [-q] [--create] + +ARGUMENTS + EXTENSION-MANIFEST [default: ./mstudio-extension.yaml] file path to the extension manifest (as YAML or JSON) + +FLAGS + -q, --quiet suppress process output and only display a machine-readable summary. + --[no-]create create the extension if it does not exist + +DESCRIPTION + Deploy an extension manifest to the marketplace + +FLAG DESCRIPTIONS + -q, --quiet suppress process output and only display a machine-readable summary. + + This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in + scripts), you can use this flag to easily get the IDs of created resources for further processing. +``` + +## `mw contributor extension init EXTENSION-MANIFEST` + +Init a new extension manifest file + +``` +USAGE + $ mw contributor extension init EXTENSION-MANIFEST [-q] [--overwrite] + +ARGUMENTS + EXTENSION-MANIFEST [default: ./mstudio-extension.yaml] file path to the extension manifest (as YAML or JSON) + +FLAGS + -q, --quiet suppress process output and only display a machine-readable summary. + --overwrite overwrite an existing extension manifest if found + +DESCRIPTION + Init a new extension manifest file + + This command will create a new extension manifest file. It only operates on your local file system; afterwards, use + the 'deploy' command to upload the manifest to the marketplace. + +FLAG DESCRIPTIONS + -q, --quiet suppress process output and only display a machine-readable summary. + + This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in + scripts), you can use this flag to easily get the IDs of created resources for further processing. +``` + +## `mw contributor extension publish EXTENSION-MANIFEST` + +Publish an extension on the marketplace + +``` +USAGE + $ mw contributor extension publish EXTENSION-MANIFEST [-q] + +ARGUMENTS + EXTENSION-MANIFEST [default: ./mstudio-extension.yaml] file path to the extension manifest (as YAML or JSON) + +FLAGS + -q, --quiet suppress process output and only display a machine-readable summary. + +DESCRIPTION + Publish an extension on the marketplace + +FLAG DESCRIPTIONS + -q, --quiet suppress process output and only display a machine-readable summary. + + This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in + scripts), you can use this flag to easily get the IDs of created resources for further processing. +``` + +## `mw contributor extension withdraw EXTENSION-MANIFEST` + +Withdraw an extension from the marketplace + +``` +USAGE + $ mw contributor extension withdraw EXTENSION-MANIFEST --reason [-q] [-f] + +ARGUMENTS + EXTENSION-MANIFEST [default: ./mstudio-extension.yaml] file path to the extension manifest (as YAML or JSON) + +FLAGS + -f, --force do not ask for confirmation + -q, --quiet suppress process output and only display a machine-readable summary. + --reason= (required) reason for withdrawal + +DESCRIPTION + Withdraw an extension from the marketplace + +FLAG DESCRIPTIONS + -q, --quiet suppress process output and only display a machine-readable summary. + + This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in + scripts), you can use this flag to easily get the IDs of created resources for further processing. +``` diff --git a/src/commands/contributor/extension/deploy.tsx b/src/commands/contributor/extension/deploy.tsx new file mode 100644 index 000000000..37350ba71 --- /dev/null +++ b/src/commands/contributor/extension/deploy.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { ExecRenderBaseCommand } from "../../../lib/basecommands/ExecRenderBaseCommand.js"; +import { + makeProcessRenderer, + processFlags, +} from "../../../rendering/process/process_flags.js"; +import { Flags } from "@oclif/core"; +import { assertStatus } from "@mittwald/api-client"; +import { Success } from "../../../rendering/react/components/Success.js"; +import { + extensionManifestArg, + parseExtensionManifest, +} from "../../../lib/resources/extension/args_contributor.js"; + +const createFlagName = "create"; + +export default class Deploy extends ExecRenderBaseCommand { + static description = "Deploy an extension manifest to the marketplace"; + + static flags = { + ...processFlags, + [createFlagName]: Flags.boolean({ + description: "create the extension if it does not exist", + default: true, + allowNo: true, + }), + }; + + static args = { + "extension-manifest": extensionManifestArg({ + required: true, + }), + }; + + protected async exec(): Promise { + const p = makeProcessRenderer(this.flags, "Updating extension manifest"); + + const manifest = await parseExtensionManifest( + this.args["extension-manifest"], + ); + const { contributorId, id } = manifest; + + const existing = await p.runStep( + "Retrieving current extension state", + async () => { + const response = + await this.apiClient.marketplace.extensionGetOwnExtension({ + contributorId, + extensionId: id, + }); + + if (response.status === 404) { + return null; + } + + assertStatus(response, 200); + + return response.data; + }, + ); + + if (existing === null) { + if (!this.flags[createFlagName]) { + await p.error( + `Extension does not exist, use --${createFlagName} to create it`, + ); + return; + } + + await p.runStep("Registering extension", async () => { + if (manifest.deprecation) { + throw new Error( + '"deprecation" is not supported when creating a new extension', + ); + } + + await this.apiClient.marketplace.extensionRegisterExtension({ + contributorId, + + // Note: This mapping step is necessary because the API apparently + // does not like additional attributes which may be present in the + // manifest file. Also, the input formats differ slightly for the + // POST and PATCH endpoints. + data: { + description: manifest.description, + detailedDescriptions: manifest.detailedDescriptions, + externalFrontends: manifest.externalFrontends, + frontendFragments: manifest.frontendFragments, + name: manifest.name, + scopes: manifest.scopes, + subTitle: manifest.subTitle, + support: manifest.support, + tags: manifest.tags, + webhookURLs: manifest.webhookUrls, + }, + }); + }); + } else { + await p.runStep("Updating extension", async () => { + await this.apiClient.marketplace.extensionPatchExtension({ + extensionId: manifest.id, + contributorId: manifest.contributorId, + + // Note: This mapping step is necessary because the API apparently + // does not like additional attributes which may be present in the + // manifest file. Also, the input formats differ slightly for the + // POST and PATCH endpoints. + data: { + deprecation: manifest.deprecation, + description: manifest.description, + detailedDescriptions: manifest.detailedDescriptions, + externalFrontends: manifest.externalFrontends, + frontendFragments: manifest.frontendFragments, + name: manifest.name, + scopes: manifest.scopes, + subTitle: manifest.subTitle, + support: manifest.support, + tags: manifest.tags, + webhookUrls: manifest.webhookUrls, + }, + }); + }); + } + + await p.complete(Extension deployed successfully); + } + + protected render(): React.ReactNode { + return undefined; + } +} diff --git a/src/commands/contributor/extension/init.tsx b/src/commands/contributor/extension/init.tsx new file mode 100644 index 000000000..cd9c34793 --- /dev/null +++ b/src/commands/contributor/extension/init.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { ExecRenderBaseCommand } from "../../../lib/basecommands/ExecRenderBaseCommand.js"; +import { + makeProcessRenderer, + processFlags, +} from "../../../rendering/process/process_flags.js"; +import { Flags } from "@oclif/core"; +import { Success } from "../../../rendering/react/components/Success.js"; +import { extensionManifestArg } from "../../../lib/resources/extension/args_contributor.js"; +import { writeFile } from "fs/promises"; +import { Value } from "../../../rendering/react/components/Value.js"; +import { pathExists } from "../../../lib/util/fs/pathExists.js"; +import { generateInitialExtensionManifest } from "../../../lib/resources/extension/init.js"; +import { ManifestAlreadyExistsError } from "../../../lib/resources/extension/init_error.js"; + +const overwriteFlagName = "overwrite"; + +export default class Init extends ExecRenderBaseCommand { + static summary = "Init a new extension manifest file"; + static description = + "This command will create a new extension manifest file. It only operates on your local file system; afterwards, use the 'deploy' command to upload the manifest to the marketplace."; + + static flags = { + ...processFlags, + [overwriteFlagName]: Flags.boolean({ + description: "overwrite an existing extension manifest if found", + default: false, + }), + }; + + static args = { + "extension-manifest": extensionManifestArg({ + required: true, + }), + }; + + protected async exec(): Promise { + const p = makeProcessRenderer( + this.flags, + "Initializing extension manifest", + ); + + const { overwrite } = this.flags; + const target = this.args["extension-manifest"]; + + await p.runStep("generating extension manifest file", async () => { + const renderedConfiguration = generateInitialExtensionManifest(); + const manifestAlreadyExists = await pathExists(target); + + if (manifestAlreadyExists && !overwrite) { + throw new ManifestAlreadyExistsError(target, overwriteFlagName); + } + + await writeFile(target, renderedConfiguration); + }); + + await p.complete( + + Extension manifest file created at {target} + , + ); + } + + protected render(): React.ReactNode { + return undefined; + } +} diff --git a/src/commands/contributor/extension/publish.tsx b/src/commands/contributor/extension/publish.tsx new file mode 100644 index 000000000..b93b3600c --- /dev/null +++ b/src/commands/contributor/extension/publish.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { ExecRenderBaseCommand } from "../../../lib/basecommands/ExecRenderBaseCommand.js"; +import { + makeProcessRenderer, + processFlags, +} from "../../../rendering/process/process_flags.js"; +import { Success } from "../../../rendering/react/components/Success.js"; +import { + extensionManifestArg, + parseExtensionManifest, +} from "../../../lib/resources/extension/args_contributor.js"; + +export default class Publish extends ExecRenderBaseCommand< + typeof Publish, + void +> { + static description = "Publish an extension on the marketplace"; + + static flags = { + ...processFlags, + }; + + static args = { + "extension-manifest": extensionManifestArg({ required: true }), + }; + + protected async exec(): Promise { + const p = makeProcessRenderer(this.flags, "Publishing extension"); + + const manifest = await parseExtensionManifest( + this.args["extension-manifest"], + ); + const { contributorId, id } = manifest; + + await p.runStep("Publishing extension", async () => { + await this.apiClient.marketplace.extensionSetExtensionPublishedState({ + contributorId, + extensionId: id, + data: { + published: true, + }, + }); + }); + + await p.complete(Extension published successfully); + } + + protected render(): React.ReactNode { + return undefined; + } +} diff --git a/src/commands/contributor/extension/withdraw.tsx b/src/commands/contributor/extension/withdraw.tsx new file mode 100644 index 000000000..26d205075 --- /dev/null +++ b/src/commands/contributor/extension/withdraw.tsx @@ -0,0 +1,107 @@ +import React, { FC } from "react"; +import { ExecRenderBaseCommand } from "../../../lib/basecommands/ExecRenderBaseCommand.js"; +import { + makeProcessRenderer, + processFlags, +} from "../../../rendering/process/process_flags.js"; +import { Flags, ux } from "@oclif/core"; +import { Success } from "../../../rendering/react/components/Success.js"; +import { + extensionManifestArg, + parseExtensionManifest, +} from "../../../lib/resources/extension/args_contributor.js"; +import { Text } from "ink"; +import { Value } from "../../../rendering/react/components/Value.js"; +import { ExtensionManifest } from "../../../lib/resources/extension/manifest.js"; +import { ProcessRenderer } from "../../../rendering/process/process.js"; + +export default class Withdraw extends ExecRenderBaseCommand< + typeof Withdraw, + void +> { + static description = "Withdraw an extension from the marketplace"; + + static flags = { + ...processFlags, + force: Flags.boolean({ + char: "f", + description: "do not ask for confirmation", + }), + reason: Flags.string({ + description: "reason for withdrawal", + required: true, + }), + }; + + static args = { + "extension-manifest": extensionManifestArg({ required: true }), + }; + + protected async exec(): Promise { + const p = makeProcessRenderer(this.flags, "Withdrawing extension"); + + const { reason } = this.flags; + const manifest = await parseExtensionManifest( + this.args["extension-manifest"], + ); + const { contributorId, id } = manifest; + + if (!(await this.shouldProceed(p, manifest))) { + ux.exit(1); + } + + await p.runStep("Withdrawing extension", async () => { + await this.apiClient.marketplace.extensionSetExtensionPublishedState({ + contributorId, + extensionId: id, + data: { + published: false, + reason, + }, + }); + }); + + await p.complete(Extension withdrawn successfully); + } + + protected async shouldProceed( + p: ProcessRenderer, + manifest: ExtensionManifest, + ): Promise { + if (this.flags.force) { + return true; + } + + const confirmed = await p.addConfirmation( + , + ); + if (confirmed) { + return true; + } + + p.addInfo(); + p.complete(<>); + + return false; + } + + protected render(): React.ReactNode { + return undefined; + } +} + +const TextConfirmWithdrawal: FC<{ manifest: ExtensionManifest }> = ({ + manifest, +}) => ( + + confirm withdrawal of extension {manifest.name} + +); + +const TextWithdrawalCancelled: FC<{ manifest: ExtensionManifest }> = ({ + manifest, +}) => ( + + withdrawal of extension {manifest.name} was cancelled + +); diff --git a/src/lib/resources/extension/args_contributor.ts b/src/lib/resources/extension/args_contributor.ts new file mode 100644 index 000000000..4d3642114 --- /dev/null +++ b/src/lib/resources/extension/args_contributor.ts @@ -0,0 +1,16 @@ +import { Args } from "@oclif/core"; +import { ExtensionManifest } from "./manifest.js"; +import yaml from "js-yaml"; +import { readFile } from "fs/promises"; + +export const extensionManifestArg = Args.custom({ + description: "file path to the extension manifest (as YAML or JSON)", + default: "./mstudio-extension.yaml", +}); + +export async function parseExtensionManifest( + filename: string, +): Promise { + const contents = await readFile(filename, { encoding: "utf-8" }); + return yaml.load(contents) as ExtensionManifest; +} diff --git a/src/lib/resources/extension/init.test.ts b/src/lib/resources/extension/init.test.ts new file mode 100644 index 000000000..51be50699 --- /dev/null +++ b/src/lib/resources/extension/init.test.ts @@ -0,0 +1,21 @@ +import { generateInitialExtensionManifest } from "./init.js"; +import { describe, expect, it } from "@jest/globals"; +import yaml from "js-yaml"; +import * as uuid from "uuid"; + +describe(generateInitialExtensionManifest.name, () => { + it("should generate valid YAML", () => { + const generated = generateInitialExtensionManifest(); + const output = yaml.load(generated); + + expect(typeof output).toEqual("object"); + }); + + it("should contain a random UUID", () => { + const generated = generateInitialExtensionManifest(); + const output = yaml.load(generated); + + expect(output).toHaveProperty("id"); + expect(uuid.validate((output as any).id)).toBe(true); + }); +}); diff --git a/src/lib/resources/extension/init.ts b/src/lib/resources/extension/init.ts new file mode 100644 index 000000000..4c12c2a33 --- /dev/null +++ b/src/lib/resources/extension/init.ts @@ -0,0 +1,95 @@ +import * as uuid from "uuid"; + +/** + * Generate a sample extension manifest file. + * + * This function generates a sample extension manifest file for mStudio + * extensions. The generated file contains a lot of comments to help the user + * understand the purpose of each field. + */ +export function generateInitialExtensionManifest(): string { + // Note: This is a hardcoded string (instead of a yaml object) because we + // want this output to contain lots of comments. + return ` +# This is a sample manifest file for an mStudio extension. +# You can use this file as a starting point for your own extension. +# +# Publish your extension to the marketplace by running: +# $ mittwald contributor extension deploy + +# This is the name of your extension. It will be displayed in the marketplace. +name: my-extension + +# The extension ID. This is a unique identifier for your extension. It must be +# unique across all extensions in the marketplace. +id: ${uuid.v4()} + +# Your contributor ID. This is the ID of your mStudio organization. The +# organization must be registered as a contributor in the marketplace. See +# https://developer.mittwald.de/docs/v2/contribution/how-to/become-contributor/ +# for more information. +contributorId: TODO + +# The description of your extension. This will be displayed in the marketplace. +description: TODO + +# The detailed description of your extension. This will be displayed in the +# marketplace. You will need to provide a plain text and a markdown version of the +# description. +detailedDescriptions: + de: + markdown: TODO + plain: TODO + en: + markdown: TODO + plain: TODO + +# The subtitle of your extension. This will be displayed in the marketplace. +subTitle: + de: TODO + en: TODO + +# The tags of your extension. This will be used to categorize your extension in +# the marketplace. +tags: + - TODO + +# The scopes of your extension. This will be used to determine the permissions +# of your extension. See https://developer.mittwald.de/docs/v2/contribution/overview/concepts/scopes/ +# for more information. +scopes: + - user:read + +# The support information of your extension. This will be displayed in the +# marketplace. +support: + email: todo@mstudio-extension.example + phone: +49 0000 000000 + +# The external frontends of your extension. This will be used to determine the +# URLs of your extension. See https://developer.mittwald.de/docs/v2/contribution/overview/concepts/external-frontend/ +# for more information. +externalFrontends: + - name: example + url: https://mstudio-extension.example/auth/oneclick?atrek=:accessTokenRetrievalKey&userId=:userId&instanceID=:extensionInstanceId + +# The frontend fragments of your extension. This will be used to determine the +# URLs of your extension. +frontendFragments: + foo: + url: https://mstudio-extension.example/ + +# The webhook URLs of your extension. This will be used to determine the +# URLs of your extension. See https://developer.mittwald.de/docs/v2/contribution/overview/concepts/lifecycle-webhooks/ +# for more information. +webhookUrls: + extensionAddedToContext: + url: https://mstudio-extension.example/webhooks + extensionInstanceUpdated: + url: https://mstudio-extension.example/webhooks + extensionInstanceSecretRotated: + url: https://mstudio-extension.example/webhooks + extensionInstanceRemovedFromContext: + url: https://mstudio-extension.example/webhooks +`.trim(); +} diff --git a/src/lib/resources/extension/init_error.ts b/src/lib/resources/extension/init_error.ts new file mode 100644 index 000000000..7f4dc747f --- /dev/null +++ b/src/lib/resources/extension/init_error.ts @@ -0,0 +1,12 @@ +/** + * Domain-specific error that is thrown when attempting to initialize a new + * extension manifest file, but a file with the same name already exists. + */ +export class ManifestAlreadyExistsError extends Error { + public readonly filename: string; + + constructor(filename: string, overwriteFlagName: string) { + super(`File already exists. Use --${overwriteFlagName} to overwrite it.`); + this.filename = filename; + } +} diff --git a/src/lib/resources/extension/manifest.ts b/src/lib/resources/extension/manifest.ts new file mode 100644 index 000000000..3a8294ea9 --- /dev/null +++ b/src/lib/resources/extension/manifest.ts @@ -0,0 +1,24 @@ +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +export type ExtensionManifest = Pick< + MittwaldAPIV2.Components.Schemas.MarketplaceOwnExtension, + | "id" + | "contributorId" + | "name" + | "context" + | "description" + | "detailedDescriptions" + | "externalFrontends" + | "frontendFragments" + | "scopes" + | "subTitle" + | "support" + | "tags" + | "webhookUrls" +> & { + deprecation?: { + deprecatedAt: string; + note?: string; + successorId?: string; + }; +};