From 426d0f48da3c20b0b892a2650bb09efb1e9046b1 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Mon, 14 Sep 2026 09:57:09 -0600 Subject: [PATCH 1/4] feat: add `sf schema` command for agentic CLI consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outputs a JSON schema describing a command's input interface — flags with types, summaries, validation constraints, args, org/project/devhub requirements, and error codes. Without --command, outputs a compact index of all available commands. Always outputs JSON (no --json needed). --- messages/schema.md | 27 +++ src/commands/schema.ts | 222 +++++++++++++++++++++++++ test/commands/schema.test.ts | 308 +++++++++++++++++++++++++++++++++++ 3 files changed, 557 insertions(+) create mode 100644 messages/schema.md create mode 100644 src/commands/schema.ts create mode 100644 test/commands/schema.test.ts diff --git a/messages/schema.md b/messages/schema.md new file mode 100644 index 00000000..06b3706e --- /dev/null +++ b/messages/schema.md @@ -0,0 +1,27 @@ +# summary + +Display the schema for a CLI command, including its flags, args, and requirements. + +# description + +Outputs a JSON schema describing a command's input interface: flags with types and validation constraints, args, org/project requirements, and error codes. Designed for programmatic consumption by agents and tools. + +When run without the --command flag, outputs a compact index of all available commands with their requirements. + +# flags.command.summary + +The command to describe (e.g. "org list" or "org:list"). + +# examples + +- Display the schema for the "org list" command: + + <%= config.bin %> <%= command.id %> --command "org list" + +- Display an index of all available commands: + + <%= config.bin %> <%= command.id %> + +# error.commandNotFound + +Command "%s" not found. Run "sf schema" to list all available commands. diff --git a/src/commands/schema.ts b/src/commands/schema.ts new file mode 100644 index 00000000..bfb8e08c --- /dev/null +++ b/src/commands/schema.ts @@ -0,0 +1,222 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; +import { Messages, SfError } from '@salesforce/core'; +// eslint-disable-next-line sf-plugin/no-oclif-flags-command-import +import type { Command } from '@oclif/core'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-info', 'schema'); + +const BASE_FLAGS = new Set(['json', 'flags-dir']); + +type Deprecation = { + to?: string; + message?: string; + version?: string | number; +}; + +type FlagSchema = { + type: 'boolean' | 'option'; + summary?: string; + char?: string; + required?: boolean; + multiple?: boolean; + helpValue?: string | string[]; + options?: readonly string[]; + delimiter?: string; + exclusive?: string[]; + dependsOn?: string[]; + exactlyOne?: string[]; + atLeastOne?: string[]; + deprecated?: true | Deprecation; +}; + +type ArgSchema = { + summary?: string; + required?: boolean; + options?: readonly string[]; + multiple?: boolean; +}; + +type CommandSchema = { + command: string; + summary?: string; + requiresProject: boolean; + requiresOrg: boolean; + acceptsOrg: boolean; + requiresDevHub: boolean; + acceptsDevHub: boolean; + enableJsonFlag: boolean; + flags: Record; + args: Record; + errorCodes?: Array<{ name: string; description: string }>; +}; + +type CommandIndex = { + commands: Array<{ + id: string; + summary?: string; + requiresProject: boolean; + requiresOrg: boolean; + acceptsOrg: boolean; + requiresDevHub: boolean; + acceptsDevHub: boolean; + }>; +}; + +function addRelationships(schema: FlagSchema, flag: Command.Flag.Cached): void { + if (flag.exclusive?.length) schema.exclusive = flag.exclusive; + if (flag.dependsOn?.length) schema.dependsOn = flag.dependsOn; + if (flag.exactlyOne?.length) schema.exactlyOne = flag.exactlyOne; + if (flag.atLeastOne?.length) schema.atLeastOne = flag.atLeastOne; + if (flag.deprecated) { + schema.deprecated = typeof flag.deprecated === 'object' ? flag.deprecated : true; + } +} + +function buildFlagSchema(flag: Command.Flag.Cached): FlagSchema { + const schema: FlagSchema = { type: flag.type }; + if (flag.summary) schema.summary = flag.summary; + if ('char' in flag && flag.char) schema.char = flag.char; + if (flag.required) schema.required = true; + if ('multiple' in flag && flag.multiple) schema.multiple = true; + if ('helpValue' in flag && flag.helpValue) schema.helpValue = flag.helpValue; + if ('options' in flag && flag.options?.length) schema.options = flag.options; + if ('delimiter' in flag && flag.delimiter) schema.delimiter = flag.delimiter; + addRelationships(schema, flag); + return schema; +} + +function buildArgSchema(arg: Command.Arg.Cached): ArgSchema { + const schema: ArgSchema = {}; + if (arg.description) schema.summary = arg.description; + if (arg.required) schema.required = true; + if (arg.options?.length) schema.options = arg.options; + if (arg.multiple) schema.multiple = true; + return schema; +} + +function hasFlag(cmd: Command.Loadable, flagName: string): boolean { + return flagName in cmd.flags; +} + +function hasRequiredFlag(cmd: Command.Loadable, flagName: string): boolean { + const flag = cmd.flags[flagName]; + return flag ? Boolean(flag.required) : false; +} + +function getErrorCodes(cmd: Command.Loadable): Array<{ name: string; description: string }> | undefined { + const errorCodes = (cmd as Record)['errorCodes']; + if (!errorCodes || typeof errorCodes !== 'object') return undefined; + const section = errorCodes as { body?: unknown[] }; + if (!Array.isArray(section.body)) return undefined; + return section.body.filter( + (entry): entry is { name: string; description: string } => + typeof entry === 'object' && + entry !== null && + typeof (entry as Record)['name'] === 'string' && + typeof (entry as Record)['description'] === 'string' + ); +} + +export default class Schema extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly enableJsonFlag = true; + + public static readonly flags = { + command: Flags.string({ + summary: messages.getMessage('flags.command.summary'), + }), + }; + + // eslint-disable-next-line class-methods-use-this + public jsonEnabled(): boolean { + return true; + } + + public async run(): Promise { + const { flags } = await this.parse(Schema); + + if (flags.command) { + return this.getCommandSchema(flags.command); + } + + return this.getCommandIndex(); + } + + private getCommandSchema(commandInput: string): CommandSchema { + const commandId = commandInput.replace(/ /g, ':'); + const found = this.config.findCommand(commandId); + if (!found) { + throw new SfError(messages.getMessage('error.commandNotFound', [commandInput]), 'CommandNotFoundError'); + } + + const flagSchemas: Record = {}; + for (const [flagName, flag] of Object.entries(found.flags)) { + if (!flag.hidden && !BASE_FLAGS.has(flagName)) { + flagSchemas[flagName] = buildFlagSchema(flag); + } + } + + const argSchemas: Record = {}; + for (const [argName, arg] of Object.entries(found.args)) { + if (!arg.hidden) { + argSchemas[argName] = buildArgSchema(arg); + } + } + + const result: CommandSchema = { + command: found.id, + summary: found.summary, + requiresProject: Boolean(found.requiresProject), + requiresOrg: hasRequiredFlag(found, 'target-org'), + acceptsOrg: hasFlag(found, 'target-org'), + requiresDevHub: hasRequiredFlag(found, 'target-dev-hub'), + acceptsDevHub: hasFlag(found, 'target-dev-hub'), + enableJsonFlag: Boolean(found.enableJsonFlag), + flags: flagSchemas, + args: argSchemas, + }; + + const codes = getErrorCodes(found); + if (codes?.length) { + result.errorCodes = codes; + } + + return result; + } + + private getCommandIndex(): CommandIndex { + const commands = this.config.commands + .filter((c) => !c.hidden) + .sort((a, b) => a.id.localeCompare(b.id)) + .map((c) => ({ + id: c.id, + summary: c.summary, + requiresProject: Boolean(c.requiresProject), + requiresOrg: hasRequiredFlag(c, 'target-org'), + acceptsOrg: hasFlag(c, 'target-org'), + requiresDevHub: hasRequiredFlag(c, 'target-dev-hub'), + acceptsDevHub: hasFlag(c, 'target-dev-hub'), + })); + + return { commands }; + } +} diff --git a/test/commands/schema.test.ts b/test/commands/schema.test.ts new file mode 100644 index 00000000..4c8292f1 --- /dev/null +++ b/test/commands/schema.test.ts @@ -0,0 +1,308 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Sinon from 'sinon'; +import { expect } from 'chai'; +import { fromStub, stubInterface } from '@salesforce/ts-sinon'; +import { Config } from '@oclif/core'; +import SchemaCmd from '../../src/commands/schema.js'; + +const makeCommand = (overrides: Record = {}) => ({ + id: 'test:command', + summary: 'A test command', + hidden: false, + flags: { + json: { type: 'boolean' as const, name: 'json', hidden: false }, + 'flags-dir': { type: 'option' as const, name: 'flags-dir', hidden: false }, + 'target-org': { + type: 'option' as const, + name: 'target-org', + summary: 'Username or alias of the target org.', + char: 'o', + required: true, + hidden: false, + }, + verbose: { + type: 'boolean' as const, + name: 'verbose', + summary: 'Show verbose output.', + char: 'v', + required: false, + hidden: false, + }, + format: { + type: 'option' as const, + name: 'format', + summary: 'Output format.', + options: ['json', 'csv', 'table'], + required: false, + hidden: false, + }, + secret: { + type: 'option' as const, + name: 'secret', + hidden: true, + }, + exclusive1: { + type: 'boolean' as const, + name: 'exclusive1', + exclusive: ['exclusive2'], + hidden: false, + }, + exclusive2: { + type: 'boolean' as const, + name: 'exclusive2', + exclusive: ['exclusive1'], + hidden: false, + }, + 'old-flag': { + type: 'option' as const, + name: 'old-flag', + deprecated: { to: 'new-flag', version: '5.0.0' }, + hidden: false, + }, + }, + args: { + file: { name: 'file', description: 'Path to the file.', required: true, hidden: false }, + optionalArg: { name: 'optionalArg', required: false, hidden: false }, + }, + enableJsonFlag: true, + requiresProject: true, + ...overrides, +}); + +const makeDevHubCommand = () => + makeCommand({ + id: 'test:devhub', + flags: { + json: { type: 'boolean' as const, name: 'json', hidden: false }, + 'flags-dir': { type: 'option' as const, name: 'flags-dir', hidden: false }, + 'target-dev-hub': { + type: 'option' as const, + name: 'target-dev-hub', + char: 'v', + required: true, + hidden: false, + }, + }, + args: {}, + }); + +const makeOptionalOrgCommand = () => + makeCommand({ + id: 'test:optionalorg', + flags: { + json: { type: 'boolean' as const, name: 'json', hidden: false }, + 'flags-dir': { type: 'option' as const, name: 'flags-dir', hidden: false }, + 'target-org': { + type: 'option' as const, + name: 'target-org', + char: 'o', + required: false, + hidden: false, + }, + }, + args: {}, + requiresProject: false, + }); + +const makeErrorCodesCommand = () => + makeCommand({ + id: 'test:errorcodes', + errorCodes: { + header: 'ERROR CODES', + body: [ + { name: 'Success (0)', description: 'Completed successfully.' }, + { name: 'AuthError (1)', description: 'Authentication failed.' }, + ], + }, + }); + +describe('schema', () => { + const sandbox = Sinon.createSandbox(); + let oclifConfig: Config; + const commands = [ + makeCommand(), + makeCommand({ + id: 'other:command', + hidden: false, + requiresProject: false, + flags: { + json: { type: 'boolean' as const, name: 'json', hidden: false }, + 'flags-dir': { type: 'option' as const, name: 'flags-dir', hidden: false }, + }, + args: {}, + }), + makeCommand({ id: 'hidden:command', hidden: true }), + makeErrorCodesCommand(), + makeDevHubCommand(), + makeOptionalOrgCommand(), + ]; + + before(() => { + oclifConfig = fromStub( + stubInterface(sandbox, { + runHook: async () => ({ + successes: [], + failures: [], + }), + commands, + findCommand: ((id: string) => commands.find((c) => c.id === id)) as Config['findCommand'], + }) + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + const runSchema = async (params: string[]) => { + const cmd = new SchemaCmd(params, oclifConfig); + return cmd.run(); + }; + + describe('--command', () => { + it('returns schema for a command using colon syntax', async () => { + const result = await runSchema(['--command', 'test:command', '--json']); + expect(result).to.have.property('command', 'test:command'); + expect(result).to.have.property('requiresProject', true); + expect(result).to.have.property('requiresOrg', true); + expect(result).to.have.property('acceptsOrg', true); + expect(result).to.have.property('enableJsonFlag', true); + }); + + it('returns schema for a command using space syntax', async () => { + const result = await runSchema(['--command', 'test command', '--json']); + expect(result).to.have.property('command', 'test:command'); + }); + + it('excludes hidden flags and base flags', async () => { + const result = await runSchema(['--command', 'test:command', '--json']); + const flags = (result as Record)['flags'] as Record; + expect(flags).to.not.have.property('secret'); + expect(flags).to.not.have.property('json'); + expect(flags).to.not.have.property('flags-dir'); + expect(flags).to.have.property('verbose'); + }); + + it('includes flag summary', async () => { + const result = await runSchema(['--command', 'test:command', '--json']); + const flags = (result as Record)['flags'] as Record>; + expect(flags['verbose']).to.have.property('summary', 'Show verbose output.'); + }); + + it('includes flag options enum', async () => { + const result = await runSchema(['--command', 'test:command', '--json']); + const flags = (result as Record)['flags'] as Record>; + expect(flags['format']).to.have.property('options').that.deep.equals(['json', 'csv', 'table']); + }); + + it('includes flag exclusive relationships', async () => { + const result = await runSchema(['--command', 'test:command', '--json']); + const flags = (result as Record)['flags'] as Record>; + expect(flags['exclusive1']).to.have.property('exclusive').that.deep.equals(['exclusive2']); + }); + + it('preserves deprecation detail', async () => { + const result = await runSchema(['--command', 'test:command', '--json']); + const flags = (result as Record)['flags'] as Record>; + expect(flags['old-flag']).to.have.property('deprecated').that.deep.equals({ to: 'new-flag', version: '5.0.0' }); + }); + + it('includes args with summary', async () => { + const result = await runSchema(['--command', 'test:command', '--json']); + const args = (result as Record)['args'] as Record>; + expect(args['file']).to.have.property('required', true); + expect(args['file']).to.have.property('summary', 'Path to the file.'); + expect(args['optionalArg']).to.not.have.property('required'); + }); + + it('handles commands with empty args', async () => { + const result = await runSchema(['--command', 'other:command', '--json']); + expect(result).to.have.property('args').that.deep.equals({}); + }); + + it('includes error codes when defined', async () => { + const result = await runSchema(['--command', 'test:errorcodes', '--json']); + expect(result).to.have.property('errorCodes').with.lengthOf(2); + }); + + it('detects requiresDevHub', async () => { + const result = await runSchema(['--command', 'test:devhub', '--json']); + expect(result).to.have.property('requiresDevHub', true); + expect(result).to.have.property('acceptsDevHub', true); + }); + + it('distinguishes acceptsOrg from requiresOrg', async () => { + const result = await runSchema(['--command', 'test:optionalorg', '--json']); + expect(result).to.have.property('requiresOrg', false); + expect(result).to.have.property('acceptsOrg', true); + }); + + it('throws for unknown commands', async () => { + try { + await runSchema(['--command', 'nonexistent', '--json']); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.have.property('name', 'CommandNotFoundError'); + } + }); + }); + + describe('command index', () => { + it('lists all non-hidden commands', async () => { + const result = await runSchema(['--json']); + const index = result as { commands: Array<{ id: string }> }; + expect(index.commands).to.be.an('array'); + const ids = index.commands.map((c) => c.id); + expect(ids).to.include('test:command'); + expect(ids).to.include('other:command'); + expect(ids).to.not.include('hidden:command'); + }); + + it('includes requirement and accepts flags in index', async () => { + const result = await runSchema(['--json']); + const index = result as { + commands: Array<{ + id: string; + requiresProject: boolean; + requiresOrg: boolean; + acceptsOrg: boolean; + requiresDevHub: boolean; + acceptsDevHub: boolean; + }>; + }; + + const testCmd = index.commands.find((c) => c.id === 'test:command'); + expect(testCmd).to.have.property('requiresProject', true); + expect(testCmd).to.have.property('requiresOrg', true); + expect(testCmd).to.have.property('acceptsOrg', true); + + const otherCmd = index.commands.find((c) => c.id === 'other:command'); + expect(otherCmd).to.have.property('requiresProject', false); + expect(otherCmd).to.have.property('requiresOrg', false); + expect(otherCmd).to.have.property('acceptsOrg', false); + + const devHubCmd = index.commands.find((c) => c.id === 'test:devhub'); + expect(devHubCmd).to.have.property('requiresDevHub', true); + expect(devHubCmd).to.have.property('acceptsDevHub', true); + + const optionalOrgCmd = index.commands.find((c) => c.id === 'test:optionalorg'); + expect(optionalOrgCmd).to.have.property('requiresOrg', false); + expect(optionalOrgCmd).to.have.property('acceptsOrg', true); + }); + }); +}); From b756a41b281978aeebbd489c51c6208881de1160 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Mon, 14 Sep 2026 13:26:03 -0600 Subject: [PATCH 2/4] feat: add `sf status` command for agentic CLI context Always-JSON command that surfaces the current CLI environment: - config values (target-org, target-dev-hub, api-version) with source location - resolved target org and dev hub details (username, alias, orgId, instanceUrl, tracksSource, expirationDate, org type flags) - project context (path, defaultPackagePath, packageDirectories, namespace) - CLI version, node, os, shell, arch --- messages/status.md | 11 ++ src/commands/status.ts | 184 ++++++++++++++++++++ test/commands/status.test.ts | 315 +++++++++++++++++++++++++++++++++++ 3 files changed, 510 insertions(+) create mode 100644 messages/status.md create mode 100644 src/commands/status.ts create mode 100644 test/commands/status.test.ts diff --git a/messages/status.md b/messages/status.md new file mode 100644 index 00000000..ec85a96a --- /dev/null +++ b/messages/status.md @@ -0,0 +1,11 @@ +# summary + +Display the current CLI environment context. + +# description + +Show the active target org, dev hub, API version, project information, authenticated orgs, and CLI version. Always outputs JSON for programmatic consumption. + +# examples + +- <%= config.bin %> <%= command.id %> diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 00000000..a41ce528 --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,184 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SfCommand } from '@salesforce/sf-plugins-core'; +import { + AuthInfo, + ConfigAggregator, + Messages, + OrgConfigProperties, + SfProject, + StateAggregator, +} from '@salesforce/core'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-info', 'status'); + +type ConfigEntry = { + value: string; + location: string; +}; + +type OrgDetail = { + username: string; + alias?: string; + orgId?: string; + instanceUrl?: string; + isScratchOrg?: boolean; + isDevHub?: boolean; + isSandbox?: boolean; + tracksSource?: boolean; + expirationDate?: string; + error?: string; +}; + +type ProjectInfo = { + path: string; + defaultPackagePath?: string; + packageDirectories: Array<{ path: string; default?: boolean }>; + namespace?: string; + sourceApiVersion?: string; +}; + +type StatusResult = { + config: Record; + targetOrg?: OrgDetail; + targetDevHub?: OrgDetail; + project?: ProjectInfo; + cli: { + version: string; + node: string; + os?: string; + shell?: string; + arch: string; + }; +}; + +const CONFIG_KEYS = [ + OrgConfigProperties.TARGET_ORG, + OrgConfigProperties.TARGET_DEV_HUB, + OrgConfigProperties.ORG_API_VERSION, + OrgConfigProperties.ORG_INSTANCE_URL, +] as const; + +function locationLabel(info: { isLocal: () => boolean; isGlobal: () => boolean; isEnvVar: () => boolean }): string { + if (info.isEnvVar()) return 'environment'; + if (info.isLocal()) return 'local'; + if (info.isGlobal()) return 'global'; + return 'unknown'; +} + +function buildConfigEntries(aggregator: ConfigAggregator): Record { + const entries: Record = {}; + for (const key of CONFIG_KEYS) { + const info = aggregator.getInfo(key); + if (info.value != null && typeof info.value === 'string') { + entries[key] = { + value: info.value, + location: locationLabel(info), + }; + } + } + return entries; +} + +function buildProjectInfo(): ProjectInfo | undefined { + try { + const project = SfProject.getInstance(); + const json = project.getSfProjectJson(); + const contents = json.getContents(); + const pkgDirs = project.getPackageDirectories(); + const defaultDir = pkgDirs.find((d) => d.default); + const info: ProjectInfo = { + path: project.getPath(), + packageDirectories: pkgDirs.map((d) => { + const entry: { path: string; default?: boolean } = { path: d.path }; + if (d.default) entry.default = true; + return entry; + }), + }; + if (defaultDir) info.defaultPackagePath = defaultDir.path; + if (contents.namespace) info.namespace = contents.namespace; + if (contents.sourceApiVersion) info.sourceApiVersion = contents.sourceApiVersion; + return info; + } catch { + return undefined; + } +} + +async function resolveOrgDetail(aliasOrUsername: string): Promise { + try { + const stateAgg = await StateAggregator.getInstance(); + const username = stateAgg.aliases.resolveUsername(aliasOrUsername); + const alias = username !== aliasOrUsername ? aliasOrUsername : undefined; + const authInfo = await AuthInfo.create({ username }); + const fields = authInfo.getFields(); + const detail: OrgDetail = { username }; + if (alias) detail.alias = alias; + if (fields.orgId) detail.orgId = fields.orgId; + if (fields.instanceUrl) detail.instanceUrl = fields.instanceUrl; + const rawFields = fields as Record; + if (fields.devHubUsername || rawFields['isScratch']) detail.isScratchOrg = true; + if (fields.isDevHub) detail.isDevHub = true; + if (rawFields['isSandbox']) detail.isSandbox = true; + if (fields.tracksSource != null) detail.tracksSource = Boolean(fields.tracksSource); + const expDate = fields.expirationDate ?? (rawFields['trailExpirationDate'] as string | undefined); + if (expDate) detail.expirationDate = String(expDate); + return detail; + } catch (e) { + return { username: aliasOrUsername, error: e instanceof Error ? e.message : 'Unknown error' }; + } +} + +export default class Status extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly enableJsonFlag = true; + + // eslint-disable-next-line class-methods-use-this + public jsonEnabled(): boolean { + return true; + } + + public async run(): Promise { + await this.parse(Status); + + const aggregator = await ConfigAggregator.create(); + const config = buildConfigEntries(aggregator); + const versionDetails = this.config.versionDetails; + + const result: StatusResult = { + config, + project: buildProjectInfo(), + cli: { + version: versionDetails.cliVersion, + node: versionDetails.nodeVersion, + os: versionDetails.osVersion, + shell: versionDetails.shell, + arch: versionDetails.architecture, + }, + }; + + const targetOrgValue = config[OrgConfigProperties.TARGET_ORG]?.value; + const targetDevHubValue = config[OrgConfigProperties.TARGET_DEV_HUB]?.value; + + if (targetOrgValue) result.targetOrg = await resolveOrgDetail(targetOrgValue); + if (targetDevHubValue) result.targetDevHub = await resolveOrgDetail(targetDevHubValue); + + return result; + } +} diff --git a/test/commands/status.test.ts b/test/commands/status.test.ts new file mode 100644 index 00000000..cc80d352 --- /dev/null +++ b/test/commands/status.test.ts @@ -0,0 +1,315 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Sinon from 'sinon'; +import { expect } from 'chai'; +import { fromStub, stubInterface } from '@salesforce/ts-sinon'; +import { AuthInfo, ConfigAggregator, SfProject, OrgConfigProperties, StateAggregator } from '@salesforce/core'; +import { Config, Interfaces } from '@oclif/core'; +import StatusCmd from '../../src/commands/status.js'; + +const makeVersionDetails = (overrides?: Partial): Interfaces.VersionDetails => ({ + cliVersion: 'sf/2.50.0', + architecture: 'darwin-arm64', + nodeVersion: 'node-v22.0.0', + osVersion: 'Darwin 25.0.0', + shell: 'zsh', + rootPath: '/usr/local/lib/sf', + pluginVersions: { + '@salesforce/plugin-org': { version: '5.0.0', type: 'core', root: '/path/to/org' }, + '@salesforce/plugin-source': { version: '3.2.0', type: 'core', root: '/path/to/source' }, + }, + ...overrides, +}); + +describe('status', () => { + const sandbox = Sinon.createSandbox(); + let oclifConfig: Config; + let configAggregatorStub: Sinon.SinonStub; + let authInfoCreateStub: Sinon.SinonStub; + let projectGetInstanceStub: Sinon.SinonStub; + + const mockConfigAggregator = { + getInfo: (key: string) => { + const values: Record< + string, + { value: string | null; isLocal: () => boolean; isGlobal: () => boolean; isEnvVar: () => boolean } + > = { + [OrgConfigProperties.TARGET_ORG]: { + value: 'my-scratch-org', + isLocal: () => true, + isGlobal: () => false, + isEnvVar: () => false, + }, + [OrgConfigProperties.TARGET_DEV_HUB]: { + value: 'devhub@example.com', + isLocal: () => false, + isGlobal: () => true, + isEnvVar: () => false, + }, + [OrgConfigProperties.ORG_API_VERSION]: { + value: null, + isLocal: () => false, + isGlobal: () => false, + isEnvVar: () => false, + }, + [OrgConfigProperties.ORG_INSTANCE_URL]: { + value: null, + isLocal: () => false, + isGlobal: () => false, + isEnvVar: () => false, + }, + }; + return values[key] ?? { value: null, isLocal: () => false, isGlobal: () => false, isEnvVar: () => false }; + }, + }; + + const noConfigAggregator = { + getInfo: () => ({ value: null, isLocal: () => false, isGlobal: () => false, isEnvVar: () => false }), + }; + + const mockStateAggregator = { + aliases: { + resolveUsername: (input: string) => { + if (input === 'my-scratch-org') return 'user@test.org'; + return input; + }, + }, + }; + + const mockAuthFields: Record> = { + 'user@test.org': { + orgId: '00D000000000001', + instanceUrl: 'https://test.salesforce.com', + devHubUsername: 'devhub@example.com', + isDevHub: false, + accessToken: '00D!SECRET_TOKEN', + }, + 'devhub@example.com': { + orgId: '00D000000000002', + instanceUrl: 'https://login.salesforce.com', + isDevHub: true, + }, + }; + + before(() => { + oclifConfig = fromStub( + stubInterface(sandbox, { + runHook: async () => ({ + successes: [], + failures: [], + }), + versionDetails: makeVersionDetails(), + commands: [], + }) + ); + }); + + beforeEach(() => { + configAggregatorStub = sandbox + .stub(ConfigAggregator, 'create') + .resolves(mockConfigAggregator as unknown as ConfigAggregator); + sandbox.stub(StateAggregator, 'getInstance').resolves(mockStateAggregator as unknown as StateAggregator); + authInfoCreateStub = sandbox.stub(AuthInfo, 'create').callsFake(async (opts) => { + const username = (opts as { username: string }).username; + const fields = mockAuthFields[username] ?? {}; + return { getFields: () => fields } as unknown as AuthInfo; + }); + projectGetInstanceStub = sandbox.stub(SfProject, 'getInstance'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + const runStatus = async (params: string[] = []) => { + const cmd = new StatusCmd(params, oclifConfig); + return cmd.run(); + }; + + it('returns config values with location', async () => { + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.config).to.have.property('target-org'); + expect(result.config['target-org']).to.deep.equal({ value: 'my-scratch-org', location: 'local' }); + expect(result.config).to.have.property('target-dev-hub'); + expect(result.config['target-dev-hub']).to.deep.equal({ value: 'devhub@example.com', location: 'global' }); + }); + + it('omits config keys with null values', async () => { + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.config).to.not.have.property('org-api-version'); + expect(result.config).to.not.have.property('org-instance-url'); + }); + + it('resolves target-org with alias to org details', async () => { + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.targetOrg).to.not.be.undefined; + expect(result.targetOrg!.username).to.equal('user@test.org'); + expect(result.targetOrg!.alias).to.equal('my-scratch-org'); + expect(result.targetOrg!.orgId).to.equal('00D000000000001'); + expect(result.targetOrg!.instanceUrl).to.equal('https://test.salesforce.com'); + expect(result.targetOrg!.isScratchOrg).to.equal(true); + }); + + it('resolves target-dev-hub to org details', async () => { + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.targetDevHub).to.not.be.undefined; + expect(result.targetDevHub!.username).to.equal('devhub@example.com'); + expect(result.targetDevHub!.alias).to.be.undefined; + expect(result.targetDevHub!.orgId).to.equal('00D000000000002'); + expect(result.targetDevHub!.isDevHub).to.equal(true); + }); + + it('omits targetOrg when no target-org is configured', async () => { + configAggregatorStub.resolves(noConfigAggregator); + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.targetOrg).to.be.undefined; + expect(result.targetDevHub).to.be.undefined; + }); + + it('returns error details when org auth fails', async () => { + authInfoCreateStub.rejects(new Error('INVALID_GRANT: expired access/refresh token')); + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.targetOrg).to.not.be.undefined; + expect(result.targetOrg!.username).to.equal('my-scratch-org'); + expect(result.targetOrg!.error).to.equal('INVALID_GRANT: expired access/refresh token'); + }); + + it('never exposes accessToken in org details', async () => { + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.targetOrg).to.not.have.property('accessToken'); + expect(JSON.stringify(result)).to.not.include('SECRET_TOKEN'); + }); + + it('returns project info when in a project', async () => { + const mockProject = { + getPath: () => '/Users/test/my-project', + getSfProjectJson: () => ({ + getContents: () => ({ + namespace: 'myNs', + sourceApiVersion: '60.0', + }), + }), + getPackageDirectories: () => [ + { path: 'force-app', default: true, name: 'force-app', fullPath: '/Users/test/my-project/force-app' }, + { path: 'utils', name: 'utils', fullPath: '/Users/test/my-project/utils' }, + ], + }; + projectGetInstanceStub.returns(mockProject); + const result = await runStatus(); + + expect(result.project).to.not.be.undefined; + expect(result.project!.path).to.equal('/Users/test/my-project'); + expect(result.project!.namespace).to.equal('myNs'); + expect(result.project!.sourceApiVersion).to.equal('60.0'); + expect(result.project!.packageDirectories).to.have.lengthOf(2); + expect(result.project!.packageDirectories[0]).to.deep.equal({ path: 'force-app', default: true }); + }); + + it('returns undefined project when not in a project', async () => { + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.project).to.be.undefined; + }); + + it('returns CLI version info', async () => { + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.cli).to.have.property('version', 'sf/2.50.0'); + expect(result.cli).to.have.property('node', 'node-v22.0.0'); + expect(result.cli).to.have.property('os', 'Darwin 25.0.0'); + expect(result.cli).to.have.property('shell', 'zsh'); + expect(result.cli).to.have.property('arch', 'darwin-arm64'); + }); + + it('includes tracksSource and expirationDate when present', async () => { + authInfoCreateStub.restore(); + sandbox.stub(AuthInfo, 'create').callsFake(async (opts) => { + const username = (opts as { username: string }).username; + if (username === 'user@test.org') { + return { + getFields: () => ({ + orgId: '00D000000000001', + instanceUrl: 'https://test.salesforce.com', + devHubUsername: 'devhub@example.com', + tracksSource: true, + expirationDate: '2026-09-20', + }), + } as AuthInfo; + } + return { getFields: () => ({}) } as AuthInfo; + }); + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.targetOrg!.tracksSource).to.equal(true); + expect(result.targetOrg!.expirationDate).to.equal('2026-09-20'); + }); + + it('surfaces defaultPackagePath from project', async () => { + const mockProject = { + getPath: () => '/Users/test/my-project', + getSfProjectJson: () => ({ + getContents: () => ({}), + }), + getPackageDirectories: () => [ + { path: 'force-app', default: true, name: 'force-app', fullPath: '/Users/test/my-project/force-app' }, + { path: 'utils', name: 'utils', fullPath: '/Users/test/my-project/utils' }, + ], + }; + projectGetInstanceStub.returns(mockProject); + const result = await runStatus(); + + expect(result.project!.defaultPackagePath).to.equal('force-app'); + }); + + it('reports env var config location', async () => { + const envConfigAggregator = { + getInfo: (key: string) => { + if (key === (OrgConfigProperties.TARGET_ORG as string)) { + return { + value: 'env-org', + isLocal: () => false, + isGlobal: () => false, + isEnvVar: () => true, + }; + } + return { value: null, isLocal: () => false, isGlobal: () => false, isEnvVar: () => false }; + }, + }; + configAggregatorStub.resolves(envConfigAggregator); + projectGetInstanceStub.throws(new Error('InvalidProjectWorkspaceError')); + const result = await runStatus(); + + expect(result.config['target-org']).to.deep.equal({ value: 'env-org', location: 'environment' }); + }); +}); From c26a615c61da2529ac83476f3a8be71b36f5026a Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 14:52:02 -0600 Subject: [PATCH 3/4] fix: export return types for schema:generate compatibility ts-json-schema-generator requires return types to be exported and resolvable as named types. Export CommandSchema, CommandIndex, and StatusResult. Add a SchemaResult type alias for the union type since the generator cannot resolve inline union expressions. Regenerate command-snapshot.json and schema JSON files. --- command-snapshot.json | 16 +++ schemas/schema.json | 230 +++++++++++++++++++++++++++++++++++++++++ schemas/status.json | 160 ++++++++++++++++++++++++++++ src/commands/schema.ts | 10 +- src/commands/status.ts | 2 +- 5 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 schemas/schema.json create mode 100644 schemas/status.json diff --git a/command-snapshot.json b/command-snapshot.json index 13f281bc..4ad6663e 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -14,5 +14,21 @@ "flagChars": ["v"], "flags": ["flags-dir", "hook", "json", "loglevel", "version"], "plugin": "@salesforce/plugin-info" + }, + { + "alias": [], + "command": "schema", + "flagAliases": [], + "flagChars": [], + "flags": ["command", "flags-dir", "json"], + "plugin": "@salesforce/plugin-info" + }, + { + "alias": [], + "command": "status", + "flagAliases": [], + "flagChars": [], + "flags": ["flags-dir", "json"], + "plugin": "@salesforce/plugin-info" } ] diff --git a/schemas/schema.json b/schemas/schema.json new file mode 100644 index 00000000..c2de936b --- /dev/null +++ b/schemas/schema.json @@ -0,0 +1,230 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/SchemaResult", + "definitions": { + "SchemaResult": { + "anyOf": [ + { + "$ref": "#/definitions/CommandSchema" + }, + { + "$ref": "#/definitions/CommandIndex" + } + ] + }, + "CommandSchema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "requiresProject": { + "type": "boolean" + }, + "requiresOrg": { + "type": "boolean" + }, + "acceptsOrg": { + "type": "boolean" + }, + "requiresDevHub": { + "type": "boolean" + }, + "acceptsDevHub": { + "type": "boolean" + }, + "enableJsonFlag": { + "type": "boolean" + }, + "flags": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["boolean", "option"] + }, + "summary": { + "type": "string" + }, + "char": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "multiple": { + "type": "boolean" + }, + "helpValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "delimiter": { + "type": "string" + }, + "exclusive": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string" + } + }, + "exactlyOne": { + "type": "array", + "items": { + "type": "string" + } + }, + "atLeastOne": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "object", + "properties": { + "to": { + "type": "string" + }, + "message": { + "type": "string" + }, + "version": { + "type": ["string", "number"] + } + }, + "additionalProperties": false + } + ] + } + }, + "required": ["type"], + "additionalProperties": false + } + }, + "args": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "multiple": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "errorCodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["name", "description"], + "additionalProperties": false + } + } + }, + "required": [ + "command", + "requiresProject", + "requiresOrg", + "acceptsOrg", + "requiresDevHub", + "acceptsDevHub", + "enableJsonFlag", + "flags", + "args" + ], + "additionalProperties": false + }, + "CommandIndex": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "requiresProject": { + "type": "boolean" + }, + "requiresOrg": { + "type": "boolean" + }, + "acceptsOrg": { + "type": "boolean" + }, + "requiresDevHub": { + "type": "boolean" + }, + "acceptsDevHub": { + "type": "boolean" + } + }, + "required": ["id", "requiresProject", "requiresOrg", "acceptsOrg", "requiresDevHub", "acceptsDevHub"], + "additionalProperties": false + } + } + }, + "required": ["commands"], + "additionalProperties": false + } + } +} diff --git a/schemas/status.json b/schemas/status.json new file mode 100644 index 00000000..1f4b1fbe --- /dev/null +++ b/schemas/status.json @@ -0,0 +1,160 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/StatusResult", + "definitions": { + "StatusResult": { + "type": "object", + "properties": { + "config": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "location": { + "type": "string" + } + }, + "required": ["value", "location"], + "additionalProperties": false + } + }, + "targetOrg": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "alias": { + "type": "string" + }, + "orgId": { + "type": "string" + }, + "instanceUrl": { + "type": "string" + }, + "isScratchOrg": { + "type": "boolean" + }, + "isDevHub": { + "type": "boolean" + }, + "isSandbox": { + "type": "boolean" + }, + "tracksSource": { + "type": "boolean" + }, + "expirationDate": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["username"], + "additionalProperties": false + }, + "targetDevHub": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "alias": { + "type": "string" + }, + "orgId": { + "type": "string" + }, + "instanceUrl": { + "type": "string" + }, + "isScratchOrg": { + "type": "boolean" + }, + "isDevHub": { + "type": "boolean" + }, + "isSandbox": { + "type": "boolean" + }, + "tracksSource": { + "type": "boolean" + }, + "expirationDate": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["username"], + "additionalProperties": false + }, + "project": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "defaultPackagePath": { + "type": "string" + }, + "packageDirectories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "default": { + "type": "boolean" + } + }, + "required": ["path"], + "additionalProperties": false + } + }, + "namespace": { + "type": "string" + }, + "sourceApiVersion": { + "type": "string" + } + }, + "required": ["path", "packageDirectories"], + "additionalProperties": false + }, + "cli": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "node": { + "type": "string" + }, + "os": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "arch": { + "type": "string" + } + }, + "required": ["version", "node", "arch"], + "additionalProperties": false + } + }, + "required": ["config", "cli"], + "additionalProperties": false + } + } +} diff --git a/src/commands/schema.ts b/src/commands/schema.ts index bfb8e08c..b0e752dc 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -53,7 +53,7 @@ type ArgSchema = { multiple?: boolean; }; -type CommandSchema = { +export type CommandSchema = { command: string; summary?: string; requiresProject: boolean; @@ -67,7 +67,7 @@ type CommandSchema = { errorCodes?: Array<{ name: string; description: string }>; }; -type CommandIndex = { +export type CommandIndex = { commands: Array<{ id: string; summary?: string; @@ -79,6 +79,8 @@ type CommandIndex = { }>; }; +export type SchemaResult = CommandSchema | CommandIndex; + function addRelationships(schema: FlagSchema, flag: Command.Flag.Cached): void { if (flag.exclusive?.length) schema.exclusive = flag.exclusive; if (flag.dependsOn?.length) schema.dependsOn = flag.dependsOn; @@ -134,7 +136,7 @@ function getErrorCodes(cmd: Command.Loadable): Array<{ name: string; description ); } -export default class Schema extends SfCommand { +export default class Schema extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); @@ -151,7 +153,7 @@ export default class Schema extends SfCommand { return true; } - public async run(): Promise { + public async run(): Promise { const { flags } = await this.parse(Schema); if (flags.command) { diff --git a/src/commands/status.ts b/src/commands/status.ts index a41ce528..03f6ba2a 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -53,7 +53,7 @@ type ProjectInfo = { sourceApiVersion?: string; }; -type StatusResult = { +export type StatusResult = { config: Record; targetOrg?: OrgDetail; targetDevHub?: OrgDetail; From d9c6dbec0702e74c9b583d83a9a29d9f2207ec1c Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 15:17:32 -0600 Subject: [PATCH 4/4] fix: add oclif topic metadata for schema and status commands The command-reference generator requires topic entries in package.json's oclif.topics section. Add entries for the new schema and status commands. --- package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/package.json b/package.json index 3ef244ad..fb955465 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,12 @@ }, "doctor": { "description": "Tools for diagnosing problems with Salesforce CLI." + }, + "schema": { + "description": "Display the schema for CLI commands." + }, + "status": { + "description": "Display the current CLI environment context." } }, "flexibleTaxonomy": true,