diff --git a/src/actions/plugin/create-config.ts b/src/actions/plugin/create-config.ts new file mode 100644 index 00000000..7aaaf0a3 --- /dev/null +++ b/src/actions/plugin/create-config.ts @@ -0,0 +1,74 @@ +import { ApiService } from '../../infrastructure/services/api-service.js'; +import { PluginCreateConfigPrompts } from '../../prompts/plugin/create-config.js'; +import { SubscriptionInfo } from '../../types/api/account.js'; +import { CommandMetadata } from '../../types/common/command-metadata.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { PluginAuthor, PluginMetadata } from '../../types/plugin/plugin-config.js'; +import { PluginConfigContext } from '../../types/plugin-config-context.js'; +import { toKebabCase, toTitleCase } from '../../utils/string-utils.js'; +import { ActionResult } from '../action-result.js'; + +const DEFAULT_PLUGIN_VERSION = '0.1.0'; + +/** + * Writes the plugin's own details into `plugin-config.json`, creating the file when absent. Only + * `plugin generate` uses this: `sdk publish` records languages and leaves identity alone, so a + * project can be published from long before anyone decides to build a context plugin. + */ +export class PluginCreateConfigAction { + private readonly prompts: PluginCreateConfigPrompts = new PluginCreateConfigPrompts(); + private readonly apiService: ApiService = new ApiService(); + private readonly configDir: DirectoryPath; + private readonly commandMetadata: CommandMetadata; + private readonly authKey: string | null; + + constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { + this.configDir = configDir; + this.commandMetadata = commandMetadata; + this.authKey = authKey; + } + + public readonly execute = async (buildDirectory: DirectoryPath): Promise => { + const metadata = await this.prompts.inputPluginMetadata(defaultMetadata(buildDirectory)); + if (!metadata) { + return ActionResult.cancelled(); + } + + // Asked after the prompts so a network failure cannot discard what the user just typed. The + // account supplies only the optional author, so failing here would cost more than it saves. + const account = await this.prompts.spinnerAccountInfo( + this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, this.authKey) + ); + if (account.isErr()) { + this.prompts.accountInfoUnavailable(); + } + + const author = account.isOk() ? authorOf(account.value) : undefined; + const written = await new PluginConfigContext(buildDirectory).upsertMetadata(metadata, author); + if (written !== 'written') { + if (written === 'unreadable') { + this.prompts.pluginConfigUnreadable(); + } else { + this.prompts.pluginConfigNotWritten(); + } + return ActionResult.failed(); + } + + this.prompts.pluginConfigCreated(metadata); + return ActionResult.success(); + }; +} + +/** Seeded from the project folder holding `src`, which is the closest thing to an API name on disk. */ +function defaultMetadata(buildDirectory: DirectoryPath): PluginMetadata { + const projectName = buildDirectory.parent().leafName(); + return { + pluginId: toKebabCase(projectName), + pluginName: toTitleCase(projectName), + pluginVersion: DEFAULT_PLUGIN_VERSION + }; +} + +function authorOf(account: SubscriptionInfo): PluginAuthor | undefined { + return account.FullName ? { name: account.FullName, email: account.Email || undefined } : undefined; +} diff --git a/src/actions/plugin/generate.ts b/src/actions/plugin/generate.ts index 7427f9b2..9a39e9ba 100644 --- a/src/actions/plugin/generate.ts +++ b/src/actions/plugin/generate.ts @@ -4,9 +4,11 @@ import { PluginGeneratePrompts } from '../../prompts/plugin/generate.js'; import { BuildContext } from '../../types/build-context.js'; import { CommandMetadata } from '../../types/common/command-metadata.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { PluginConfigContext } from '../../types/plugin-config-context.js'; import { PluginContext } from '../../types/plugin-context.js'; import { TempContext } from '../../types/temp-context.js'; import { ActionResult } from '../action-result.js'; +import { PluginCreateConfigAction } from './create-config.js'; export class PluginGenerateAction { private readonly prompts: PluginGeneratePrompts = new PluginGeneratePrompts(); @@ -44,6 +46,33 @@ export class PluginGenerateAction { return ActionResult.cancelled(); } + const configState = await new PluginConfigContext(buildDirectory).validate(); + if (configState.state === 'unreadable') { + this.prompts.pluginConfigUnreadable(configState.reason, configState.path); + return ActionResult.failed(); + } + + // Read before the metadata prompts, which do not touch languages: `sdk publish` is the only + // thing that records them, so what is on disk now is what the run has to work with. + const namesAnySdk = configState.state === 'present' && configState.hasLanguages; + + if (configState.state === 'missing' || !configState.hasMetadata) { + const created = await this.createConfig(buildDirectory); + if (created.isCancelled()) { + this.prompts.metadataCancelled(); + } + if (!created.isSuccess()) { + return created; + } + } + + // The config is complete but names nothing to build, so there is no point uploading it. + if (!namesAnySdk) { + this.prompts.noPublishedSdks(); + this.prompts.nextStepsPublishSdks(); + return ActionResult.success(); + } + return await withDirPath(async (tempDirectory) => { const tempContext = new TempContext(tempDirectory); const buildZipPath = await tempContext.zip(buildDirectory); @@ -65,4 +94,7 @@ export class PluginGenerateAction { return ActionResult.success(); }); }; + + private readonly createConfig = (buildDirectory: DirectoryPath): Promise => + new PluginCreateConfigAction(this.configDir, this.commandMetadata, this.authKey).execute(buildDirectory); } diff --git a/src/actions/plugin/record-sdk.ts b/src/actions/plugin/record-sdk.ts new file mode 100644 index 00000000..3fa6302d --- /dev/null +++ b/src/actions/plugin/record-sdk.ts @@ -0,0 +1,81 @@ +import { PluginRecordSdkPrompts } from '../../prompts/plugin/record-sdk.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { buildLanguageEntry } from '../../types/plugin/language-entry.js'; +import { PluginConfigContext } from '../../types/plugin-config-context.js'; +import { PublishType } from '../../types/publish-api/publishing-profile-item.js'; +import { PublishingProfile } from '../../types/publish/publishing-profile.js'; +import { CodeGenerationVersion, Language } from '../../types/sdk/generate.js'; + +/** + * Records a freshly published SDK in `plugin-config.json`. Returns nothing and never throws: this + * runs after a successful publish, and no outcome here may change that result. + * + * Interactive runs are asked first, which is what keeps the file from appearing for users who do + * not want a context plugin. Non-interactive runs answer with `--update-plugin-config` instead, + * because a prompt on the CI path would never be answered. + * + * Metadata is deliberately not written — `plugin generate` owns that, so publishing never has to + * ask for a plugin id or reach the account API. + */ +export class PluginRecordSdkAction { + private readonly prompts: PluginRecordSdkPrompts = new PluginRecordSdkPrompts(); + + public readonly execute = async ( + buildDirectory: DirectoryPath, + language: Language, + publishingProfile: PublishingProfile, + publishTypes: PublishType[], + codegenVersion: CodeGenerationVersion, + confirmFirst: boolean = true + ): Promise => { + // The entry has to describe what this run published, not what the profile happens to enable. + // A package-only run must not claim a repository it never pushed to, nor a source-only run a + // package that was never released. + const built = buildLanguageEntry( + language, + publishTypes.includes(PublishType.SourceCodePublishing) + ? publishingProfile.getGitConfigurationForLanguage(language) + : undefined, + publishTypes.includes(PublishType.PackagePublishing) + ? publishingProfile.getPackageConfigurationDataForLanguage(language) + : undefined, + codegenVersion + ); + + // Both of these return before the confirm below, so without a message the prompt would simply + // never appear. + if (built.kind === 'unresolvableRepositoryName') { + this.prompts.unresolvableRepositoryName(language, built.repositoryName); + return; + } + if (built.kind !== 'entry') { + this.prompts.noSourceRepository(language); + return; + } + + const pluginConfigContext = new PluginConfigContext(buildDirectory); + const configState = await pluginConfigContext.validate(); + if (configState.state === 'unreadable') { + this.prompts.pluginConfigUnreadable(); + return; + } + + // A non-interactive run has already decided via `--update-plugin-config`; there is nobody to ask. + if (confirmFirst && !(await this.prompts.confirmRecordSdk(language, configState.state !== 'missing'))) { + return; + } + + switch (await pluginConfigContext.upsertLanguage(language, built.entry)) { + case 'written': + this.prompts.sdkRecorded(language); + break; + // Readable a moment ago, so this only happens if the file changed underneath us. + case 'unreadable': + this.prompts.pluginConfigUnreadable(); + break; + case 'unwritable': + this.prompts.pluginConfigNotWritten(); + break; + } + }; +} diff --git a/src/actions/sdk/publish/interactive.ts b/src/actions/sdk/publish/interactive.ts index 2ba49248..81b85d35 100644 --- a/src/actions/sdk/publish/interactive.ts +++ b/src/actions/sdk/publish/interactive.ts @@ -8,6 +8,7 @@ import { PublishingProfiles } from '../../../types/publish/publishing-profiles.j import { getCodegenOptions } from '../../../types/sdk/generate.js'; import { formatPublishingDetails } from '../../../prompts/sdk/publish.js'; import { ActionResult } from '../../action-result.js'; +import { PluginRecordSdkAction } from '../../plugin/record-sdk.js'; import { SdkPublishAction } from '../publish.js'; import { BuildContext } from '../../../types/build-context.js'; import { ProfileId } from '../../../types/publish/profile-id.js'; @@ -139,6 +140,17 @@ export class SdkPublishInteractiveAction { return ActionResult.cancelled(); } + // Interactive only: the non-interactive path is documented for CI/CD, where a prompt would + // never be answered. The publish is already polled to completion by this point, so the SDK + // really is published, and the entry records the generator that actually produced it. + await new PluginRecordSdkAction().execute( + buildDirectory, + language, + publishingProfile, + publishTypes, + codegenOption.codeGenerationVersion() + ); + return ActionResult.success(); }; diff --git a/src/actions/sdk/publish/non-interactive.ts b/src/actions/sdk/publish/non-interactive.ts index ee5887fa..8e4d205b 100644 --- a/src/actions/sdk/publish/non-interactive.ts +++ b/src/actions/sdk/publish/non-interactive.ts @@ -11,6 +11,7 @@ import { getDownloadsDirectory } from '../../../infrastructure/os-extensions.js' import { SemVersion } from '../../../types/publish/version.js'; import { ProfileId } from '../../../types/publish/profile-id.js'; import { BuildContext } from '../../../types/build-context.js'; +import { PluginRecordSdkAction } from '../../plugin/record-sdk.js'; import { SdkPublishAction } from '../publish.js'; import { FileService } from '../../../infrastructure/file-service.js'; @@ -32,7 +33,8 @@ export class SdkPublishNonInteractiveAction { stabilityWasProvided: boolean, onPublishSdkError: (errorMessage: string) => void, profileId?: string, - version?: string + version?: string, + updatePluginConfig: boolean = false ): Promise => { if (buildDirectory.isEqual(sdkDirectory)) { this.prompts.directoryCannotBeSame(sdkDirectory); @@ -135,6 +137,23 @@ export class SdkPublishNonInteractiveAction { return ActionResult.cancelled(); } + // No prompt here: this path is documented for CI/CD, so the answer comes from the flag. A dry + // run publishes nothing, so recording it would claim an SDK that does not exist anywhere. + if (updatePluginConfig) { + if (dryRun) { + this.prompts.dryRunPluginConfigNotice(); + } else { + await new PluginRecordSdkAction().execute( + buildDirectory, + language, + publishingProfile, + publishTypes, + codegenOption.codeGenerationVersion(), + false + ); + } + } + return ActionResult.success(); }; } diff --git a/src/commands/sdk/publish.ts b/src/commands/sdk/publish.ts index 34c26ce4..d2e7a276 100644 --- a/src/commands/sdk/publish.ts +++ b/src/commands/sdk/publish.ts @@ -56,6 +56,11 @@ export default class SdkPublish extends Command { description: 'Stability level of the generated SDK', options: Object.values(Stability).map((s) => s.valueOf()), default: Stability.STABLE + }), + 'update-plugin-config': Flags.boolean({ + default: false, + description: + "Record the published SDK in 'plugin-config.json', creating the file if it does not exist. Interactive runs are asked instead." }) }; @@ -97,7 +102,8 @@ export default class SdkPublish extends Command { 'publish-type': publishType, 'dry-run': dryRun, 'codegen-version': codegenVersion, - stability + stability, + 'update-plugin-config': updatePluginConfig }, metadata } = await this.parse(SdkPublish); @@ -153,6 +159,7 @@ export default class SdkPublish extends Command { onPublishSdkError, profileId, version, + updatePluginConfig ); outro(result); } diff --git a/src/prompts/plugin/create-config.ts b/src/prompts/plugin/create-config.ts new file mode 100644 index 00000000..f4eb065f --- /dev/null +++ b/src/prompts/plugin/create-config.ts @@ -0,0 +1,81 @@ +import { isCancel, log, text } from '@clack/prompts'; +import { Result } from 'neverthrow'; +import { ServiceError } from '../../infrastructure/service-error.js'; +import { SubscriptionInfo } from '../../types/api/account.js'; +import { PluginMetadata } from '../../types/plugin/plugin-config.js'; +import { SemVersion } from '../../types/publish/version.js'; +import { format as f } from '../format.js'; +import { noteWrapped, withSpinner } from '../prompt.js'; + +const PLUGIN_CONFIG_FILE = 'plugin-config.json'; +const KEBAB_CASE = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +export class PluginCreateConfigPrompts { + public spinnerAccountInfo(fn: Promise>) { + return withSpinner( + 'Retrieving your subscription info', + 'Subscription info retrieved', + 'Subscription info retrieval failed', + fn + ); + } + + /** + * Required fields get a `placeholder` but no `defaultValue`, so an empty answer re-prompts. + * Optional ones get both, so Enter accepts the suggestion — the same split `sdk quickstart` uses. + */ + public async inputPluginMetadata(defaults: PluginMetadata): Promise { + const pluginId = await text({ + message: 'Enter an ID for your plugin:', + placeholder: defaults.pluginId, + validate: (value) => { + if (!value) return 'Plugin ID is required.'; + if (!KEBAB_CASE.test(value)) return `Plugin ID must be lower-case kebab-case, for example 'acme-payments'.`; + } + }); + if (isCancel(pluginId)) return undefined; + + const pluginName = await text({ + message: 'Enter a name for your plugin:', + placeholder: defaults.pluginName, + validate: (value) => { + if (!value) return 'Plugin name is required.'; + } + }); + if (isCancel(pluginName)) return undefined; + + const pluginVersion = await text({ + message: 'Enter a version for your plugin:', + placeholder: `Provide a version or press Enter to use ${defaults.pluginVersion}.`, + defaultValue: defaults.pluginVersion, + validate: (value) => { + if (value && SemVersion.tryCreate(value).isErr()) + return 'Please enter a valid version in the format major.minor.patch (e.g., 0.1.0).'; + } + }); + if (isCancel(pluginVersion)) return undefined; + + return { pluginId, pluginName, pluginVersion }; + } + + public accountInfoUnavailable() { + log.warn(`Could not read your subscription info, so the author was left out of ${f.var(PLUGIN_CONFIG_FILE)}.`); + } + + public pluginConfigUnreadable() { + log.error(`${f.var(PLUGIN_CONFIG_FILE)} could not be read, so its plugin details were not written.`); + } + + public pluginConfigNotWritten() { + log.error(`${f.var(PLUGIN_CONFIG_FILE)} could not be written, so its plugin details were not saved.`); + } + + public pluginConfigCreated(metadata: PluginMetadata) { + const message = + `Plugin ID: ${f.var(metadata.pluginId)}\n` + + `Plugin Name: ${f.var(metadata.pluginName)}\n` + + `Version: ${f.var(metadata.pluginVersion)}\n\n` + + `Configuration saved to: ${f.var(PLUGIN_CONFIG_FILE)}`; + noteWrapped(message, 'Plugin Configuration'); + } +} diff --git a/src/prompts/plugin/generate.ts b/src/prompts/plugin/generate.ts index a1572fc7..9cc356a5 100644 --- a/src/prompts/plugin/generate.ts +++ b/src/prompts/plugin/generate.ts @@ -2,8 +2,11 @@ import { confirm, isCancel, log } from '@clack/prompts'; import { Result } from 'neverthrow'; import { ServiceError } from '../../infrastructure/service-error.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { FilePath } from '../../types/file/filePath.js'; import { format as f } from '../format.js'; -import { withSpinner } from '../prompt.js'; +import { noteWrapped, withSpinner } from '../prompt.js'; + +const PLUGIN_CONFIG_FILE = 'plugin-config.json'; export class PluginGeneratePrompts { public generatePlugin(fn: Promise>) { @@ -43,6 +46,30 @@ export class PluginGeneratePrompts { log.error(error); } + public pluginConfigUnreadable(reason: string, path: FilePath) { + const message = + `${f.var(PLUGIN_CONFIG_FILE)} cannot be used: ${reason}. ` + `Fix or delete it at ${f.path(path)} and try again.`; + log.error(message); + } + + /** A cancelled metadata prompt stops the run, so it is a warning rather than an error. */ + public metadataCancelled() { + log.warn('A plugin ID is required. Exiting without generating a plugin.'); + } + + public noPublishedSdks() { + log.info(`${f.var(PLUGIN_CONFIG_FILE)} has no published SDKs yet.`); + } + + /** Reached on a successful run that found nothing to build, not on a generation failure. */ + public nextStepsPublishSdks() { + const message = + `Publish an SDK for each language you want in the plugin:\n` + + `'${f.cmdAlt('apimatic', 'sdk', 'publish')} ${f.flag('language', '')}'\n` + + `Then run '${f.cmdAlt('apimatic', 'plugin', 'generate')}'.`; + noteWrapped(message, 'Next Steps'); + } + public pluginGenerated(plugin: DirectoryPath) { log.info(`Plugin artifacts can be found at ${f.path(plugin)}.`); } diff --git a/src/prompts/plugin/record-sdk.ts b/src/prompts/plugin/record-sdk.ts new file mode 100644 index 00000000..f03df106 --- /dev/null +++ b/src/prompts/plugin/record-sdk.ts @@ -0,0 +1,51 @@ +import { confirm, isCancel, log } from '@clack/prompts'; +import { Language } from '../../types/sdk/generate.js'; +import { format as f } from '../format.js'; + +const PLUGIN_CONFIG_FILE = 'plugin-config.json'; + +export class PluginRecordSdkPrompts { + public async confirmRecordSdk(language: Language, configExists: boolean): Promise { + const message = configExists + ? `Add ${f.var(language)} to ${f.var(PLUGIN_CONFIG_FILE)}?` + : `Create ${f.var(PLUGIN_CONFIG_FILE)} and add ${f.var(language)} to it?`; + + const record = await confirm({ message, initialValue: true }); + + if (isCancel(record)) { + return false; + } + + return record; + } + + public sdkRecorded(language: Language) { + const message = + `Added ${f.var(language)} to ${f.var(PLUGIN_CONFIG_FILE)}. ` + + `Run '${f.cmdAlt('apimatic', 'plugin', 'generate')}' to build your context plugin.`; + log.info(message); + } + + public noSourceRepository(language: Language) { + const message = + `No source repository was published for ${f.var(language)}. ` + + `${f.var(PLUGIN_CONFIG_FILE)} needs one to describe an SDK, so nothing was recorded.`; + log.info(message); + } + + public unresolvableRepositoryName(language: Language, repositoryName: string) { + const message = + `The publishing profile names the ${f.var(language)} repository as ${f.var(repositoryName)}, ` + + `which cannot be resolved to a URL. Nothing was recorded — set it to ${f.var('owner/repo')} ` + + `or a full https:// URL and publish again.`; + log.warn(message); + } + + public pluginConfigUnreadable() { + log.warn(`${f.var(PLUGIN_CONFIG_FILE)} cannot be used, so this SDK was not added to it.`); + } + + public pluginConfigNotWritten() { + log.warn(`${f.var(PLUGIN_CONFIG_FILE)} could not be written, so this SDK was not added to it.`); + } +} diff --git a/src/prompts/sdk/publish/non-interactive.ts b/src/prompts/sdk/publish/non-interactive.ts index d25c83c0..17c547a9 100644 --- a/src/prompts/sdk/publish/non-interactive.ts +++ b/src/prompts/sdk/publish/non-interactive.ts @@ -9,6 +9,8 @@ import { PublishingProfileItem } from '../../../types/publish-api/publishing-pro import { ProfileId } from '../../../types/publish/profile-id.js'; import { Language } from '../../../types/sdk/generate.js'; +const PLUGIN_CONFIG_FILE = 'plugin-config.json'; + export class SdkPublishNonInteractivePrompts { public directoryCannotBeSame(directory: DirectoryPath) { const message = `The ${f.var('src')} and ${f.var('sdk')} directories must be different. Current value: ${f.path( @@ -80,4 +82,11 @@ export class SdkPublishNonInteractivePrompts { 'Version tags will not be created in your Git repository because you have opted to publish Source Code only.' ); } + + public dryRunPluginConfigNotice() { + const message = + `${f.flag('update-plugin-config')} was ignored because ${f.flag('dry-run')} does not publish the SDK. ` + + `Re-run without ${f.flag('dry-run')} to record it in ${f.var(PLUGIN_CONFIG_FILE)}.`; + log.info(message); + } } diff --git a/src/types/file/directoryPath.ts b/src/types/file/directoryPath.ts index a3b87dc7..41cdf98d 100644 --- a/src/types/file/directoryPath.ts +++ b/src/types/file/directoryPath.ts @@ -31,4 +31,8 @@ export class DirectoryPath { public leafName() { return path.basename(this.directoryPath); } + + public parent(): DirectoryPath { + return new DirectoryPath(path.dirname(this.directoryPath)); + } } diff --git a/src/types/plugin-config-context.ts b/src/types/plugin-config-context.ts new file mode 100644 index 00000000..f88c5a53 --- /dev/null +++ b/src/types/plugin-config-context.ts @@ -0,0 +1,147 @@ +import { FileService } from '../infrastructure/file-service.js'; +import { DirectoryPath } from './file/directoryPath.js'; +import { FileName } from './file/fileName.js'; +import { FilePath } from './file/filePath.js'; +import { + DEFAULT_PLUGIN_LICENSE, + LanguageEntry, + PLUGIN_CONFIG_SCHEMA_VERSION, + PluginAuthor, + PluginConfigData, + PluginMetadata +} from './plugin/plugin-config.js'; +import { Language } from './sdk/generate.js'; + +/** + * What a caller needs to know before generating. Metadata and languages are written by different + * commands — `plugin generate` owns the first, `sdk publish` the second — so they are reported + * separately. `path` rides on `unreadable` purely so the prompt can say where to fix the file. + */ +export type PluginConfigState = + | { state: 'missing' } + | { state: 'unreadable'; reason: string; path: FilePath } + | { state: 'present'; hasMetadata: boolean; hasLanguages: boolean }; + +/** + * Why a write did or did not happen. A bare boolean cannot say whether the file was unusable or + * the disk refused it, and callers have to tell the user which. + */ +export type PluginConfigWriteResult = 'written' | 'unreadable' | 'unwritable'; + +type ParseResult = { config: PluginConfigData } | { reason: string }; + +export class PluginConfigContext { + private readonly fileService = new FileService(); + + constructor(private readonly buildDirectory: DirectoryPath) {} + + private get configPath(): FilePath { + return new FilePath(this.buildDirectory, new FileName('plugin-config.json')); + } + + public async validate(): Promise { + if (!(await this.fileService.fileExists(this.configPath))) { + return { state: 'missing' }; + } + + const parsed = await this.parse(); + if ('reason' in parsed) { + return { state: 'unreadable', reason: parsed.reason, path: this.configPath }; + } + + // A version this CLI does not model would be uploaded and rejected server-side, so it is + // refused here instead. Absent is fine — the next write backfills it. + const { schemaVersion } = parsed.config; + if (schemaVersion !== undefined && schemaVersion !== PLUGIN_CONFIG_SCHEMA_VERSION) { + const reason = + `it declares schemaVersion ${schemaVersion}, ` + + `and this version of the CLI only understands ${PLUGIN_CONFIG_SCHEMA_VERSION}`; + return { state: 'unreadable', reason, path: this.configPath }; + } + + return { + state: 'present', + hasMetadata: namesThePlugin(parsed.config), + hasLanguages: namesAnyLanguage(parsed.config) + }; + } + + /** + * Adds the plugin's identity, creating the file when absent. `license` is written unprompted + * because the backend consumes it; `pluginKey` is deliberately not written, because nothing does. + */ + public async upsertMetadata(metadata: PluginMetadata, author?: PluginAuthor): Promise { + return await this.merge((config) => ({ + ...config, + pluginId: metadata.pluginId, + pluginName: metadata.pluginName, + pluginVersion: metadata.pluginVersion, + ...(author && { author }), + license: config.license ?? DEFAULT_PLUGIN_LICENSE + })); + } + + /** Adds one language, creating the file — with no metadata — when absent. */ + public async upsertLanguage(language: Language, entry: LanguageEntry): Promise { + return await this.merge((config) => ({ + ...config, + languages: { ...config.languages, [language]: entry } + })); + } + + /** + * Reads, applies, writes. A file that exists but cannot be parsed is left alone rather than + * overwritten, and a write fault is reported rather than thrown: this runs after a publish that + * already succeeded, and nothing here may turn that into a crash. + */ + private async merge(apply: (config: PluginConfigData) => PluginConfigData): Promise { + const existing = await this.read(); + if ('reason' in existing) { + return 'unreadable'; + } + + const merged = apply(existing.config); + // A hand-written file may omit it, and the backend accepts only this one version. + merged.schemaVersion = merged.schemaVersion ?? PLUGIN_CONFIG_SCHEMA_VERSION; + + try { + await this.write(merged); + } catch { + return 'unwritable'; + } + + return 'written'; + } + + private async read(): Promise { + if (!(await this.fileService.fileExists(this.configPath))) { + return { config: { schemaVersion: PLUGIN_CONFIG_SCHEMA_VERSION, languages: {} } }; + } + return await this.parse(); + } + + private async parse(): Promise { + try { + const parsed: unknown = JSON.parse(await this.fileService.getContents(this.configPath)); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { reason: 'it is not a JSON object' }; + } + return { config: parsed as PluginConfigData }; + } catch (error) { + return { reason: error instanceof Error ? error.message : String(error) }; + } + } + + private async write(config: PluginConfigData): Promise { + await this.fileService.ensurePathExists(this.configPath); + await this.fileService.writeContents(this.configPath, JSON.stringify(config, null, 2)); + } +} + +const namesThePlugin = (config: PluginConfigData): boolean => + Boolean(config.pluginId?.trim()) && Boolean(config.pluginName?.trim()); + +const namesAnyLanguage = (config: PluginConfigData): boolean => { + const languages = config.languages; + return typeof languages === 'object' && languages !== null && Object.keys(languages).length > 0; +}; diff --git a/src/types/plugin/language-entry.ts b/src/types/plugin/language-entry.ts new file mode 100644 index 00000000..55f3b72a --- /dev/null +++ b/src/types/plugin/language-entry.ts @@ -0,0 +1,109 @@ +import { + CSharpPackageConfiguration, + GitConfiguration, + GoPackageConfiguration, + JavaPackageConfiguration, + PackageConfigurationData, + PhpPackageConfiguration, + PythonPackageConfiguration, + RubyPackageConfiguration, + TypeScriptPackageConfiguration +} from '../publish/package-settings-configuration.js'; +import { CodeGenerationVersion, Language } from '../sdk/generate.js'; +import { LanguageEntry, LanguageSource, PluginPackage } from './plugin-config.js'; + +const ABSOLUTE_HTTP_URL = /^https?:\/\//i; +/** `owner/repo` — the only bare form that can be resolved to a URL without guessing. */ +const OWNER_AND_REPO = /^[^/@:\s]+\/[^/@:\s]+$/; +const GITHUB_BASE_URL = 'https://github.com'; + +/** + * `noSourceRepository` is routine rather than a fault: the run published no source code, so there + * is nothing for a language entry to describe. `unresolvableRepositoryName` is the opposite — a + * repository is configured, but its name is not a shape that can be turned into a URL, and a guess + * would be written as a clone target that only fails once the backend tries to use it. + */ +export type LanguageEntryResult = + | { kind: 'entry'; entry: LanguageEntry } + | { kind: 'noSourceRepository' } + | { kind: 'unresolvableRepositoryName'; repositoryName: string }; + +export function buildLanguageEntry( + language: Language, + gitConfiguration: GitConfiguration | undefined, + packageConfiguration: PackageConfigurationData | undefined, + version: CodeGenerationVersion +): LanguageEntryResult { + const repositoryName = gitConfiguration?.repositoryName?.trim(); + if (!repositoryName) { + return { kind: 'noSourceRepository' }; + } + + const repositoryUrl = repositoryUrlOf(repositoryName); + if (!repositoryUrl) { + return { kind: 'unresolvableRepositoryName', repositoryName }; + } + + const source: LanguageSource = { repositoryUrl }; + const branch = gitConfiguration?.branch?.trim(); + if (branch) { + source.branch = branch; + } + + const entry: LanguageEntry = { source, version }; + const pluginPackage = packageOf(language, packageConfiguration); + if (pluginPackage) { + entry.package = pluginPackage; + } + + return { kind: 'entry', entry }; +} + +/** + * A publishing profile carries no git host, so a bare name can only be resolved against GitHub — + * an assumption the profile itself cannot confirm. Anything that is not plainly `owner/repo` is + * refused rather than guessed at, because nothing downstream validates the URL: the backend checks + * only that it is non-blank, so a wrong one survives until a clone fails. + */ +function repositoryUrlOf(repositoryName: string): string | undefined { + if (ABSOLUTE_HTTP_URL.test(repositoryName)) { + return repositoryName; + } + + const bareName = repositoryName.replace(/^\/+/, '').replace(/\/+$/, ''); + return OWNER_AND_REPO.test(bareName) ? `${GITHUB_BASE_URL}/${bareName}` : undefined; +} + +function packageOf(language: Language, configuration: PackageConfigurationData | undefined): PluginPackage | undefined { + if (!configuration) { + return undefined; + } + + switch (language) { + case Language.CSHARP: { + const { packageId } = configuration as CSharpPackageConfiguration; + return packageId ? { packageId } : undefined; + } + case Language.JAVA: { + const { groupId, artifactId } = configuration as JavaPackageConfiguration; + return groupId && artifactId ? { groupId, artifactId } : undefined; + } + case Language.PHP: { + const { vendorName, projectName } = configuration as PhpPackageConfiguration; + return vendorName && projectName ? { vendorName, projectName } : undefined; + } + case Language.GO: { + const { packageName } = configuration as GoPackageConfiguration; + return packageName ? { packageName } : undefined; + } + case Language.PYTHON: + case Language.RUBY: + case Language.TYPESCRIPT: { + const { name } = configuration as + | PythonPackageConfiguration + | RubyPackageConfiguration + | TypeScriptPackageConfiguration; + return name ? { name } : undefined; + } + } +} diff --git a/src/types/plugin/plugin-config.ts b/src/types/plugin/plugin-config.ts new file mode 100644 index 00000000..0a71fdf5 --- /dev/null +++ b/src/types/plugin/plugin-config.ts @@ -0,0 +1,93 @@ +import { CodeGenerationVersion, Language } from '../sdk/generate.js'; + +/** The only schema version the backend accepts. */ +export const PLUGIN_CONFIG_SCHEMA_VERSION = 1; + +/** Written unprompted: the backend consumes it, and nothing in the CLI asks for it. */ +export const DEFAULT_PLUGIN_LICENSE = 'MIT'; + +export interface PluginAuthor { + name: string; + email?: string; +} + +export interface LanguageSource { + repositoryUrl: string; + branch?: string; +} + +export interface CSharpPluginPackage { + packageId: string; + packageUrl?: string; +} + +/** Shared by typescript, python and ruby, which all identify a package by a single name. */ +export interface NamedPluginPackage { + name: string; + packageUrl?: string; +} + +export interface JavaPluginPackage { + groupId: string; + artifactId: string; + packageUrl?: string; +} + +export interface PhpPluginPackage { + vendorName: string; + projectName: string; + packageUrl?: string; +} + +export interface GoPluginPackage { + packageName: string; + packageUrl?: string; +} + +export type PluginPackage = + | CSharpPluginPackage + | NamedPluginPackage + | JavaPluginPackage + | PhpPluginPackage + | GoPluginPackage; + +export interface LanguageEntry { + source: LanguageSource; + package?: PluginPackage; + version?: CodeGenerationVersion; +} + +/** The backend also accepts a bare source URL in place of an entry. Read, never written. */ +export type LanguageValue = LanguageEntry | string; + +export type PluginLanguages = Partial>; + +export interface PluginConfigData { + schemaVersion: number; + // Optional on disk: `sdk publish` creates a config carrying languages alone, and + // `plugin generate` fills these in before it ever uploads. + pluginId?: string; + pluginName?: string; + pluginVersion?: string; + // Read and preserved on a round-trip, never written: nothing in codegen-v2 consumes it. + pluginKey?: string; + author?: PluginAuthor; + license?: string; + homepage?: string; + repository?: string; + languages: PluginLanguages; + // A hand-written config may carry fields this CLI version does not model; the index + // signature is what lets a read-modify-write round-trip preserve them. + [key: string]: unknown; +} + +/** The fields the CLI asks for; everything else is derived or constant. */ +export interface PluginMetadata { + pluginId: string; + pluginName: string; + pluginVersion: string; +} + +export function isLanguageEntry(value: LanguageValue): value is LanguageEntry { + return typeof value !== 'string'; +} diff --git a/src/types/publish/publishing-profile.ts b/src/types/publish/publishing-profile.ts index 7f1398d7..d6633ede 100644 --- a/src/types/publish/publishing-profile.ts +++ b/src/types/publish/publishing-profile.ts @@ -114,6 +114,10 @@ export class PublishingProfile { return this.languageConfigs[language]; } + public getGitConfigurationForLanguage(language: Language): GitConfiguration | undefined { + return this.gitConfigs[language]; + } + private static createCSharpConfiguration(config: CSharpConfigurationItem): CSharpPackageConfiguration { return { packageId: config.packageId, diff --git a/src/types/sdk/generate.ts b/src/types/sdk/generate.ts index 4ce6e4c2..29f65e67 100644 --- a/src/types/sdk/generate.ts +++ b/src/types/sdk/generate.ts @@ -79,6 +79,11 @@ export class CodegenOption { return this.stability; } + /** The generator that produced the SDK, as `plugin-config.json` records it. */ + public codeGenerationVersion(): CodeGenerationVersion { + return this.version; + } + public toString(): string { return `${this.version.toUpperCase()} (${this.stability})`; } diff --git a/src/utils/string-utils.ts b/src/utils/string-utils.ts index 25aff368..815179d2 100644 --- a/src/utils/string-utils.ts +++ b/src/utils/string-utils.ts @@ -9,6 +9,21 @@ export const removeQuotes = (input: string): string => { return input; }; +/** Lower-case kebab-case — the shape a plugin id must take to pass server-side validation. */ +export const toKebabCase = (input: string): string => + input + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + +export const toTitleCase = (input: string): string => + input + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + export function stripAnsi(str: string) { let result = ''; let i = 0; diff --git a/test/actions/plugin/create-config.test.ts b/test/actions/plugin/create-config.test.ts new file mode 100644 index 00000000..9affabce --- /dev/null +++ b/test/actions/plugin/create-config.test.ts @@ -0,0 +1,148 @@ +import * as path from 'path'; +import fsExtra from 'fs-extra'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { err, ok } from 'neverthrow'; +import { dir as tmpDir, DirectoryResult } from 'tmp-promise'; +import { PluginCreateConfigAction } from '../../../src/actions/plugin/create-config.js'; +import { PluginCreateConfigPrompts } from '../../../src/prompts/plugin/create-config.js'; +import { ApiService } from '../../../src/infrastructure/services/api-service.js'; +import { ServiceError } from '../../../src/infrastructure/service-error.js'; +import { SubscriptionInfo } from '../../../src/types/api/account.js'; +import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { PluginConfigData } from '../../../src/types/plugin/plugin-config.js'; +import { CommandMetadata } from '../../../src/types/common/command-metadata.js'; + +const COMMAND_METADATA: CommandMetadata = { commandName: 'plugin generate', shell: 'test' }; + +const ACCOUNT = { + FullName: 'Acme', + Email: 'developers@acme.com', + ApiCopilotKeys: ['copilot-key'] +} as unknown as SubscriptionInfo; + +describe('PluginCreateConfigAction', () => { + let tmpDirResult: DirectoryResult; + let buildDirectory: string; + let action: PluginCreateConfigAction; + + const execute = () => action.execute(new DirectoryPath(buildDirectory)); + + const writtenConfig = (): PluginConfigData => fsExtra.readJsonSync(path.join(buildDirectory, 'plugin-config.json')); + + beforeEach(async () => { + tmpDirResult = await tmpDir({ unsafeCleanup: true }); + buildDirectory = path.join(tmpDirResult.path, 'acme-payments', 'src'); + await fsExtra.ensureDir(buildDirectory); + + // The spinner would render to stdout; pass the underlying promise straight through. + sinon.stub(PluginCreateConfigPrompts.prototype, 'spinnerAccountInfo').callsFake((fn) => fn); + sinon.stub(PluginCreateConfigPrompts.prototype, 'pluginConfigCreated'); + sinon.stub(ApiService.prototype, 'getAccountInfo').resolves(ok(ACCOUNT)); + + action = new PluginCreateConfigAction(new DirectoryPath(tmpDirResult.path), COMMAND_METADATA, 'auth-key'); + }); + + afterEach(async () => { + sinon.restore(); + await tmpDirResult.cleanup(); + }); + + const answers = (overrides: Partial<{ pluginId: string; pluginName: string; pluginVersion: string }> = {}) => + sinon.stub(PluginCreateConfigPrompts.prototype, 'inputPluginMetadata').resolves({ + pluginId: 'acme-payments', + pluginName: 'Acme Payments', + pluginVersion: '0.1.0', + ...overrides + }); + + it('writes the metadata and a default licence into the config', async () => { + answers(); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect(writtenConfig()).to.include({ + schemaVersion: 1, + pluginId: 'acme-payments', + pluginName: 'Acme Payments', + pluginVersion: '0.1.0', + license: 'MIT' + }); + }); + + it('records the author from the account', async () => { + answers(); + + await execute(); + + expect(writtenConfig().author).to.deep.equal({ name: 'Acme', email: 'developers@acme.com' }); + }); + + it('never writes a plugin key', async () => { + answers(); + + await execute(); + + expect(writtenConfig()).to.not.have.property('pluginKey'); + }); + + it('seeds the prompt defaults from the project directory name', async () => { + const inputPluginMetadata = answers(); + + await execute(); + + expect(inputPluginMetadata.firstCall.args[0]).to.deep.equal({ + pluginId: 'acme-payments', + pluginName: 'Acme Payments', + pluginVersion: '0.1.0' + }); + }); + + it('cancels without writing anything when the user escapes the prompts', async () => { + sinon.stub(PluginCreateConfigPrompts.prototype, 'inputPluginMetadata').resolves(undefined); + + const result = await execute(); + + expect(result.isCancelled()).to.be.true; + expect(fsExtra.existsSync(path.join(buildDirectory, 'plugin-config.json'))).to.be.false; + }); + + it('still writes the config when the account lookup fails, leaving the author out', async () => { + answers(); + (ApiService.prototype.getAccountInfo as sinon.SinonStub).resolves(err(ServiceError.ServerError)); + const accountInfoUnavailable = sinon.stub(PluginCreateConfigPrompts.prototype, 'accountInfoUnavailable'); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect(accountInfoUnavailable.called).to.be.true; + expect(writtenConfig()).to.not.have.property('author'); + expect(writtenConfig().pluginId).to.equal('acme-payments'); + }); + + it('keeps the languages a previous sdk publish recorded', async () => { + const entry = { source: { repositoryUrl: 'https://github.com/acme/acme-payments-csharp' } }; + await fsExtra.writeJson(path.join(buildDirectory, 'plugin-config.json'), { + schemaVersion: 1, + languages: { csharp: entry } + }); + answers(); + + await execute(); + + expect(writtenConfig().languages).to.deep.equal({ csharp: entry }); + }); + + it('fails rather than overwriting a config it could not read', async () => { + await fsExtra.writeFile(path.join(buildDirectory, 'plugin-config.json'), '{ not json'); + answers(); + const pluginConfigUnreadable = sinon.stub(PluginCreateConfigPrompts.prototype, 'pluginConfigUnreadable'); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(pluginConfigUnreadable.called).to.be.true; + expect(fsExtra.readFileSync(path.join(buildDirectory, 'plugin-config.json'), 'utf-8')).to.equal('{ not json'); + }); +}); diff --git a/test/actions/plugin/generate.test.ts b/test/actions/plugin/generate.test.ts index 592424b1..f9d43803 100644 --- a/test/actions/plugin/generate.test.ts +++ b/test/actions/plugin/generate.test.ts @@ -7,6 +7,9 @@ import { err, ok } from 'neverthrow'; import { dir as tmpDir, DirectoryResult } from 'tmp-promise'; import { PluginGenerateAction } from '../../../src/actions/plugin/generate.js'; import { PluginGeneratePrompts } from '../../../src/prompts/plugin/generate.js'; +import { PluginCreateConfigPrompts } from '../../../src/prompts/plugin/create-config.js'; +import { ApiService } from '../../../src/infrastructure/services/api-service.js'; +import { SubscriptionInfo } from '../../../src/types/api/account.js'; import { PluginService } from '../../../src/infrastructure/services/plugin-service.js'; import { ServiceError } from '../../../src/infrastructure/service-error.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; @@ -37,7 +40,7 @@ describe('PluginGenerateAction', () => { schemaVersion: 1, pluginId: 'acme-payments', pluginName: 'Acme Payments', - sdkRepos: { csharp: { sourceCode: { srcCodeUrl: 'https://github.com/acme/acme-csharp' } } } + languages: { csharp: { source: { repositoryUrl: 'https://github.com/acme/acme-csharp' } } } }); // The spinner would render to stdout; pass the underlying promise straight through. @@ -113,6 +116,103 @@ describe('PluginGenerateAction', () => { }); }); + describe('plugin config', () => { + const ACCOUNT = { FullName: 'Acme', Email: 'developers@acme.com' } as unknown as SubscriptionInfo; + const METADATA = { pluginId: 'acme-payments', pluginName: 'Acme Payments', pluginVersion: '0.1.0' }; + const CSHARP = { source: { repositoryUrl: 'https://github.com/acme/acme-csharp' } }; + + const configPath = () => path.join(buildDirectory, 'plugin-config.json'); + const writeConfig = (config: object) => fsExtra.writeJson(configPath(), config); + const writtenConfig = () => fsExtra.readJsonSync(configPath()); + + // The real PluginCreateConfigAction runs; only its prompts and the account call are stubbed, + // so these assert what actually lands on disk. + const answersMetadata = () => + sinon.stub(PluginCreateConfigPrompts.prototype, 'inputPluginMetadata').resolves(METADATA); + const cancelsMetadata = () => + sinon.stub(PluginCreateConfigPrompts.prototype, 'inputPluginMetadata').resolves(undefined); + + beforeEach(() => { + sinon.stub(PluginCreateConfigPrompts.prototype, 'spinnerAccountInfo').callsFake((fn) => fn); + sinon.stub(PluginCreateConfigPrompts.prototype, 'pluginConfigCreated'); + sinon.stub(ApiService.prototype, 'getAccountInfo').resolves(ok(ACCOUNT)); + sinon.stub(PluginGeneratePrompts.prototype, 'noPublishedSdks'); + sinon.stub(PluginGeneratePrompts.prototype, 'nextStepsPublishSdks'); + }); + + it('fails without generating when the config cannot be read', async () => { + await fsExtra.writeFile(configPath(), '{ not json'); + const generatePlugin = sinon.stub(PluginService.prototype, 'generatePlugin'); + const pluginConfigUnreadable = sinon.stub(PluginGeneratePrompts.prototype, 'pluginConfigUnreadable'); + + expect((await execute()).isFailed()).to.be.true; + expect(pluginConfigUnreadable.called).to.be.true; + expect(generatePlugin.called).to.be.false; + }); + + it('creates the config, then stops with next steps when no SDK is recorded', async () => { + await fsExtra.remove(configPath()); + answersMetadata(); + const generatePlugin = sinon.stub(PluginService.prototype, 'generatePlugin'); + const nextSteps = PluginGeneratePrompts.prototype.nextStepsPublishSdks as sinon.SinonStub; + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect(writtenConfig()).to.include(METADATA); + expect(nextSteps.called).to.be.true; + expect(generatePlugin.called).to.be.false; + }); + + it('fills in metadata and generates when sdk publish already recorded a language', async () => { + await writeConfig({ schemaVersion: 1, languages: { csharp: CSHARP } }); + answersMetadata(); + generated(); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + const config = writtenConfig(); + expect(config).to.include(METADATA); + expect(config.languages).to.deep.equal({ csharp: CSHARP }); + }); + + it('stops with next steps when the config has metadata but no languages', async () => { + await writeConfig({ schemaVersion: 1, ...METADATA, languages: {} }); + const generatePlugin = sinon.stub(PluginService.prototype, 'generatePlugin'); + const inputPluginMetadata = answersMetadata(); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect(generatePlugin.called).to.be.false; + // Metadata is already there, so the user is not asked again. + expect(inputPluginMetadata.called).to.be.false; + }); + + it('cancels without generating when the metadata prompts are escaped', async () => { + await fsExtra.remove(configPath()); + cancelsMetadata(); + const generatePlugin = sinon.stub(PluginService.prototype, 'generatePlugin'); + const metadataCancelled = sinon.stub(PluginGeneratePrompts.prototype, 'metadataCancelled'); + + const result = await execute(); + + expect(result.isCancelled()).to.be.true; + expect(metadataCancelled.called).to.be.true; + expect(generatePlugin.called).to.be.false; + expect(fsExtra.existsSync(configPath())).to.be.false; + }); + + it('generates straight away when the config is already complete', async () => { + const inputPluginMetadata = answersMetadata(); + generated(); + + expect((await execute()).isSuccess()).to.be.true; + expect(inputPluginMetadata.called).to.be.false; + }); + }); + describe('generation failures', () => { it('falls back to the plain service message for any other failure', async () => { sinon.stub(PluginService.prototype, 'generatePlugin').resolves(err(ServiceError.ServerError)); diff --git a/test/actions/plugin/record-sdk.test.ts b/test/actions/plugin/record-sdk.test.ts new file mode 100644 index 00000000..38f25ce4 --- /dev/null +++ b/test/actions/plugin/record-sdk.test.ts @@ -0,0 +1,243 @@ +import * as path from 'path'; +import fsExtra from 'fs-extra'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { dir as tmpDir, DirectoryResult } from 'tmp-promise'; +import { PluginRecordSdkAction } from '../../../src/actions/plugin/record-sdk.js'; +import { PluginRecordSdkPrompts } from '../../../src/prompts/plugin/record-sdk.js'; +import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { PluginConfigContext } from '../../../src/types/plugin-config-context.js'; +import { PluginConfigData } from '../../../src/types/plugin/plugin-config.js'; +import { PublishType } from '../../../src/types/publish-api/publishing-profile-item.js'; +import { PublishingProfile } from '../../../src/types/publish/publishing-profile.js'; +import { CodeGenerationVersion, Language } from '../../../src/types/sdk/generate.js'; + +const BOTH = [PublishType.SourceCodePublishing, PublishType.PackagePublishing]; + +const profileWith = (gitConfiguration: object | undefined, packageConfiguration: object | undefined = undefined) => + ({ + getGitConfigurationForLanguage: () => gitConfiguration, + getPackageConfigurationDataForLanguage: () => packageConfiguration + } as unknown as PublishingProfile); + +const GIT_CONFIG = { + isEnabled: true, + credentialsId: 'creds', + repositoryName: 'acme/acme-payments-csharp', + branch: 'main' +}; + +describe('PluginRecordSdkAction', () => { + let tmpDirResult: DirectoryResult; + let buildDirectory: string; + let action: PluginRecordSdkAction; + let noSourceRepository: sinon.SinonStub; + + const configPath = () => path.join(buildDirectory, 'plugin-config.json'); + const writtenConfig = (): PluginConfigData => fsExtra.readJsonSync(configPath()); + + const execute = (profile: PublishingProfile, publishTypes: PublishType[] = BOTH) => + action.execute(new DirectoryPath(buildDirectory), Language.CSHARP, profile, publishTypes, CodeGenerationVersion.V3); + + // What `sdk publish --update-plugin-config` does: the flag already answered the question. + const executeWithoutAsking = (profile: PublishingProfile, publishTypes: PublishType[] = BOTH) => + action.execute( + new DirectoryPath(buildDirectory), + Language.CSHARP, + profile, + publishTypes, + CodeGenerationVersion.V3, + false + ); + + beforeEach(async () => { + tmpDirResult = await tmpDir({ unsafeCleanup: true }); + buildDirectory = path.join(tmpDirResult.path, 'acme-payments', 'src'); + await fsExtra.ensureDir(buildDirectory); + sinon.stub(PluginRecordSdkPrompts.prototype, 'sdkRecorded'); + noSourceRepository = sinon.stub(PluginRecordSdkPrompts.prototype, 'noSourceRepository'); + action = new PluginRecordSdkAction(); + }); + + afterEach(async () => { + sinon.restore(); + await tmpDirResult.cleanup(); + }); + + const accepts = () => sinon.stub(PluginRecordSdkPrompts.prototype, 'confirmRecordSdk').resolves(true); + const declines = () => sinon.stub(PluginRecordSdkPrompts.prototype, 'confirmRecordSdk').resolves(false); + + it('creates a config carrying the language and no metadata at all', async () => { + accepts(); + + await execute(profileWith(GIT_CONFIG, { packageId: 'Acme.Payments.Sdk' })); + + expect(writtenConfig()).to.deep.equal({ + schemaVersion: 1, + languages: { + csharp: { + source: { repositoryUrl: 'https://github.com/acme/acme-payments-csharp', branch: 'main' }, + package: { packageId: 'Acme.Payments.Sdk' }, + version: 'v3' + } + } + }); + }); + + it('adds the language to a config that already has metadata, leaving it alone', async () => { + await fsExtra.writeJson(configPath(), { + schemaVersion: 1, + pluginId: 'acme-payments', + pluginName: 'Acme Payments', + license: 'MIT', + languages: {} + }); + accepts(); + + await execute(profileWith(GIT_CONFIG)); + + const config = writtenConfig(); + expect(config).to.include({ pluginId: 'acme-payments', pluginName: 'Acme Payments', license: 'MIT' }); + expect(Object.keys(config.languages)).to.deep.equal(['csharp']); + }); + + it('asks to create the file when there is none', async () => { + const confirmRecordSdk = accepts(); + + await execute(profileWith(GIT_CONFIG)); + + expect(confirmRecordSdk.firstCall.args).to.deep.equal([Language.CSHARP, false]); + }); + + it('asks to add to the file when one already exists', async () => { + await fsExtra.writeJson(configPath(), { schemaVersion: 1, languages: {} }); + const confirmRecordSdk = accepts(); + + await execute(profileWith(GIT_CONFIG)); + + expect(confirmRecordSdk.firstCall.args).to.deep.equal([Language.CSHARP, true]); + }); + + it('writes nothing when the user declines', async () => { + declines(); + + await execute(profileWith(GIT_CONFIG)); + + expect(fsExtra.existsSync(configPath())).to.be.false; + }); + + // The return happens before the confirm, so without this message the prompt would just never + // appear and the user would have nothing to act on. + it('explains itself instead of skipping the prompt silently when there is no source repository', async () => { + const confirmRecordSdk = accepts(); + + await execute(profileWith(undefined)); + + expect(confirmRecordSdk.called).to.be.false; + expect(noSourceRepository.calledOnceWith(Language.CSHARP)).to.be.true; + expect(fsExtra.existsSync(configPath())).to.be.false; + }); + + // The entry must describe the run, not the profile: recording either half that was not published + // sends plugin generation at an artifact that does not exist. + describe('records only what the run published', () => { + it('leaves out the source when only the package was published', async () => { + accepts(); + + await execute(profileWith(GIT_CONFIG, { packageId: 'Acme.Payments.Sdk' }), [PublishType.PackagePublishing]); + + expect(noSourceRepository.calledOnceWith(Language.CSHARP)).to.be.true; + expect(fsExtra.existsSync(configPath())).to.be.false; + }); + + it('leaves out the package when only the source was published', async () => { + accepts(); + + await execute(profileWith(GIT_CONFIG, { packageId: 'Acme.Payments.Sdk' }), [PublishType.SourceCodePublishing]); + + expect(writtenConfig().languages.csharp).to.not.have.property('package'); + }); + }); + + it('refuses a repository name it cannot resolve to a URL', async () => { + const confirmRecordSdk = accepts(); + const unresolvable = sinon.stub(PluginRecordSdkPrompts.prototype, 'unresolvableRepositoryName'); + + await execute(profileWith({ ...GIT_CONFIG, repositoryName: 'git@github.com:acme/sdk.git' })); + + expect(unresolvable.calledOnceWith(Language.CSHARP, 'git@github.com:acme/sdk.git')).to.be.true; + expect(confirmRecordSdk.called).to.be.false; + expect(fsExtra.existsSync(configPath())).to.be.false; + }); + + it('warns when the config cannot be written', async () => { + accepts(); + sinon.stub(PluginConfigContext.prototype, 'upsertLanguage').resolves('unwritable'); + const notWritten = sinon.stub(PluginRecordSdkPrompts.prototype, 'pluginConfigNotWritten'); + + await execute(profileWith(GIT_CONFIG)); + + expect(notWritten.calledOnce).to.be.true; + }); + + it('reports a config that became unreadable after the user agreed to record', async () => { + const confirmRecordSdk = accepts(); + sinon.stub(PluginConfigContext.prototype, 'upsertLanguage').resolves('unreadable'); + const pluginConfigUnreadable = sinon.stub(PluginRecordSdkPrompts.prototype, 'pluginConfigUnreadable'); + + await execute(profileWith(GIT_CONFIG)); + + expect(confirmRecordSdk.called).to.be.true; + expect(pluginConfigUnreadable.calledOnce).to.be.true; + }); + + describe('without confirmation (the --update-plugin-config path)', () => { + it('records without ever prompting', async () => { + const confirmRecordSdk = sinon.stub(PluginRecordSdkPrompts.prototype, 'confirmRecordSdk'); + + await executeWithoutAsking(profileWith(GIT_CONFIG, { packageId: 'Acme.Payments.Sdk' })); + + expect(confirmRecordSdk.called).to.be.false; + expect(Object.keys(writtenConfig().languages)).to.deep.equal(['csharp']); + }); + + it('still creates the file with no metadata', async () => { + sinon.stub(PluginRecordSdkPrompts.prototype, 'confirmRecordSdk'); + + await executeWithoutAsking(profileWith(GIT_CONFIG)); + + const config = writtenConfig(); + expect(config.schemaVersion).to.equal(1); + expect(config).to.not.have.property('pluginId'); + }); + + it('still skips a profile that names no source repository, and says so', async () => { + await executeWithoutAsking(profileWith(undefined)); + + expect(noSourceRepository.calledOnceWith(Language.CSHARP)).to.be.true; + expect(fsExtra.existsSync(configPath())).to.be.false; + }); + + it('still refuses to touch a config it cannot read', async () => { + await fsExtra.writeFile(configPath(), '{ not json'); + const pluginConfigUnreadable = sinon.stub(PluginRecordSdkPrompts.prototype, 'pluginConfigUnreadable'); + + await executeWithoutAsking(profileWith(GIT_CONFIG)); + + expect(pluginConfigUnreadable.called).to.be.true; + expect(fsExtra.readFileSync(configPath(), 'utf-8')).to.equal('{ not json'); + }); + }); + + it('warns without asking when the config cannot be read', async () => { + await fsExtra.writeFile(configPath(), '{ not json'); + const confirmRecordSdk = accepts(); + const pluginConfigUnreadable = sinon.stub(PluginRecordSdkPrompts.prototype, 'pluginConfigUnreadable'); + + await execute(profileWith(GIT_CONFIG)); + + expect(pluginConfigUnreadable.called).to.be.true; + expect(confirmRecordSdk.called).to.be.false; + expect(fsExtra.readFileSync(configPath(), 'utf-8')).to.equal('{ not json'); + }); +}); diff --git a/test/infrastructure/services/plugin-service.test.ts b/test/infrastructure/services/plugin-service.test.ts index 281296d9..61051a33 100644 --- a/test/infrastructure/services/plugin-service.test.ts +++ b/test/infrastructure/services/plugin-service.test.ts @@ -205,15 +205,15 @@ describe('PluginService', () => { expect(error.errorMessage).to.include("'author.email' must be an email."); }); - it('keeps the sdkRepos errors addressable by key', async () => { + it('keeps the language errors addressable by key', async () => { respondToStatus = statusBody({ status: 'ValidationError', - errors: { sdkRepos: ['no language in sdkRepos can be built by this generator'] } + errors: { languages: ['no language in languages can be built by this generator'] } }); const error = errorFrom(await generatePlugin()); - expect(error.getError('sdkRepos')).to.deep.equal(['no language in sdkRepos can be built by this generator']); + expect(error.getError('languages')).to.deep.equal(['no language in languages can be built by this generator']); }); it('terminates on a validation error that carries no messages', async () => { diff --git a/test/types/plugin-config-context.test.ts b/test/types/plugin-config-context.test.ts new file mode 100644 index 00000000..94be716b --- /dev/null +++ b/test/types/plugin-config-context.test.ts @@ -0,0 +1,228 @@ +import fs from 'fs'; +import path from 'path'; +import mockFs from 'mock-fs'; +import { expect } from 'chai'; +import { PluginConfigContext } from '../../src/types/plugin-config-context'; +import { DirectoryPath } from '../../src/types/file/directoryPath'; +import { LanguageEntry, PluginConfigData } from '../../src/types/plugin/plugin-config'; +import { CodeGenerationVersion, Language } from '../../src/types/sdk/generate'; + +describe('PluginConfigContext', () => { + const buildDirectory = new DirectoryPath('src'); + const context = new PluginConfigContext(buildDirectory); + + const CSHARP_ENTRY: LanguageEntry = { + source: { repositoryUrl: 'https://github.com/acme/acme-payments-csharp', branch: 'main' }, + package: { packageId: 'Acme.Payments.Sdk' }, + version: CodeGenerationVersion.V3 + }; + + const METADATA = { pluginId: 'acme-payments', pluginName: 'Acme Payments', pluginVersion: '0.1.0' }; + + const writtenConfig = (): PluginConfigData => + JSON.parse(fs.readFileSync(path.join(buildDirectory.toString(), 'plugin-config.json'), 'utf-8')); + + const withConfig = (config: object) => mockFs({ src: { 'plugin-config.json': JSON.stringify(config) } }); + + afterEach(() => mockFs.restore()); + + describe('validate', () => { + it('is missing when there is no file', async () => { + mockFs({ src: {} }); + + expect(await context.validate()).to.deep.equal({ state: 'missing' }); + }); + + it('is unreadable when the file is not valid JSON', async () => { + mockFs({ src: { 'plugin-config.json': '{ not json' } }); + + const state = await context.validate(); + + expect(state.state).to.equal('unreadable'); + }); + + it('is unreadable when the file is a JSON array', async () => { + mockFs({ src: { 'plugin-config.json': '[]' } }); + + const state = await context.validate(); + + expect(state).to.include({ state: 'unreadable', reason: 'it is not a JSON object' }); + }); + + it('is unreadable when the file declares a schema version this CLI does not model', async () => { + withConfig({ schemaVersion: 2, languages: {} }); + + const state = await context.validate(); + + expect(state.state).to.equal('unreadable'); + expect((state as { reason: string }).reason).to.contain('schemaVersion 2'); + }); + + it('reports neither metadata nor languages for a bare file', async () => { + withConfig({ schemaVersion: 1, languages: {} }); + + expect(await context.validate()).to.deep.equal({ + state: 'present', + hasMetadata: false, + hasLanguages: false + }); + }); + + it('reports languages without metadata for a file written by sdk publish', async () => { + withConfig({ schemaVersion: 1, languages: { csharp: CSHARP_ENTRY } }); + + expect(await context.validate()).to.deep.equal({ + state: 'present', + hasMetadata: false, + hasLanguages: true + }); + }); + + it('reports metadata without languages for a file written by plugin generate', async () => { + withConfig({ schemaVersion: 1, ...METADATA, languages: {} }); + + expect(await context.validate()).to.deep.equal({ + state: 'present', + hasMetadata: true, + hasLanguages: false + }); + }); + + it('reports both once the config is complete', async () => { + withConfig({ schemaVersion: 1, ...METADATA, languages: { csharp: CSHARP_ENTRY } }); + + expect(await context.validate()).to.deep.equal({ + state: 'present', + hasMetadata: true, + hasLanguages: true + }); + }); + + it('does not count a blank plugin id as metadata', async () => { + withConfig({ schemaVersion: 1, pluginId: ' ', pluginName: 'Acme', languages: {} }); + + expect(await context.validate()).to.include({ hasMetadata: false }); + }); + }); + + describe('upsertMetadata', () => { + it('creates the file with schema version, metadata and a default licence', async () => { + mockFs({ src: {} }); + + expect(await context.upsertMetadata(METADATA)).to.equal('written'); + expect(writtenConfig()).to.deep.equal({ + schemaVersion: 1, + languages: {}, + ...METADATA, + license: 'MIT' + }); + }); + + it('records the author when one is supplied', async () => { + mockFs({ src: {} }); + + await context.upsertMetadata(METADATA, { name: 'Acme', email: 'developers@acme.com' }); + + expect(writtenConfig().author).to.deep.equal({ name: 'Acme', email: 'developers@acme.com' }); + }); + + it('never writes a plugin key', async () => { + mockFs({ src: {} }); + + await context.upsertMetadata(METADATA); + + expect(writtenConfig()).to.not.have.property('pluginKey'); + }); + + it('leaves a hand-written licence alone', async () => { + withConfig({ schemaVersion: 1, license: 'Apache-2.0', languages: {} }); + + await context.upsertMetadata(METADATA); + + expect(writtenConfig().license).to.equal('Apache-2.0'); + }); + + it('adds metadata to a config sdk publish already created, keeping its languages', async () => { + withConfig({ schemaVersion: 1, languages: { csharp: CSHARP_ENTRY } }); + + await context.upsertMetadata(METADATA); + + const config = writtenConfig(); + expect(config).to.include(METADATA); + expect(config.languages).to.deep.equal({ csharp: CSHARP_ENTRY }); + }); + + it('preserves fields this CLI version does not model', async () => { + withConfig({ schemaVersion: 1, pluginKey: 'hand-written', homepage: 'https://acme.com', languages: {} }); + + await context.upsertMetadata(METADATA); + + const config = writtenConfig(); + expect(config.pluginKey).to.equal('hand-written'); + expect(config.homepage).to.equal('https://acme.com'); + }); + + it('refuses to overwrite a file it could not read', async () => { + mockFs({ src: { 'plugin-config.json': '{ not json' } }); + + expect(await context.upsertMetadata(METADATA)).to.equal('unreadable'); + expect(fs.readFileSync(path.join('src', 'plugin-config.json'), 'utf-8')).to.equal('{ not json'); + }); + }); + + describe('schemaVersion', () => { + it('backfills it into a hand-written file that omits it', async () => { + mockFs({ src: { 'plugin-config.json': JSON.stringify({ languages: {} }) } }); + + await context.upsertMetadata(METADATA); + + expect(writtenConfig().schemaVersion).to.equal(1); + }); + }); + + describe('upsertLanguage', () => { + it('creates the file with no metadata at all', async () => { + mockFs({ src: {} }); + + expect(await context.upsertLanguage(Language.CSHARP, CSHARP_ENTRY)).to.equal('written'); + expect(writtenConfig()).to.deep.equal({ + schemaVersion: 1, + languages: { csharp: CSHARP_ENTRY } + }); + }); + + it('adds a second language beside the first', async () => { + withConfig({ schemaVersion: 1, languages: { csharp: CSHARP_ENTRY } }); + + const typescriptEntry: LanguageEntry = { + source: { repositoryUrl: 'https://github.com/acme/acme-payments-typescript' }, + package: { name: '@acme/payments-sdk' } + }; + await context.upsertLanguage(Language.TYPESCRIPT, typescriptEntry); + + expect(writtenConfig().languages).to.deep.equal({ csharp: CSHARP_ENTRY, typescript: typescriptEntry }); + }); + + it('replaces an entry for a language already recorded', async () => { + withConfig({ schemaVersion: 1, languages: { csharp: { source: { repositoryUrl: 'https://old' } } } }); + + await context.upsertLanguage(Language.CSHARP, CSHARP_ENTRY); + + expect(writtenConfig().languages).to.deep.equal({ csharp: CSHARP_ENTRY }); + }); + + it('leaves existing metadata untouched', async () => { + withConfig({ schemaVersion: 1, ...METADATA, license: 'MIT', languages: {} }); + + await context.upsertLanguage(Language.CSHARP, CSHARP_ENTRY); + + expect(writtenConfig()).to.include({ ...METADATA, license: 'MIT' }); + }); + + it('refuses to overwrite a file it could not read', async () => { + mockFs({ src: { 'plugin-config.json': '{ not json' } }); + + expect(await context.upsertLanguage(Language.CSHARP, CSHARP_ENTRY)).to.equal('unreadable'); + }); + }); +}); diff --git a/test/types/plugin/language-entry.test.ts b/test/types/plugin/language-entry.test.ts new file mode 100644 index 00000000..2fed0bf9 --- /dev/null +++ b/test/types/plugin/language-entry.test.ts @@ -0,0 +1,140 @@ +import { expect } from 'chai'; +import { buildLanguageEntry, LanguageEntryResult } from '../../../src/types/plugin/language-entry'; +import { LanguageEntry } from '../../../src/types/plugin/plugin-config'; +import { GitConfiguration, PackageConfigurationData } from '../../../src/types/publish/package-settings-configuration'; +import { CodeGenerationVersion, Language } from '../../../src/types/sdk/generate'; + +const gitConfig = (repositoryName: string, branch = 'main'): GitConfiguration => ({ + isEnabled: true, + credentialsId: 'creds', + repositoryName, + branch +}); + +// The profile configurations carry a dozen presentational fields the entry never reads. +const packageConfig = (data: object) => data as PackageConfigurationData; + +const entryOf = (result: LanguageEntryResult): LanguageEntry => { + expect(result.kind).to.equal('entry'); + return (result as { kind: 'entry'; entry: LanguageEntry }).entry; +}; + +describe('buildLanguageEntry', () => { + describe('source', () => { + it('resolves a bare repository name against GitHub', () => { + const result = buildLanguageEntry( + Language.CSHARP, + gitConfig('acme/acme-payments-csharp'), + undefined, + CodeGenerationVersion.V4 + ); + + expect(result).to.deep.equal({ + kind: 'entry', + entry: { + source: { repositoryUrl: 'https://github.com/acme/acme-payments-csharp', branch: 'main' }, + version: 'v4' + } + }); + }); + + it('passes an absolute URL through untouched', () => { + const result = buildLanguageEntry( + Language.CSHARP, + gitConfig('https://gitlab.com/acme/sdk'), + undefined, + CodeGenerationVersion.V3 + ); + + expect(entryOf(result).source.repositoryUrl).to.equal('https://gitlab.com/acme/sdk'); + }); + + it('trims stray slashes off a bare repository name', () => { + const result = buildLanguageEntry(Language.GO, gitConfig('/acme/sdk/'), undefined, CodeGenerationVersion.V3); + + expect(entryOf(result).source.repositoryUrl).to.equal('https://github.com/acme/sdk'); + }); + + it('omits the branch when the profile does not name one', () => { + const result = buildLanguageEntry(Language.GO, gitConfig('acme/sdk', ''), undefined, CodeGenerationVersion.V3); + + expect(entryOf(result).source.branch).to.be.undefined; + }); + + it('reports no source repository when the profile has no git configuration', () => { + const result = buildLanguageEntry(Language.CSHARP, undefined, undefined, CodeGenerationVersion.V3); + + expect(result).to.deep.equal({ kind: 'noSourceRepository' }); + }); + + it('reports no source repository when the repository name is blank', () => { + const result = buildLanguageEntry(Language.CSHARP, gitConfig(' '), undefined, CodeGenerationVersion.V3); + + expect(result).to.deep.equal({ kind: 'noSourceRepository' }); + }); + + // Nothing downstream validates the URL — the backend checks only that it is non-blank — so a + // guess would survive all the way to a failed clone. These are refused instead. + ['git@github.com:acme/sdk.git', 'ssh://git@github.com/acme/sdk', 'acme-payments-csharp'].forEach( + (repositoryName) => { + it(`refuses to invent a URL for ${repositoryName}`, () => { + const result = buildLanguageEntry( + Language.CSHARP, + gitConfig(repositoryName), + undefined, + CodeGenerationVersion.V3 + ); + + expect(result).to.deep.equal({ kind: 'unresolvableRepositoryName', repositoryName }); + }); + } + ); + }); + + describe('package', () => { + const packageFor = (language: Language, configuration: object) => + entryOf( + buildLanguageEntry(language, gitConfig('acme/sdk'), packageConfig(configuration), CodeGenerationVersion.V3) + ).package; + + it('names a C# package by its package id', () => { + expect(packageFor(Language.CSHARP, { packageId: 'Acme.Payments.Sdk' })).to.deep.equal({ + packageId: 'Acme.Payments.Sdk' + }); + }); + + it('names a Java package by both halves of its coordinate', () => { + expect(packageFor(Language.JAVA, { groupId: 'io.acme', artifactId: 'acme-sdk' })).to.deep.equal({ + groupId: 'io.acme', + artifactId: 'acme-sdk' + }); + }); + + it('names a PHP package by vendor and project', () => { + expect(packageFor(Language.PHP, { vendorName: 'acme', projectName: 'sdk' })).to.deep.equal({ + vendorName: 'acme', + projectName: 'sdk' + }); + }); + + it('names a Go package by its package name', () => { + expect(packageFor(Language.GO, { packageName: 'acmesdk' })).to.deep.equal({ packageName: 'acmesdk' }); + }); + + for (const language of [Language.TYPESCRIPT, Language.PYTHON, Language.RUBY]) { + it(`names a ${language} package by its single name`, () => { + expect(packageFor(language, { name: '@acme/sdk' })).to.deep.equal({ name: '@acme/sdk' }); + }); + } + + it('omits the package when the profile configures none', () => { + const result = buildLanguageEntry(Language.CSHARP, gitConfig('acme/sdk'), undefined, CodeGenerationVersion.V3); + + expect(entryOf(result).package).to.be.undefined; + }); + + it('omits the package when the configuration is missing half its identity', () => { + expect(packageFor(Language.JAVA, { groupId: 'io.acme' })).to.be.undefined; + }); + }); +});