From 383ad48ab052398749673137a9053051c2f9bbc7 Mon Sep 17 00:00:00 2001 From: MuHamza30 Date: Thu, 13 Aug 2026 16:13:20 +0500 Subject: [PATCH 1/6] feat: describe plugin-config.json and read it through a context The file is written by two commands with different jobs: `sdk publish` records languages, `plugin generate` owns the plugin's own identity. `validate()` reports those two facts separately so each caller can act on its own half, and both upserts preserve keys this CLI version does not model, so a hand-written config survives a round trip. `pluginKey` is read but never written: codegen-v2 parses and validates it, then never reads it, so writing one would only be a promise the plugin cannot keep. Co-Authored-By: Claude Opus 5 (1M context) --- src/types/file/directoryPath.ts | 4 + src/types/plugin-config-context.ts | 121 +++++++++++++ src/types/plugin/language-entry.ts | 99 +++++++++++ src/types/plugin/plugin-config.ts | 93 ++++++++++ src/types/publish/publishing-profile.ts | 4 + src/utils/string-utils.ts | 15 ++ test/types/plugin-config-context.test.ts | 209 +++++++++++++++++++++++ test/types/plugin/language-entry.test.ts | 123 +++++++++++++ 8 files changed, 668 insertions(+) create mode 100644 src/types/plugin-config-context.ts create mode 100644 src/types/plugin/language-entry.ts create mode 100644 src/types/plugin/plugin-config.ts create mode 100644 test/types/plugin-config-context.test.ts create mode 100644 test/types/plugin/language-entry.test.ts 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..c11588eb --- /dev/null +++ b/src/types/plugin-config-context.ts @@ -0,0 +1,121 @@ +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 }; + +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 }; + } + + 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. Returns false only when the file exists but could not be parsed, so a + * caller can stay silent rather than overwrite something the user wrote by hand. + */ + private async merge(apply: (config: PluginConfigData) => PluginConfigData): Promise { + const existing = await this.read(); + if ('reason' in existing) { + return false; + } + + await this.write(apply(existing.config)); + return true; + } + + 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..10196474 --- /dev/null +++ b/src/types/plugin/language-entry.ts @@ -0,0 +1,99 @@ +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; +const GITHUB_BASE_URL = 'https://github.com'; + +/** + * `noSourceRepository` is routine rather than a fault: a package-only publishing profile has no + * repository configured, and a language entry cannot describe an SDK without one. + */ +export type LanguageEntryResult = { kind: 'entry'; entry: LanguageEntry } | { kind: 'noSourceRepository' }; + +export function buildLanguageEntry( + language: Language, + gitConfiguration: GitConfiguration | undefined, + packageConfiguration: PackageConfigurationData | undefined, + version: CodeGenerationVersion +): LanguageEntryResult { + const source = sourceOf(gitConfiguration); + if (!source) { + return { kind: 'noSourceRepository' }; + } + + const entry: LanguageEntry = { source, version }; + const pluginPackage = packageOf(language, packageConfiguration); + if (pluginPackage) { + entry.package = pluginPackage; + } + + return { kind: 'entry', entry }; +} + +function sourceOf(gitConfiguration: GitConfiguration | undefined): LanguageSource | undefined { + const repositoryName = gitConfiguration?.repositoryName?.trim(); + if (!repositoryName) { + return undefined; + } + + const source: LanguageSource = { repositoryUrl: repositoryUrlOf(repositoryName) }; + const branch = gitConfiguration?.branch?.trim(); + if (branch) { + source.branch = branch; + } + + return source; +} + +/** Publishing profiles target GitHub, so a repository named without a host resolves against it. */ +function repositoryUrlOf(repositoryName: string): string { + if (ABSOLUTE_HTTP_URL.test(repositoryName)) { + return repositoryName; + } + return `${GITHUB_BASE_URL}/${repositoryName.replace(/^\/+/, '').replace(/\/+$/, '')}`; +} + +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/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/types/plugin-config-context.test.ts b/test/types/plugin-config-context.test.ts new file mode 100644 index 00000000..62ca7068 --- /dev/null +++ b/test/types/plugin-config-context.test.ts @@ -0,0 +1,209 @@ +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('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.be.true; + 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.be.false; + expect(fs.readFileSync(path.join('src', 'plugin-config.json'), 'utf-8')).to.equal('{ not json'); + }); + }); + + describe('upsertLanguage', () => { + it('creates the file with no metadata at all', async () => { + mockFs({ src: {} }); + + expect(await context.upsertLanguage(Language.CSHARP, CSHARP_ENTRY)).to.be.true; + 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.be.false; + }); + }); +}); diff --git a/test/types/plugin/language-entry.test.ts b/test/types/plugin/language-entry.test.ts new file mode 100644 index 00000000..6f46b232 --- /dev/null +++ b/test/types/plugin/language-entry.test.ts @@ -0,0 +1,123 @@ +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' }); + }); + }); + + 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; + }); + }); +}); From a403f232061ee51ae0a6bf66f09aac8fef6de903 Mon Sep 17 00:00:00 2001 From: MuHamza30 Date: Thu, 13 Aug 2026 16:13:33 +0500 Subject: [PATCH 2/6] feat: record a published SDK in plugin-config.json A published SDK is the only thing that can name a language for the plugin, so `sdk publish` is where the entry comes from. It writes languages alone and never metadata, which keeps publishing free of plugin questions for anyone who does not want a context plugin. Interactive runs are asked. Non-interactive runs answer with `--update-plugin-config` instead: that path is documented for CI/CD, where a prompt is either never answered or silently declined at EOF. Nothing here may change the publish result, so the action returns void and stays silent when the profile names no source repository. Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/plugin/record-sdk.ts | 57 +++++++ src/actions/sdk/publish/interactive.ts | 11 ++ src/actions/sdk/publish/non-interactive.ts | 17 +- src/commands/sdk/publish.ts | 9 +- src/prompts/plugin/record-sdk.ts | 32 ++++ src/types/sdk/generate.ts | 5 + test/actions/plugin/record-sdk.test.ts | 173 +++++++++++++++++++++ 7 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 src/actions/plugin/record-sdk.ts create mode 100644 src/prompts/plugin/record-sdk.ts create mode 100644 test/actions/plugin/record-sdk.test.ts diff --git a/src/actions/plugin/record-sdk.ts b/src/actions/plugin/record-sdk.ts new file mode 100644 index 00000000..956809b1 --- /dev/null +++ b/src/actions/plugin/record-sdk.ts @@ -0,0 +1,57 @@ +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 { 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, + codegenVersion: CodeGenerationVersion, + confirmFirst: boolean = true + ): Promise => { + const built = buildLanguageEntry( + language, + publishingProfile.getGitConfigurationForLanguage(language), + publishingProfile.getPackageConfigurationDataForLanguage(language), + codegenVersion + ); + // A package-only profile names no repository, and a language cannot be described without one, + // so there is nothing to offer. + if (built.kind !== 'entry') { + 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; + } + + if (await pluginConfigContext.upsertLanguage(language, built.entry)) { + this.prompts.sdkRecorded(language); + } + }; +} diff --git a/src/actions/sdk/publish/interactive.ts b/src/actions/sdk/publish/interactive.ts index 2ba49248..87dddb7e 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,16 @@ 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, + 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..5f52b1ec 100644 --- a/src/actions/sdk/publish/non-interactive.ts +++ b/src/actions/sdk/publish/non-interactive.ts @@ -5,12 +5,13 @@ import { CommandMetadata } from '../../../types/common/command-metadata.js'; import { DirectoryPath } from '../../../types/file/directoryPath.js'; import { PublishingProfileItem, PublishType } from '../../../types/publish-api/publishing-profile-item.js'; import { PublishingProfile } from '../../../types/publish/publishing-profile.js'; -import { CodegenOption, Language } from '../../../types/sdk/generate.js'; +import { CodegenOption, CodeGenerationVersion, Language } from '../../../types/sdk/generate.js'; import { ActionResult } from '../../action-result.js'; 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,17 @@ export class SdkPublishNonInteractiveAction { return ActionResult.cancelled(); } + // No prompt here: this path is documented for CI/CD, so the answer comes from the flag. + if (updatePluginConfig) { + await new PluginRecordSdkAction().execute( + buildDirectory, + language, + publishingProfile, + 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/record-sdk.ts b/src/prompts/plugin/record-sdk.ts new file mode 100644 index 00000000..babe8a7c --- /dev/null +++ b/src/prompts/plugin/record-sdk.ts @@ -0,0 +1,32 @@ +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 pluginConfigUnreadable() { + log.warn(`${f.var(PLUGIN_CONFIG_FILE)} could not be read, so this SDK was not added to it.`); + } +} 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/test/actions/plugin/record-sdk.test.ts b/test/actions/plugin/record-sdk.test.ts new file mode 100644 index 00000000..601b39dc --- /dev/null +++ b/test/actions/plugin/record-sdk.test.ts @@ -0,0 +1,173 @@ +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 { PluginConfigData } from '../../../src/types/plugin/plugin-config.js'; +import { PublishingProfile } from '../../../src/types/publish/publishing-profile.js'; +import { CodeGenerationVersion, Language } from '../../../src/types/sdk/generate.js'; + +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; + + const configPath = () => path.join(buildDirectory, 'plugin-config.json'); + const writtenConfig = (): PluginConfigData => fsExtra.readJsonSync(configPath()); + + const execute = (profile: PublishingProfile) => + action.execute(new DirectoryPath(buildDirectory), Language.CSHARP, profile, CodeGenerationVersion.V3); + + // What `sdk publish --update-plugin-config` does: the flag already answered the question. + const executeWithoutAsking = (profile: PublishingProfile) => + action.execute(new DirectoryPath(buildDirectory), Language.CSHARP, profile, 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'); + 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; + }); + + it('stays silent when the profile names no source repository', async () => { + const confirmRecordSdk = accepts(); + + await execute(profileWith(undefined)); + + expect(confirmRecordSdk.called).to.be.false; + expect(fsExtra.existsSync(configPath())).to.be.false; + }); + + 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', async () => { + await executeWithoutAsking(profileWith(undefined)); + + 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'); + }); +}); From 7aff7fe2fdb670fbfc16aaf40daabb81e26d6c27 Mon Sep 17 00:00:00 2001 From: MuHamza30 Date: Thu, 13 Aug 2026 16:13:50 +0500 Subject: [PATCH 3/6] feat: create the plugin's metadata from plugin generate `sdk publish` leaves a config with languages and no identity, and a fresh project has no config at all. Both are now filled in here rather than failing at the backend: the run asks for an id, name and version, writes them beside whatever languages are already recorded, and only then generates. A config that names no language stops with next steps instead of uploading a build the backend will reject. Also renames the language error key to `languages`, following codegen-v2. Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/plugin/create-config.ts | 69 ++++++++ src/actions/plugin/generate.ts | 32 ++++ src/prompts/plugin/create-config.ts | 77 +++++++++ src/prompts/plugin/generate.ts | 30 +++- test/actions/plugin/create-config.test.ts | 148 ++++++++++++++++++ test/actions/plugin/generate.test.ts | 102 +++++++++++- .../services/plugin-service.test.ts | 6 +- 7 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 src/actions/plugin/create-config.ts create mode 100644 src/prompts/plugin/create-config.ts create mode 100644 test/actions/plugin/create-config.test.ts diff --git a/src/actions/plugin/create-config.ts b/src/actions/plugin/create-config.ts new file mode 100644 index 00000000..26e582ba --- /dev/null +++ b/src/actions/plugin/create-config.ts @@ -0,0 +1,69 @@ +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; + if (!(await new PluginConfigContext(buildDirectory).upsertMetadata(metadata, author))) { + this.prompts.pluginConfigUnreadable(); + return ActionResult.failed('plugin-config.json could not be read'); + } + + 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/prompts/plugin/create-config.ts b/src/prompts/plugin/create-config.ts new file mode 100644 index 00000000..c5cc69a8 --- /dev/null +++ b/src/prompts/plugin/create-config.ts @@ -0,0 +1,77 @@ +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 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..6122d47b 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,31 @@ export class PluginGeneratePrompts { log.error(error); } + public pluginConfigUnreadable(reason: string, path: FilePath) { + const message = + `${f.var(PLUGIN_CONFIG_FILE)} could not be read: ${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/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/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 () => { From 6ce0c66f35321a64f1b3ec16cc5e3b4a95ef7459 Mon Sep 17 00:00:00 2001 From: MuHamza30 Date: Thu, 13 Aug 2026 19:56:45 +0500 Subject: [PATCH 4/6] fix: stop a dry run recording an SDK that was never published `--dry-run` generates locally and publishes nothing, but `SdkPublishAction` returns success for it, so `--update-plugin-config` wrote a languages entry claiming a published SDK. `plugin generate` would then build a plugin from a repository holding nothing. Ignoring the flag in silence would repeat the failure it fixes, so the run says why it was ignored. Also drops a `CodeGenerationVersion` import left unused when this call switched to `codegenOption.codeGenerationVersion()`. Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/sdk/publish/non-interactive.ts | 23 +++++++++++++--------- src/prompts/sdk/publish/non-interactive.ts | 9 +++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/actions/sdk/publish/non-interactive.ts b/src/actions/sdk/publish/non-interactive.ts index 5f52b1ec..86fb1e62 100644 --- a/src/actions/sdk/publish/non-interactive.ts +++ b/src/actions/sdk/publish/non-interactive.ts @@ -5,7 +5,7 @@ import { CommandMetadata } from '../../../types/common/command-metadata.js'; import { DirectoryPath } from '../../../types/file/directoryPath.js'; import { PublishingProfileItem, PublishType } from '../../../types/publish-api/publishing-profile-item.js'; import { PublishingProfile } from '../../../types/publish/publishing-profile.js'; -import { CodegenOption, CodeGenerationVersion, Language } from '../../../types/sdk/generate.js'; +import { CodegenOption, Language } from '../../../types/sdk/generate.js'; import { ActionResult } from '../../action-result.js'; import { getDownloadsDirectory } from '../../../infrastructure/os-extensions.js'; import { SemVersion } from '../../../types/publish/version.js'; @@ -137,15 +137,20 @@ export class SdkPublishNonInteractiveAction { return ActionResult.cancelled(); } - // No prompt here: this path is documented for CI/CD, so the answer comes from the flag. + // 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) { - await new PluginRecordSdkAction().execute( - buildDirectory, - language, - publishingProfile, - codegenOption.codeGenerationVersion(), - false - ); + if (dryRun) { + this.prompts.dryRunPluginConfigNotice(); + } else { + await new PluginRecordSdkAction().execute( + buildDirectory, + language, + publishingProfile, + codegenOption.codeGenerationVersion(), + false + ); + } } return ActionResult.success(); 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); + } } From e16e369a929d878b5baddc725b9dd9af55e74d49 Mon Sep 17 00:00:00 2001 From: MuHamza30 Date: Thu, 13 Aug 2026 19:57:04 +0500 Subject: [PATCH 5/6] fix: report the two publishes that recorded nothing in silence A package-only profile names no repository, so no language entry can be built. That return happens before the confirm, so an interactive publish simply never showed the prompt and gave no reason. `upsertLanguage` refusing a config it cannot parse was equally silent, and that one lands after the user has already agreed to record. Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/plugin/record-sdk.ts | 7 ++++++- src/prompts/plugin/record-sdk.ts | 7 +++++++ test/actions/plugin/record-sdk.test.ts | 22 ++++++++++++++++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/actions/plugin/record-sdk.ts b/src/actions/plugin/record-sdk.ts index 956809b1..85a2ae35 100644 --- a/src/actions/plugin/record-sdk.ts +++ b/src/actions/plugin/record-sdk.ts @@ -33,8 +33,10 @@ export class PluginRecordSdkAction { codegenVersion ); // A package-only profile names no repository, and a language cannot be described without one, - // so there is nothing to offer. + // so there is nothing to offer. This returns before the confirm below, so without a message + // the prompt would simply never appear. if (built.kind !== 'entry') { + this.prompts.noSourceRepository(language); return; } @@ -50,8 +52,11 @@ export class PluginRecordSdkAction { return; } + // The state was readable a moment ago, so this only fails if the file changed underneath us. if (await pluginConfigContext.upsertLanguage(language, built.entry)) { this.prompts.sdkRecorded(language); + } else { + this.prompts.pluginConfigUnreadable(); } }; } diff --git a/src/prompts/plugin/record-sdk.ts b/src/prompts/plugin/record-sdk.ts index babe8a7c..0d53011b 100644 --- a/src/prompts/plugin/record-sdk.ts +++ b/src/prompts/plugin/record-sdk.ts @@ -26,6 +26,13 @@ export class PluginRecordSdkPrompts { log.info(message); } + public noSourceRepository(language: Language) { + const message = + `The publishing profile has no source repository for ${f.var(language)}. ` + + `${f.var(PLUGIN_CONFIG_FILE)} needs one to describe an SDK, so nothing was recorded.`; + log.info(message); + } + public pluginConfigUnreadable() { log.warn(`${f.var(PLUGIN_CONFIG_FILE)} could not be read, so this SDK was not added to it.`); } diff --git a/test/actions/plugin/record-sdk.test.ts b/test/actions/plugin/record-sdk.test.ts index 601b39dc..da91e82c 100644 --- a/test/actions/plugin/record-sdk.test.ts +++ b/test/actions/plugin/record-sdk.test.ts @@ -6,6 +6,7 @@ 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 { PublishingProfile } from '../../../src/types/publish/publishing-profile.js'; import { CodeGenerationVersion, Language } from '../../../src/types/sdk/generate.js'; @@ -27,6 +28,7 @@ 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()); @@ -43,6 +45,7 @@ describe('PluginRecordSdkAction', () => { 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(); }); @@ -113,15 +116,29 @@ describe('PluginRecordSdkAction', () => { expect(fsExtra.existsSync(configPath())).to.be.false; }); - it('stays silent when the profile names no source repository', async () => { + // 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; }); + it('reports a config that became unreadable after the user agreed to record', async () => { + const confirmRecordSdk = accepts(); + sinon.stub(PluginConfigContext.prototype, 'upsertLanguage').resolves(false); + 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'); @@ -142,9 +159,10 @@ describe('PluginRecordSdkAction', () => { expect(config).to.not.have.property('pluginId'); }); - it('still skips a profile that names no source repository', async () => { + 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; }); From 92dadec25b37cf08fc06d700be22b455e9b85f03 Mon Sep 17 00:00:00 2001 From: MuHamza30 Date: Thu, 13 Aug 2026 20:33:52 +0500 Subject: [PATCH 6/6] fix: make the recorded language entry truthful and the write safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes that interleave across the same files, so they land together. Record only what the run published. `buildLanguageEntry` read whatever the profile enabled, so `--publish-type package` recorded a source repository this run never pushed to, and `--publish-type sourcecode` recorded a package that was never released. Both halves are now gated on the requested types. Never let recording crash the publish. `merge()` left `write()` unguarded, so a read-only `src/`, a locked file or a full disk turned an already-successful publish into a stack trace with exit 1, and `outro` never ran — despite the action documenting that it never throws. Reporting the fault meant the write result had to say *which* fault, so `upsertMetadata`/`upsertLanguage` return `written | unreadable | unwritable` instead of a boolean that silently meant "unreadable". Refuse to invent a repository URL. A publishing profile carries no git host, so only an absolute http(s) URL or a plain `owner/repo` can be resolved. An SSH remote previously became `https://github.com/git@github.com:acme/sdk.git` and was written as a clone target; the backend validates only that the URL is non-blank, so a guess survived to a failed clone. Anything unresolvable is now reported with the offending value. Backfill `schemaVersion`, and refuse one this CLI does not model. A hand-written config that omitted it round-tripped still missing it and was rejected server side after the CLI said the config was saved. Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/plugin/create-config.ts | 11 ++-- src/actions/plugin/record-sdk.ts | 39 ++++++++++---- src/actions/sdk/publish/interactive.ts | 1 + src/actions/sdk/publish/non-interactive.ts | 1 + src/prompts/plugin/create-config.ts | 4 ++ src/prompts/plugin/generate.ts | 3 +- src/prompts/plugin/record-sdk.ts | 16 +++++- src/types/plugin-config-context.ts | 42 ++++++++++++--- src/types/plugin/language-entry.ts | 56 +++++++++++-------- test/actions/plugin/record-sdk.test.ts | 62 ++++++++++++++++++++-- test/types/plugin-config-context.test.ts | 27 ++++++++-- test/types/plugin/language-entry.test.ts | 17 ++++++ 12 files changed, 222 insertions(+), 57 deletions(-) diff --git a/src/actions/plugin/create-config.ts b/src/actions/plugin/create-config.ts index 26e582ba..7aaaf0a3 100644 --- a/src/actions/plugin/create-config.ts +++ b/src/actions/plugin/create-config.ts @@ -44,9 +44,14 @@ export class PluginCreateConfigAction { } const author = account.isOk() ? authorOf(account.value) : undefined; - if (!(await new PluginConfigContext(buildDirectory).upsertMetadata(metadata, author))) { - this.prompts.pluginConfigUnreadable(); - return ActionResult.failed('plugin-config.json could not be read'); + 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); diff --git a/src/actions/plugin/record-sdk.ts b/src/actions/plugin/record-sdk.ts index 85a2ae35..3fa6302d 100644 --- a/src/actions/plugin/record-sdk.ts +++ b/src/actions/plugin/record-sdk.ts @@ -2,6 +2,7 @@ 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'; @@ -23,18 +24,30 @@ export class PluginRecordSdkAction { 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, - publishingProfile.getGitConfigurationForLanguage(language), - publishingProfile.getPackageConfigurationDataForLanguage(language), + publishTypes.includes(PublishType.SourceCodePublishing) + ? publishingProfile.getGitConfigurationForLanguage(language) + : undefined, + publishTypes.includes(PublishType.PackagePublishing) + ? publishingProfile.getPackageConfigurationDataForLanguage(language) + : undefined, codegenVersion ); - // A package-only profile names no repository, and a language cannot be described without one, - // so there is nothing to offer. This returns before the confirm below, so without a message - // the prompt would simply never appear. + + // 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; @@ -52,11 +65,17 @@ export class PluginRecordSdkAction { return; } - // The state was readable a moment ago, so this only fails if the file changed underneath us. - if (await pluginConfigContext.upsertLanguage(language, built.entry)) { - this.prompts.sdkRecorded(language); - } else { - this.prompts.pluginConfigUnreadable(); + 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 87dddb7e..81b85d35 100644 --- a/src/actions/sdk/publish/interactive.ts +++ b/src/actions/sdk/publish/interactive.ts @@ -147,6 +147,7 @@ export class SdkPublishInteractiveAction { buildDirectory, language, publishingProfile, + publishTypes, codegenOption.codeGenerationVersion() ); diff --git a/src/actions/sdk/publish/non-interactive.ts b/src/actions/sdk/publish/non-interactive.ts index 86fb1e62..8e4d205b 100644 --- a/src/actions/sdk/publish/non-interactive.ts +++ b/src/actions/sdk/publish/non-interactive.ts @@ -147,6 +147,7 @@ export class SdkPublishNonInteractiveAction { buildDirectory, language, publishingProfile, + publishTypes, codegenOption.codeGenerationVersion(), false ); diff --git a/src/prompts/plugin/create-config.ts b/src/prompts/plugin/create-config.ts index c5cc69a8..f4eb065f 100644 --- a/src/prompts/plugin/create-config.ts +++ b/src/prompts/plugin/create-config.ts @@ -66,6 +66,10 @@ export class PluginCreateConfigPrompts { 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` + diff --git a/src/prompts/plugin/generate.ts b/src/prompts/plugin/generate.ts index 6122d47b..9cc356a5 100644 --- a/src/prompts/plugin/generate.ts +++ b/src/prompts/plugin/generate.ts @@ -48,8 +48,7 @@ export class PluginGeneratePrompts { public pluginConfigUnreadable(reason: string, path: FilePath) { const message = - `${f.var(PLUGIN_CONFIG_FILE)} could not be read: ${reason}. ` + - `Fix or delete it at ${f.path(path)} and try again.`; + `${f.var(PLUGIN_CONFIG_FILE)} cannot be used: ${reason}. ` + `Fix or delete it at ${f.path(path)} and try again.`; log.error(message); } diff --git a/src/prompts/plugin/record-sdk.ts b/src/prompts/plugin/record-sdk.ts index 0d53011b..f03df106 100644 --- a/src/prompts/plugin/record-sdk.ts +++ b/src/prompts/plugin/record-sdk.ts @@ -28,12 +28,24 @@ export class PluginRecordSdkPrompts { public noSourceRepository(language: Language) { const message = - `The publishing profile has no source repository for ${f.var(language)}. ` + + `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)} could not be read, so this SDK was not added to it.`); + 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/types/plugin-config-context.ts b/src/types/plugin-config-context.ts index c11588eb..f88c5a53 100644 --- a/src/types/plugin-config-context.ts +++ b/src/types/plugin-config-context.ts @@ -22,6 +22,12 @@ export type PluginConfigState = | { 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 { @@ -43,6 +49,16 @@ export class PluginConfigContext { 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), @@ -54,7 +70,7 @@ export class PluginConfigContext { * 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 { + public async upsertMetadata(metadata: PluginMetadata, author?: PluginAuthor): Promise { return await this.merge((config) => ({ ...config, pluginId: metadata.pluginId, @@ -66,7 +82,7 @@ export class PluginConfigContext { } /** Adds one language, creating the file — with no metadata — when absent. */ - public async upsertLanguage(language: Language, entry: LanguageEntry): Promise { + public async upsertLanguage(language: Language, entry: LanguageEntry): Promise { return await this.merge((config) => ({ ...config, languages: { ...config.languages, [language]: entry } @@ -74,17 +90,27 @@ export class PluginConfigContext { } /** - * Reads, applies, writes. Returns false only when the file exists but could not be parsed, so a - * caller can stay silent rather than overwrite something the user wrote by hand. + * 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 { + private async merge(apply: (config: PluginConfigData) => PluginConfigData): Promise { const existing = await this.read(); if ('reason' in existing) { - return false; + 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'; } - await this.write(apply(existing.config)); - return true; + return 'written'; } private async read(): Promise { diff --git a/src/types/plugin/language-entry.ts b/src/types/plugin/language-entry.ts index 10196474..55f3b72a 100644 --- a/src/types/plugin/language-entry.ts +++ b/src/types/plugin/language-entry.ts @@ -13,13 +13,20 @@ 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: a package-only publishing profile has no - * repository configured, and a language entry cannot describe an SDK without one. + * `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' }; +export type LanguageEntryResult = + | { kind: 'entry'; entry: LanguageEntry } + | { kind: 'noSourceRepository' } + | { kind: 'unresolvableRepositoryName'; repositoryName: string }; export function buildLanguageEntry( language: Language, @@ -27,41 +34,44 @@ export function buildLanguageEntry( packageConfiguration: PackageConfigurationData | undefined, version: CodeGenerationVersion ): LanguageEntryResult { - const source = sourceOf(gitConfiguration); - if (!source) { + const repositoryName = gitConfiguration?.repositoryName?.trim(); + if (!repositoryName) { return { kind: 'noSourceRepository' }; } - const entry: LanguageEntry = { source, version }; - const pluginPackage = packageOf(language, packageConfiguration); - if (pluginPackage) { - entry.package = pluginPackage; - } - - return { kind: 'entry', entry }; -} - -function sourceOf(gitConfiguration: GitConfiguration | undefined): LanguageSource | undefined { - const repositoryName = gitConfiguration?.repositoryName?.trim(); - if (!repositoryName) { - return undefined; + const repositoryUrl = repositoryUrlOf(repositoryName); + if (!repositoryUrl) { + return { kind: 'unresolvableRepositoryName', repositoryName }; } - const source: LanguageSource = { repositoryUrl: repositoryUrlOf(repositoryName) }; + const source: LanguageSource = { repositoryUrl }; const branch = gitConfiguration?.branch?.trim(); if (branch) { source.branch = branch; } - return source; + const entry: LanguageEntry = { source, version }; + const pluginPackage = packageOf(language, packageConfiguration); + if (pluginPackage) { + entry.package = pluginPackage; + } + + return { kind: 'entry', entry }; } -/** Publishing profiles target GitHub, so a repository named without a host resolves against it. */ -function repositoryUrlOf(repositoryName: string): string { +/** + * 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; } - return `${GITHUB_BASE_URL}/${repositoryName.replace(/^\/+/, '').replace(/\/+$/, '')}`; + + 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 { diff --git a/test/actions/plugin/record-sdk.test.ts b/test/actions/plugin/record-sdk.test.ts index da91e82c..38f25ce4 100644 --- a/test/actions/plugin/record-sdk.test.ts +++ b/test/actions/plugin/record-sdk.test.ts @@ -8,9 +8,12 @@ import { PluginRecordSdkPrompts } from '../../../src/prompts/plugin/record-sdk.j 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, @@ -33,12 +36,19 @@ describe('PluginRecordSdkAction', () => { const configPath = () => path.join(buildDirectory, 'plugin-config.json'); const writtenConfig = (): PluginConfigData => fsExtra.readJsonSync(configPath()); - const execute = (profile: PublishingProfile) => - action.execute(new DirectoryPath(buildDirectory), Language.CSHARP, profile, CodeGenerationVersion.V3); + 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) => - action.execute(new DirectoryPath(buildDirectory), Language.CSHARP, profile, CodeGenerationVersion.V3, false); + 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 }); @@ -128,9 +138,51 @@ describe('PluginRecordSdkAction', () => { 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(false); + sinon.stub(PluginConfigContext.prototype, 'upsertLanguage').resolves('unreadable'); const pluginConfigUnreadable = sinon.stub(PluginRecordSdkPrompts.prototype, 'pluginConfigUnreadable'); await execute(profileWith(GIT_CONFIG)); diff --git a/test/types/plugin-config-context.test.ts b/test/types/plugin-config-context.test.ts index 62ca7068..94be716b 100644 --- a/test/types/plugin-config-context.test.ts +++ b/test/types/plugin-config-context.test.ts @@ -49,6 +49,15 @@ describe('PluginConfigContext', () => { 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: {} }); @@ -100,7 +109,7 @@ describe('PluginConfigContext', () => { it('creates the file with schema version, metadata and a default licence', async () => { mockFs({ src: {} }); - expect(await context.upsertMetadata(METADATA)).to.be.true; + expect(await context.upsertMetadata(METADATA)).to.equal('written'); expect(writtenConfig()).to.deep.equal({ schemaVersion: 1, languages: {}, @@ -156,16 +165,26 @@ describe('PluginConfigContext', () => { it('refuses to overwrite a file it could not read', async () => { mockFs({ src: { 'plugin-config.json': '{ not json' } }); - expect(await context.upsertMetadata(METADATA)).to.be.false; + 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.be.true; + expect(await context.upsertLanguage(Language.CSHARP, CSHARP_ENTRY)).to.equal('written'); expect(writtenConfig()).to.deep.equal({ schemaVersion: 1, languages: { csharp: CSHARP_ENTRY } @@ -203,7 +222,7 @@ describe('PluginConfigContext', () => { 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.be.false; + 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 index 6f46b232..2fed0bf9 100644 --- a/test/types/plugin/language-entry.test.ts +++ b/test/types/plugin/language-entry.test.ts @@ -72,6 +72,23 @@ describe('buildLanguageEntry', () => { 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', () => {