Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/actions/plugin/create-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ApiService } from '../../infrastructure/services/api-service.js';
import { PluginCreateConfigPrompts } from '../../prompts/plugin/create-config.js';
import { SubscriptionInfo } from '../../types/api/account.js';
import { CommandMetadata } from '../../types/common/command-metadata.js';
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { PluginAuthor, PluginMetadata } from '../../types/plugin/plugin-config.js';
import { PluginConfigContext } from '../../types/plugin-config-context.js';
import { toKebabCase, toTitleCase } from '../../utils/string-utils.js';
import { ActionResult } from '../action-result.js';

const DEFAULT_PLUGIN_VERSION = '0.1.0';

/**
* Writes the plugin's own details into `plugin-config.json`, creating the file when absent. Only
* `plugin generate` uses this: `sdk publish` records languages and leaves identity alone, so a
* project can be published from long before anyone decides to build a context plugin.
*/
export class PluginCreateConfigAction {
private readonly prompts: PluginCreateConfigPrompts = new PluginCreateConfigPrompts();
private readonly apiService: ApiService = new ApiService();
private readonly configDir: DirectoryPath;
private readonly commandMetadata: CommandMetadata;
private readonly authKey: string | null;

constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) {
this.configDir = configDir;
this.commandMetadata = commandMetadata;
this.authKey = authKey;
}

public readonly execute = async (buildDirectory: DirectoryPath): Promise<ActionResult> => {
const metadata = await this.prompts.inputPluginMetadata(defaultMetadata(buildDirectory));
if (!metadata) {
return ActionResult.cancelled();
}

// Asked after the prompts so a network failure cannot discard what the user just typed. The
// account supplies only the optional author, so failing here would cost more than it saves.
const account = await this.prompts.spinnerAccountInfo(
this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, this.authKey)
);
if (account.isErr()) {
this.prompts.accountInfoUnavailable();
}

const author = account.isOk() ? authorOf(account.value) : undefined;
const written = await new PluginConfigContext(buildDirectory).upsertMetadata(metadata, author);
if (written !== 'written') {
if (written === 'unreadable') {
this.prompts.pluginConfigUnreadable();
} else {
this.prompts.pluginConfigNotWritten();
}
return ActionResult.failed();
}

this.prompts.pluginConfigCreated(metadata);
return ActionResult.success();
};
}

/** Seeded from the project folder holding `src`, which is the closest thing to an API name on disk. */
function defaultMetadata(buildDirectory: DirectoryPath): PluginMetadata {
const projectName = buildDirectory.parent().leafName();
return {
pluginId: toKebabCase(projectName),
pluginName: toTitleCase(projectName),
pluginVersion: DEFAULT_PLUGIN_VERSION
};
}

function authorOf(account: SubscriptionInfo): PluginAuthor | undefined {
return account.FullName ? { name: account.FullName, email: account.Email || undefined } : undefined;
}
32 changes: 32 additions & 0 deletions src/actions/plugin/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -65,4 +94,7 @@ export class PluginGenerateAction {
return ActionResult.success();
});
};

private readonly createConfig = (buildDirectory: DirectoryPath): Promise<ActionResult> =>
new PluginCreateConfigAction(this.configDir, this.commandMetadata, this.authKey).execute(buildDirectory);
}
81 changes: 81 additions & 0 deletions src/actions/plugin/record-sdk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { PluginRecordSdkPrompts } from '../../prompts/plugin/record-sdk.js';
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { buildLanguageEntry } from '../../types/plugin/language-entry.js';
import { PluginConfigContext } from '../../types/plugin-config-context.js';
import { PublishType } from '../../types/publish-api/publishing-profile-item.js';
import { PublishingProfile } from '../../types/publish/publishing-profile.js';
import { CodeGenerationVersion, Language } from '../../types/sdk/generate.js';

