Skip to content

Commit d1e6da8

Browse files
Copilothotlong
andcommitted
feat: add CLI command extension support for plugins
Plugins can now extend the ObjectStack CLI with custom commands by declaring `contributes.commands` in their manifest. The CLI dynamically discovers and loads plugin-contributed Commander.js commands at startup. Changes: - spec: Add `commands` contribution point to ManifestSchema.contributes - spec: Add CLICommandContributionSchema and CLIExtensionExportSchema - cli: Add plugin command discovery and loading (plugin-commands.ts) - cli: Update bin.ts to load plugin commands before parsing - tests: Add tests for new schemas and plugin command loading Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent bbbfc85 commit d1e6da8

9 files changed

Lines changed: 625 additions & 1 deletion

File tree

packages/cli/src/bin.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { initCommand } from './commands/init.js';
1717
import { infoCommand } from './commands/info.js';
1818
import { generateCommand } from './commands/generate.js';
1919
import { pluginCommand } from './commands/plugin.js';
20+
import { loadPluginCommands } from './utils/plugin-commands.js';
2021

2122
const require = createRequire(import.meta.url);
2223
const pkg = require('../package.json');
@@ -79,4 +80,15 @@ program.addCommand(pluginCommand);
7980
program.addCommand(testCommand);
8081
program.addCommand(doctorCommand);
8182

82-
program.parse(process.argv);
83+
// ── Plugin-Contributed Commands ──
84+
// Load commands from installed plugins that declare `contributes.commands` in their manifest.
85+
// This must complete before `program.parse()` so that plugin commands are available.
86+
loadPluginCommands(program).then(() => {
87+
program.parse(process.argv);
88+
}).catch((err) => {
89+
// If plugin command loading fails, still parse with built-in commands
90+
if (process.env.DEBUG) {
91+
console.error(chalk.yellow(`\n ⚠ Plugin command loading failed: ${err?.message || err}`));
92+
}
93+
program.parse(process.argv);
94+
});

