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
+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;