/**
* Records a freshly published SDK in `plugin-config.json`. Returns nothing and never throws: this
* runs after a successful publish, and no outcome here may change that result.
*
* Interactive runs are asked first, which is what keeps the file from appearing for users who do
* not want a context plugin. Non-interactive runs answer with `--update-plugin-config` instead,
* because a prompt on the CI path would never be answered.
*
* Metadata is deliberately not written — `plugin generate` owns that, so publishing never has to
* ask for a plugin id or reach the account API.
*/
export class PluginRecordSdkAction {
private readonly prompts: PluginRecordSdkPrompts = new PluginRecordSdkPrompts();

public readonly execute = async (
buildDirectory: DirectoryPath,
language: Language,
publishingProfile: PublishingProfile,
publishTypes: PublishType[],
codegenVersion: CodeGenerationVersion,
confirmFirst: boolean = true
): Promise<void> => {
// The entry has to describe what this run published, not what the profile happens to enable.
// A package-only run must not claim a repository it never pushed to, nor a source-only run a
// package that was never released.
const built = buildLanguageEntry(
language,
publishTypes.includes(PublishType.SourceCodePublishing)
? publishingProfile.getGitConfigurationForLanguage(language)
: undefined,
publishTypes.includes(PublishType.PackagePublishing)
? publishingProfile.getPackageConfigurationDataForLanguage(language)
: undefined,
codegenVersion
);

// Both of these return before the confirm below, so without a message the prompt would simply
// never appear.
if (built.kind === 'unresolvableRepositoryName') {
this.prompts.unresolvableRepositoryName(language, built.repositoryName);
return;
}
if (built.kind !== 'entry') {
this.prompts.noSourceRepository(language);
return;
}

const pluginConfigContext = new PluginConfigContext(buildDirectory);
const configState = await pluginConfigContext.validate();
if (configState.state === 'unreadable') {
this.prompts.pluginConfigUnreadable();
return;
}

// A non-interactive run has already decided via `--update-plugin-config`; there is nobody to ask.
if (confirmFirst && !(await this.prompts.confirmRecordSdk(language, configState.state !== 'missing'))) {
return;
}

switch (await pluginConfigContext.upsertLanguage(language, built.entry)) {
case 'written':
this.prompts.sdkRecorded(language);
break;
// Readable a moment ago, so this only happens if the file changed underneath us.
case 'unreadable':
this.prompts.pluginConfigUnreadable();
break;
case 'unwritable':
this.prompts.pluginConfigNotWritten();
break;
}
};
}
12 changes: 12 additions & 0 deletions src/actions/sdk/publish/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -139,6 +140,17 @@ export class SdkPublishInteractiveAction {
return ActionResult.cancelled();
}

// Interactive only: the non-interactive path is documented for CI/CD, where a prompt would
// never be answered. The publish is already polled to completion by this point, so the SDK
// really is published, and the entry records the generator that actually produced it.
await new PluginRecordSdkAction().execute(
buildDirectory,
language,
publishingProfile,
publishTypes,
codegenOption.codeGenerationVersion()
);

return ActionResult.success();
};

Expand Down
21 changes: 20 additions & 1 deletion src/actions/sdk/publish/non-interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getDownloadsDirectory } from '../../../infrastructure/os-extensions.js'
import { SemVersion } from '../../../types/publish/version.js';
import { ProfileId } from '../../../types/publish/profile-id.js';
import { BuildContext } from '../../../types/build-context.js';
import { PluginRecordSdkAction } from '../../plugin/record-sdk.js';
import { SdkPublishAction } from '../publish.js';
import { FileService } from '../../../infrastructure/file-service.js';