packages/cli/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export * from './commands/dev.js';
1111
export * from './commands/serve.js';
1212
export * from './commands/test.js';
1313
export * from './commands/doctor.js';
14+
export * from './utils/plugin-commands.js';
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Command } from 'commander';
4+
import chalk from 'chalk';
5+
import { loadConfig } from './config.js';
6+
7+
/**
8+
* CLI Command Contribution resolved from a plugin manifest.
9+
*/
10+
interface ResolvedCommandContribution {
11+
/** CLI command name */
12+
name: string;
13+
/** Brief description */
14+
description?: string;
15+
/** Module path to import */
16+
module?: string;
17+
/** Source plugin package name */
18+
pluginName: string;
19+
}
20+
21+
/**
22+
* Discover CLI command contributions from installed plugins.
23+
*
24+
* Scans the project's `objectstack.config.ts` for plugins that declare
25+
* `contributes.commands` in their manifest, then dynamically imports
26+
* those plugin modules to register Commander.js commands.
27+
*
28+
* @param program - The root Commander.js program to register commands on
29+
*/
30+
export async function loadPluginCommands(program: Command): Promise<void> {
31+
let config: any;
32+
33+
try {
34+
const loaded = await loadConfig();
35+
config = loaded.config;
36+
} catch {
37+
// No config file found — nothing to load
38+
return;
39+
}
40+
41+
const plugins: unknown[] = [
42+
...(config.plugins || []),
43+
...(config.devPlugins || []),
44+
];
45+
46+
// Collect command contributions from plugin manifests
47+
const contributions: ResolvedCommandContribution[] = [];
48+
49+
for (const plugin of plugins) {
50+
if (!plugin || typeof plugin !== 'object') continue;
51+
const p = plugin as Record<string, unknown>;
52+
53+
const manifest = p.manifest as Record<string, unknown> | undefined;
54+
const contributes = (manifest?.contributes ?? p.contributes) as Record<string, unknown> | undefined;
55+
if (!contributes) continue;
56+
57+
const commands = contributes.commands as Array<Record<string, unknown>> | undefined;
58+
if (!Array.isArray(commands)) continue;
59+
60+
const pluginName = resolvePluginName(p);
61+
62+
for (const cmd of commands) {
63+
if (!cmd || typeof cmd.name !== 'string') continue;
64+
contributions.push({
65+
name: cmd.name,
66+
description: typeof cmd.description === 'string' ? cmd.description : undefined,
67+
module: typeof cmd.module === 'string' ? cmd.module : undefined,
68+
pluginName,
69+
});
70+
}
71+
}
72+
73+
if (contributions.length === 0) return;
74+
75+
// Load and register each contributed command
76+
for (const contribution of contributions) {
77+
try {
78+
const commands = await importPluginCommands(contribution);
79+
for (const cmd of commands) {
80+
program.addCommand(cmd);
81+
}
82+
} catch (error: any) {
83+
// Log warning but don't crash — plugin commands are optional
84+
if (process.env.DEBUG) {
85+
console.error(
86+
chalk.yellow(` ⚠ Failed to load CLI command '${contribution.name}' from plugin '${contribution.pluginName}': ${error.message}`)
87+
);
88+
}
89+
}
90+
}
91+
}
92+
93+
/**
94+
* Import Commander.js commands from a plugin module.
95+
*
96+
* The module must export commands in one of these forms:
97+
* - `export const commands: Command[]`
98+
* - `export default Command`
99+
* - `export default Command[]`
100+
*/
101+
async function importPluginCommands(
102+
contribution: ResolvedCommandContribution
103+
): Promise<Command[]> {
104+
// Resolve the module specifier
105+
const moduleId = contribution.module
106+
? `${contribution.pluginName}/${contribution.module.replace(/^\.\//, '')}`
107+
: contribution.pluginName;
108+
109+
const mod = await import(moduleId);
110+
111+
// Form 1: Named export `commands`
112+
if (Array.isArray(mod.commands)) {
113+
return mod.commands.filter(isCommandInstance);
114+
}
115+
116+
// Form 2: Default export (single or array)
117+
const defaultExport = mod.default;
118+
if (defaultExport) {
119+
if (Array.isArray(defaultExport)) {
120+
return defaultExport.filter(isCommandInstance);
121+
}
122+
if (isCommandInstance(defaultExport)) {
123+
return [defaultExport];
124+
}
125+
}
126+
127+
// Fallback: search for any Command instances in module exports
128+
const commands: Command[] = [];
129+
for (const key of Object.keys(mod)) {
130+
if (isCommandInstance(mod[key])) {
131+
commands.push(mod[key]);
132+
}
133+
}
134+
135+
return commands;
136+
}
137+
138+
/**
139+
* Check if a value is a Commander.js Command instance.
140+
* Uses duck-typing to avoid import dependency issues.
141+
*/
142+
function isCommandInstance(value: unknown): value is Command {
143+
return (
144+
value !== null &&
145+
typeof value === 'object' &&
146+
typeof (value as any).name === 'function' &&
147+
typeof (value as any).description === 'function' &&
148+
typeof (value as any).action === 'function' &&
149+
typeof (value as any).parse === 'function'
150+
);
151+
}
152+
153+
/**
154+
* Resolve a human-readable name from a plugin object.
155+
*/
156+
function resolvePluginName(plugin: Record<string, unknown>): string {
157+
if (typeof plugin.name === 'string') return plugin.name;
158+
const manifest = plugin.manifest as Record<string, unknown> | undefined;
159+
if (manifest && typeof manifest.name === 'string') return manifest.name;
160+
if (plugin.constructor && plugin.constructor.name !== 'Object') return plugin.constructor.name;
161+
return 'unknown';
162+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { Command } from 'commander';
3+
import { loadPluginCommands } from '../src/utils/plugin-commands';
4+
5+
// Mock the config loader
6+
vi.mock('../src/utils/config.js', () => ({
7+
loadConfig: vi.fn(),
8+
}));
9+
10+
import { loadConfig } from '../src/utils/config';
11+
12+
const mockedLoadConfig = vi.mocked(loadConfig);
13+
14+
describe('loadPluginCommands', () => {
15+
let program: Command;
16+
17+
beforeEach(() => {
18+
program = new Command();
19+
program.name('objectstack');
20+
vi.clearAllMocks();
21+
});
22+
23+
it('should do nothing when no config file exists', async () => {
24+
mockedLoadConfig.mockRejectedValue(new Error('No config found'));
25+
26+
await loadPluginCommands(program);
27+
expect(program.commands).toHaveLength(0);
28+
});
29+
30+
it('should do nothing when no plugins have command contributions', async () => {
31+
mockedLoadConfig.mockResolvedValue({
32+
config: {
33+
plugins: [
34+
{ name: 'simple-plugin' },
35+
],
36+
},
37+
absolutePath: '/test/objectstack.config.ts',
38+
duration: 10,
39+
});
40+
41+
await loadPluginCommands(program);
42+
expect(program.commands).toHaveLength(0);
43+
});
44+
45+
it('should do nothing when plugins array is empty', async () => {
46+
mockedLoadConfig.mockResolvedValue({
47+
config: {
48+
plugins: [],
49+
},
50+
absolutePath: '/test/objectstack.config.ts',
51+
duration: 10,
52+
});
53+
54+
await loadPluginCommands(program);
55+
expect(program.commands).toHaveLength(0);
56+
});
57+
58+
it('should do nothing when config has no plugins key', async () => {
59+
mockedLoadConfig.mockResolvedValue({
60+
config: {},
61+
absolutePath: '/test/objectstack.config.ts',
62+
duration: 10,
63+
});
64+
65+
await loadPluginCommands(program);
66+
expect(program.commands).toHaveLength(0);
67+
});
68+
69+
it('should detect command contributions from plugin manifest', async () => {
70+
// This test verifies that the contribution detection logic works,
71+
// even though the dynamic import will fail (no real module to load)
72+
mockedLoadConfig.mockResolvedValue({
73+
config: {
74+
plugins: [
75+
{
76+
name: '@acme/plugin-marketplace',
77+
manifest: {
78+
contributes: {
79+
commands: [
80+
{
81+
name: 'marketplace',
82+
description: 'Manage marketplace apps',
83+
module: './cli',
84+
},
85+
],
86+
},
87+
},
88+
},
89+
],
90+
},
91+
absolutePath: '/test/objectstack.config.ts',
92+
duration: 10,
93+
});
94+
95+
// loadPluginCommands will try to import the module and fail silently
96+
// (since no real module exists), but it should not throw
97+
await loadPluginCommands(program);
98+
});
99+
100+
it('should detect contributions from top-level contributes field', async () => {
101+
mockedLoadConfig.mockResolvedValue({
102+
config: {
103+
plugins: [
104+
{
105+
name: '@acme/plugin-deploy',
106+
contributes: {
107+
commands: [
108+
{
109+
name: 'deploy',
110+
description: 'Deploy to cloud',
111+
},
112+
],
113+
},
114+
},
115+
],
116+
},
117+
absolutePath: '/test/objectstack.config.ts',
118+
duration: 10,
119+
});
120+
121+
// Should not throw even if import fails
122+
await loadPluginCommands(program);
123+
});
124+
125+
it('should skip non-object plugins', async () => {
126+
mockedLoadConfig.mockResolvedValue({
127+
config: {
128+
plugins: [
129+
'some-string-plugin',
130+
null,
131+
undefined,
132+
42,
133+
],
134+
},
135+
absolutePath: '/test/objectstack.config.ts',
136+
duration: 10,
137+
});
138+
139+
await loadPluginCommands(program);
140+
expect(program.commands).toHaveLength(0);
141+
});
142+
143+
it('should handle plugins with non-array commands gracefully', async () => {
144+
mockedLoadConfig.mockResolvedValue({
145+
config: {
146+
plugins: [
147+
{
148+
name: '@acme/bad-plugin',
149+
contributes: {
150+
commands: 'not-an-array',
151+
},
152+
},
153+
],
154+
},
155+
absolutePath: '/test/objectstack.config.ts',
156+
duration: 10,
157+
});
158+
159+
await loadPluginCommands(program);
160+
expect(program.commands).toHaveLength(0);
161+
});
162+
});

0 commit comments

Comments
 (0)