From a90939e13820551f24929768d8eca986dea17d0f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Mar 2026 22:27:33 +0100 Subject: [PATCH] Use opaque types for command graph nodes Switch CommandGraph and CliInitializer to use OpaqueCommandTreeNode and OpaqueCommandLeafNode from @internal/cli instead of raw $$type markers. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../cli-internal/src/InternalCommandNode.ts | 54 +++++++++++ packages/cli-internal/src/index.ts | 9 ++ packages/cli/src/wiring/CliInitializer.ts | 52 ++++++----- packages/cli/src/wiring/CommandGraph.ts | 90 +++++++++++-------- 4 files changed, 146 insertions(+), 59 deletions(-) create mode 100644 packages/cli-internal/src/InternalCommandNode.ts diff --git a/packages/cli-internal/src/InternalCommandNode.ts b/packages/cli-internal/src/InternalCommandNode.ts new file mode 100644 index 0000000000..fec79ffb8e --- /dev/null +++ b/packages/cli-internal/src/InternalCommandNode.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { BackstageCommand } from '@backstage/cli-plugin-api'; +import { OpaqueType } from '@internal/opaque'; + +/** @internal */ +export interface CommandTreeNode { + readonly $$type: '@backstage/CommandTreeNode'; +} + +/** @internal */ +export interface CommandLeafNode { + readonly $$type: '@backstage/CommandLeafNode'; +} + +export type CommandNode = CommandTreeNode | CommandLeafNode; + +export const OpaqueCommandTreeNode = OpaqueType.create<{ + public: CommandTreeNode; + versions: { + readonly version: 'v1'; + readonly name: string; + readonly children: CommandNode[]; + }; +}>({ + type: '@backstage/CommandTreeNode', + versions: ['v1'], +}); + +export const OpaqueCommandLeafNode = OpaqueType.create<{ + public: CommandLeafNode; + versions: { + readonly version: 'v1'; + readonly name: string; + readonly command: BackstageCommand; + }; +}>({ + type: '@backstage/CommandLeafNode', + versions: ['v1'], +}); diff --git a/packages/cli-internal/src/index.ts b/packages/cli-internal/src/index.ts index a1dc8ad24c..578333ee96 100644 --- a/packages/cli-internal/src/index.ts +++ b/packages/cli-internal/src/index.ts @@ -15,3 +15,12 @@ */ export { OpaqueCliPlugin } from './InternalCliPlugin'; +export type { + CommandNode, + CommandTreeNode, + CommandLeafNode, +} from './InternalCommandNode'; +export { + OpaqueCommandTreeNode, + OpaqueCommandLeafNode, +} from './InternalCommandNode'; diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index f63dc330e8..ad9c483d33 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -15,8 +15,13 @@ */ import { CommandGraph } from './CommandGraph'; -import { OpaqueCliPlugin } from '@internal/cli'; -import type { BackstageCommand, CliPlugin } from '@backstage/cli-plugin-api'; +import { + OpaqueCliPlugin, + OpaqueCommandTreeNode, + OpaqueCommandLeafNode, +} from '@internal/cli'; +import type { CommandNode } from '@internal/cli'; +import type { CliPlugin } from '@backstage/cli-plugin-api'; import { CommandRegistry } from './CommandRegistry'; import { Command } from 'commander'; import { version } from './version'; @@ -25,15 +30,13 @@ import { exitWithError } from './errors'; import { ForwardedError } from '@backstage/errors'; import { isPromise } from 'node:util/types'; -function isNodeHidden( - node: - | { $$type: '@tree/leaf'; command: BackstageCommand } - | { $$type: '@tree/root'; children: unknown[] }, -): boolean { - if (node.$$type === '@tree/leaf') { - return !!node.command.deprecated || !!node.command.experimental; +function isNodeHidden(node: CommandNode): boolean { + if (OpaqueCommandLeafNode.isType(node)) { + const { command } = OpaqueCommandLeafNode.toInternal(node); + return !!command.deprecated || !!command.experimental; } - return node.children.every(child => isNodeHidden(child as any)); + const { children } = OpaqueCommandTreeNode.toInternal(node); + return children.every(child => isNodeHidden(child)); } type UninitializedFeature = CliPlugin | Promise<{ default: CliPlugin }>; @@ -92,25 +95,28 @@ export class CliInitializer { })); while (queue.length) { const { node, argParser } = queue.shift()!; - if (node.$$type === '@tree/root') { + if (OpaqueCommandTreeNode.isType(node)) { + const internal = OpaqueCommandTreeNode.toInternal(node); const treeParser = argParser - .command(`${node.name} [command]`, { + .command(`${internal.name} [command]`, { hidden: isNodeHidden(node), }) - .description(node.name); + .description(internal.name); queue.push( - ...node.children.map(child => ({ + ...internal.children.map(child => ({ node: child, argParser: treeParser, })), ); } else { + const internal = OpaqueCommandLeafNode.toInternal(node); argParser - .command(node.name, { - hidden: !!node.command.deprecated || !!node.command.experimental, + .command(internal.name, { + hidden: + !!internal.command.deprecated || !!internal.command.experimental, }) - .description(node.command.description) + .description(internal.command.description) .helpOption(false) .allowUnknownOption(true) .allowExcessArguments(true) @@ -129,7 +135,7 @@ export class CliInitializer { // Skip the command name if ( argIndex === index && - node.command.path[argIndex] === nonProcessArgs[argIndex] + internal.command.path[argIndex] === nonProcessArgs[argIndex] ) { index += 1; continue; @@ -139,15 +145,15 @@ export class CliInitializer { const context = { args: [...positionalArgs, ...args.unknown], info: { - usage: [programName, ...node.command.path].join(' '), - name: node.command.path.join(' '), + usage: [programName, ...internal.command.path].join(' '), + name: internal.command.path.join(' '), }, }; - if (typeof node.command.execute === 'function') { - await node.command.execute(context); + if (typeof internal.command.execute === 'function') { + await internal.command.execute(context); } else { - const mod = await node.command.execute.loader(); + const mod = await internal.command.execute.loader(); // Handle CJS double-wrapping of default exports const fn = typeof mod.default === 'function' diff --git a/packages/cli/src/wiring/CommandGraph.ts b/packages/cli/src/wiring/CommandGraph.ts index a0a2de3305..d64d14b41c 100644 --- a/packages/cli/src/wiring/CommandGraph.ts +++ b/packages/cli/src/wiring/CommandGraph.ts @@ -13,27 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { + CommandNode, + OpaqueCommandTreeNode, + OpaqueCommandLeafNode, +} from '@internal/cli'; import { BackstageCommand } from './types'; -type Node = TreeNode | LeafNode; - -interface TreeNode { - $$type: '@tree/root'; - name: string; - children: TreeNode[]; -} - -interface LeafNode { - $$type: '@tree/leaf'; - name: string; - command: BackstageCommand; -} - /** * A sparse graph of commands. */ export class CommandGraph { - private graph: Node[] = []; + private graph: CommandNode[] = []; /** * Adds a command to the graph. The graph is sparse, so we use the path to determine the nodes @@ -44,28 +35,44 @@ export class CommandGraph { let current = this.graph; for (let i = 0; i < path.length - 1; i++) { const name = path[i]; - let next = current.find(n => n.name === name); + let next = current.find( + n => + (OpaqueCommandTreeNode.isType(n) && + OpaqueCommandTreeNode.toInternal(n).name === name) || + (OpaqueCommandLeafNode.isType(n) && + OpaqueCommandLeafNode.toInternal(n).name === name), + ); if (!next) { - next = { $$type: '@tree/root', name, children: [] }; + next = OpaqueCommandTreeNode.createInstance('v1', { + name, + children: [], + }); current.push(next); - } else if (next.$$type === '@tree/leaf') { + } else if (OpaqueCommandLeafNode.isType(next)) { throw new Error( `Command already exists at path: "${path.slice(0, i).join(' ')}"`, ); } - current = next.children; + current = OpaqueCommandTreeNode.toInternal(next).children; } - const last = current.find(n => n.name === path[path.length - 1]); - if (last && last.$$type === '@tree/leaf') { + const lastName = path[path.length - 1]; + const last = current.find(n => { + if (OpaqueCommandTreeNode.isType(n)) { + return OpaqueCommandTreeNode.toInternal(n).name === lastName; + } + return OpaqueCommandLeafNode.toInternal(n).name === lastName; + }); + if (last && OpaqueCommandLeafNode.isType(last)) { throw new Error( `Command already exists at path: "${path.slice(0, -1).join(' ')}"`, ); } else { - current.push({ - $$type: '@tree/leaf', - name: path[path.length - 1], - command, - }); + current.push( + OpaqueCommandLeafNode.createInstance('v1', { + name: lastName, + command, + }), + ); } } @@ -76,26 +83,37 @@ export class CommandGraph { let current = this.graph; for (let i = 0; i < path.length - 1; i++) { const name = path[i]; - const next = current.find(n => n.name === name); - if (!next) { - return undefined; - } else if (next.$$type === '@tree/leaf') { + const next = current.find(n => { + if (OpaqueCommandTreeNode.isType(n)) { + return OpaqueCommandTreeNode.toInternal(n).name === name; + } + return OpaqueCommandLeafNode.toInternal(n).name === name; + }); + if (!next || OpaqueCommandLeafNode.isType(next)) { return undefined; } - current = next.children; + current = OpaqueCommandTreeNode.toInternal(next).children; } - const last = current.find(n => n.name === path[path.length - 1]); - if (!last || last.$$type === '@tree/root') { + const lastName = path[path.length - 1]; + const last = current.find(n => { + if (OpaqueCommandTreeNode.isType(n)) { + return OpaqueCommandTreeNode.toInternal(n).name === lastName; + } + return OpaqueCommandLeafNode.toInternal(n).name === lastName; + }); + if (!last || OpaqueCommandTreeNode.isType(last)) { return undefined; } - return last?.command; + return OpaqueCommandLeafNode.toInternal(last).command; } - atDepth(depth: number): Node[] { + atDepth(depth: number): CommandNode[] { let current = this.graph; for (let i = 0; i < depth; i++) { current = current.flatMap(n => - n.$$type === '@tree/root' ? n.children : [], + OpaqueCommandTreeNode.isType(n) + ? OpaqueCommandTreeNode.toInternal(n).children + : [], ); } return current;