Expand All @@ -32,7 +33,8 @@ export class SdkPublishNonInteractiveAction {
stabilityWasProvided: boolean,
onPublishSdkError: (errorMessage: string) => void,
profileId?: string,
version?: string
version?: string,
updatePluginConfig: boolean = false
): Promise<ActionResult> => {
if (buildDirectory.isEqual(sdkDirectory)) {
this.prompts.directoryCannotBeSame(sdkDirectory);
Expand Down Expand Up @@ -135,6 +137,23 @@ export class SdkPublishNonInteractiveAction {
return ActionResult.cancelled();
}

// No prompt here: this path is documented for CI/CD, so the answer comes from the flag. A dry
// run publishes nothing, so recording it would claim an SDK that does not exist anywhere.
if (updatePluginConfig) {
if (dryRun) {
this.prompts.dryRunPluginConfigNotice();
} else {
await new PluginRecordSdkAction().execute(
buildDirectory,
language,
publishingProfile,
publishTypes,
codegenOption.codeGenerationVersion(),
false
);
}
}

return ActionResult.success();
};
}
9 changes: 8 additions & 1 deletion src/commands/sdk/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
})
};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -153,6 +159,7 @@ export default class SdkPublish extends Command {
onPublishSdkError,
profileId,
version,
updatePluginConfig
);
outro(result);
}
Expand Down
81 changes: 81 additions & 0 deletions src/prompts/plugin/create-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { isCancel, log, text } from '@clack/prompts';
import { Result } from 'neverthrow';
import { ServiceError } from '../../infrastructure/service-error.js';
import { SubscriptionInfo } from '../../types/api/account.js';
import { PluginMetadata } from '../../types/plugin/plugin-config.js';
import { SemVersion } from '../../types/publish/version.js';
import { format as f } from '../format.js';
import { noteWrapped, withSpinner } from '../prompt.js';

const PLUGIN_CONFIG_FILE = 'plugin-config.json';
const KEBAB_CASE = /^[a-z0-9]+(-[a-z0-9]+)*$/;

export class PluginCreateConfigPrompts {
public spinnerAccountInfo(fn: Promise<Result<SubscriptionInfo, ServiceError>>) {
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<PluginMetadata | undefined> {
const pluginId = await text({
message: 'Enter an ID for your plugin:',
placeholder: defaults.pluginId,
validate: (value) => {
if (!value) return 'Plugin ID is required.';
if (!KEBAB_CASE.test(value)) return `Plugin ID must be lower-case kebab-case, for example 'acme-payments'.`;
}
});
if (isCancel(pluginId)) return undefined;

const pluginName = await text({
message: 'Enter a name for your plugin:',
placeholder: defaults.pluginName,
validate: (value) => {
if (!value) return 'Plugin name is required.';
}
});
if (isCancel(pluginName)) return undefined;

const pluginVersion = await text({
message: 'Enter a version for your plugin:',
placeholder: `Provide a version or press Enter to use ${defaults.pluginVersion}.`,
defaultValue: defaults.pluginVersion,
validate: (value) => {
if (value && SemVersion.tryCreate(value).isErr())
return 'Please enter a valid version in the format major.minor.patch (e.g., 0.1.0).';
}
});
if (isCancel(pluginVersion)) return undefined;

return { pluginId, pluginName, pluginVersion };
}

public accountInfoUnavailable() {
log.warn(`Could not read your subscription info, so the author was left out of ${f.var(PLUGIN_CONFIG_FILE)}.`);
}

public pluginConfigUnreadable() {
log.error(`${f.var(PLUGIN_CONFIG_FILE)} could not be read, so its plugin details were not written.`);
}

public pluginConfigNotWritten() {
log.error(`${f.var(PLUGIN_CONFIG_FILE)} could not be written, so its plugin details were not saved.`);
}

public pluginConfigCreated(metadata: PluginMetadata) {
const message =
`Plugin ID: ${f.var(metadata.pluginId)}\n` +
`Plugin Name: ${f.var(metadata.pluginName)}\n` +
`Version: ${f.var(metadata.pluginVersion)}\n\n` +
`Configuration saved to: ${f.var(PLUGIN_CONFIG_FILE)}`;
noteWrapped(message, 'Plugin Configuration');
}
}
Loading
Loading