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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-12 22:27:33 +01:00
parent 28a438a9f2
commit a90939e138
4 changed files with 146 additions and 59 deletions
@@ -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'],
});
+9
View File
@@ -15,3 +15,12 @@
*/
export { OpaqueCliPlugin } from './InternalCliPlugin';
export type {
CommandNode,
CommandTreeNode,
CommandLeafNode,
} from './InternalCommandNode';
export {
OpaqueCommandTreeNode,
OpaqueCommandLeafNode,
} from './InternalCommandNode';
+29 -23
View File
@@ -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'
+54 -36
View File
@@ -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;