@objectstack/cli 2.0.6 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,163 @@
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: Record<string, unknown>;
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 as unknown[] | undefined) || []),
43
+ ...((config.devPlugins as unknown[] | undefined) || []),
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: unknown) {
83
+ // Log warning but don't crash — plugin commands are optional
84
+ if (process.env.DEBUG) {
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ console.error(
87
+ chalk.yellow(` ⚠ Failed to load CLI command '${contribution.name}' from plugin '${contribution.pluginName}': ${message}`)
88
+ );
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Import Commander.js commands from a plugin module.
96
+ *
97
+ * The module must export commands in one of these forms:
98
+ * - `export const commands: Command[]`
99
+ * - `export default Command`
100
+ * - `export default Command[]`
101
+ */
102
+ async function importPluginCommands(
103
+ contribution: ResolvedCommandContribution
104
+ ): Promise<Command[]> {
105
+ // Resolve the module specifier
106
+ const moduleId = contribution.module
107
+ ? `${contribution.pluginName}/${contribution.module.replace(/^\.\//, '')}`
108
+ : contribution.pluginName;
109
+
110
+ const mod = await import(moduleId);
111
+
112
+ // Form 1: Named export `commands`
113
+ if (Array.isArray(mod.commands)) {
114
+ return mod.commands.filter(isCommandInstance);
115
+ }
116
+
117
+ // Form 2: Default export (single or array)
118
+ const defaultExport = mod.default;
119
+ if (defaultExport) {
120
+ if (Array.isArray(defaultExport)) {
121
+ return defaultExport.filter(isCommandInstance);
122
+ }
123
+ if (isCommandInstance(defaultExport)) {
124
+ return [defaultExport];
125
+ }
126
+ }
127
+
128
+ // Fallback: search for any Command instances in module exports
129
+ const commands: Command[] = [];
130
+ for (const key of Object.keys(mod)) {
131
+ if (isCommandInstance(mod[key])) {
132
+ commands.push(mod[key]);
133
+ }
134
+ }
135
+
136
+ return commands;
137
+ }
138
+
139
+ /**
140
+ * Check if a value is a Commander.js Command instance.
141
+ * Uses duck-typing to avoid import dependency issues.
142
+ */
143
+ function isCommandInstance(value: unknown): value is Command {
144
+ if (value === null || typeof value !== 'object') return false;
145
+ const obj = value as Record<string, unknown>;
146
+ return (
147
+ typeof obj.name === 'function' &&
148
+ typeof obj.description === 'function' &&
149
+ typeof obj.action === 'function' &&
150
+ typeof obj.parse === 'function'
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Resolve a human-readable name from a plugin object.
156
+ */
157
+ function resolvePluginName(plugin: Record<string, unknown>): string {
158
+ if (typeof plugin.name === 'string') return plugin.name;
159
+ const manifest = plugin.manifest as Record<string, unknown> | undefined;
160
+ if (manifest && typeof manifest.name === 'string') return manifest.name;
161
+ if (plugin.constructor && plugin.constructor.name !== 'Object') return plugin.constructor.name;
162
+ return 'unknown';
163
+ }
@@ -9,6 +9,7 @@ import { validateCommand } from '../src/commands/validate';
9
9
  import { initCommand } from '../src/commands/init';
10
10
  import { infoCommand } from '../src/commands/info';
11
11
  import { generateCommand } from '../src/commands/generate';
12
+ import { pluginCommand } from '../src/commands/plugin';
12
13
 
13
14
  describe('CLI Commands', () => {
14
15
  it('should have compile command', () => {
@@ -61,4 +62,9 @@ describe('CLI Commands', () => {
61
62
  expect(generateCommand.alias()).toBe('g');
62
63
  expect(generateCommand.description()).toContain('Generate');
63
64
  });
65
+
66
+ it('should have plugin command with subcommands', () => {
67
+ expect(pluginCommand.name()).toBe('plugin');
68
+ expect(pluginCommand.description()).toContain('plugin');
69
+ });
64
70
  });
@@ -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,0 +1,173 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { pluginCommand } from '../src/commands/plugin';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ describe('Plugin Command', () => {
8
+ it('should have plugin command with subcommands', () => {
9
+ expect(pluginCommand.name()).toBe('plugin');
10
+ expect(pluginCommand.description()).toContain('plugin');
11
+ });
12
+
13
+ it('should have list subcommand with alias', () => {
14
+ const list = pluginCommand.commands.find(c => c.name() === 'list');
15
+ expect(list).toBeDefined();
16
+ expect(list!.alias()).toBe('ls');
17
+ expect(list!.description()).toContain('List');
18
+ });
19
+
20
+ it('should have info subcommand', () => {
21
+ const info = pluginCommand.commands.find(c => c.name() === 'info');
22
+ expect(info).toBeDefined();
23
+ expect(info!.description()).toContain('information');
24
+ });
25
+
26
+ it('should have add subcommand', () => {
27
+ const add = pluginCommand.commands.find(c => c.name() === 'add');
28
+ expect(add).toBeDefined();
29
+ expect(add!.description()).toContain('Add');
30
+ });
31
+
32
+ it('should have remove subcommand with alias', () => {
33
+ const remove = pluginCommand.commands.find(c => c.name() === 'remove');
34
+ expect(remove).toBeDefined();
35
+ expect(remove!.alias()).toBe('rm');
36
+ expect(remove!.description()).toContain('Remove');
37
+ });
38
+ });
39
+
40
+ describe('Plugin Config Manipulation', () => {
41
+ let tmpDir: string;
42
+ let configPath: string;
43
+
44
+ beforeEach(() => {
45
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-plugin-test-'));
46
+ configPath = path.join(tmpDir, 'objectstack.config.ts');
47
+ });
48
+
49
+ afterEach(() => {
50
+ fs.rmSync(tmpDir, { recursive: true, force: true });
51
+ });
52
+
53
+ describe('addPluginToConfig (via file manipulation)', () => {
54
+ it('should add plugin import and entry to config with existing plugins array', () => {
55
+ fs.writeFileSync(configPath, `import { defineStack } from '@objectstack/spec';
56
+
57
+ export default defineStack({
58
+ manifest: {
59
+ name: 'test-app',
60
+ version: '1.0.0',
61
+ },
62
+ plugins: [
63
+ ],
64
+ });
65
+ `);
66
+
67
+ // Simulate what the add command does
68
+ let content = fs.readFileSync(configPath, 'utf-8');
69
+ const packageName = '@objectstack/plugin-auth';
70
+ const varName = 'authPlugin';
71
+ const importLine = `import ${varName} from '${packageName}';\n`;
72
+
73
+ // Add import after last import
74
+ const importRegex = /^import .+$/gm;
75
+ let lastImportEnd = 0;
76
+ let match: RegExpExecArray | null;
77
+ while ((match = importRegex.exec(content)) !== null) {
78
+ lastImportEnd = match.index + match[0].length;
79
+ }
80
+ content = content.slice(0, lastImportEnd) + '\n' + importLine + content.slice(lastImportEnd);
81
+
82
+ // Add to plugins array
83
+ content = content.replace(
84
+ /(plugins\s*:\s*\[)/,
85
+ `$1\n ${varName},`
86
+ );
87
+
88
+ fs.writeFileSync(configPath, content);
89
+
90
+ const result = fs.readFileSync(configPath, 'utf-8');
91
+ expect(result).toContain("import authPlugin from '@objectstack/plugin-auth'");
92
+ expect(result).toContain('authPlugin,');
93
+ });
94
+
95
+ it('should add plugin to config without existing plugins array', () => {
96
+ fs.writeFileSync(configPath, `import { defineStack } from '@objectstack/spec';
97
+
98
+ export default defineStack({
99
+ manifest: {
100
+ name: 'test-app',
101
+ version: '1.0.0',
102
+ },
103
+ });
104
+ `);
105
+
106
+ let content = fs.readFileSync(configPath, 'utf-8');
107
+ const packageName = '@objectstack/plugin-security';
108
+ const varName = 'securityPlugin';
109
+ const importLine = `import ${varName} from '${packageName}';\n`;
110
+
111
+ // Add import
112
+ const importRegex = /^import .+$/gm;
113
+ let lastImportEnd = 0;
114
+ let match: RegExpExecArray | null;
115
+ while ((match = importRegex.exec(content)) !== null) {
116
+ lastImportEnd = match.index + match[0].length;
117
+ }
118
+ content = content.slice(0, lastImportEnd) + '\n' + importLine + content.slice(lastImportEnd);
119
+
120
+ // Add plugins array
121
+ if (!/plugins\s*:\s*\[/.test(content)) {
122
+ content = content.replace(
123
+ /(defineStack\(\{[\s\S]*?)(}\s*\))/,
124
+ `$1 plugins: [\n ${varName},\n ],\n$2`
125
+ );
126
+ }
127
+
128
+ fs.writeFileSync(configPath, content);
129
+
130
+ const result = fs.readFileSync(configPath, 'utf-8');
131
+ expect(result).toContain("import securityPlugin from '@objectstack/plugin-security'");
132
+ expect(result).toContain('plugins: [');
133
+ expect(result).toContain('securityPlugin,');
134
+ });
135
+ });
136
+
137
+ describe('removePluginFromConfig (via file manipulation)', () => {
138
+ it('should remove plugin import and entry from config', () => {
139
+ fs.writeFileSync(configPath, `import { defineStack } from '@objectstack/spec';
140
+ import authPlugin from '@objectstack/plugin-auth';
141
+
142
+ export default defineStack({
143
+ manifest: {
144
+ name: 'test-app',
145
+ version: '1.0.0',
146
+ },
147
+ plugins: [
148
+ authPlugin,
149
+ ],
150
+ });
151
+ `);
152
+
153
+ let content = fs.readFileSync(configPath, 'utf-8');
154
+ const packageName = '@objectstack/plugin-auth';
155
+
156
+ // Remove import
157
+ const importRegex = new RegExp(`^import .+['"]${packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"].*$\\n?`, 'gm');
158
+ content = content.replace(importRegex, '');
159
+
160
+ // Remove from plugins array
161
+ content = content.replace(/\s*authPlugin,?\n?/g, '\n');
162
+
163
+ fs.writeFileSync(configPath, content);
164
+
165
+ const result = fs.readFileSync(configPath, 'utf-8');
166
+ expect(result).not.toContain('@objectstack/plugin-auth');
167
+ expect(result).not.toContain('authPlugin');
168
+ // The rest of the config should remain intact
169
+ expect(result).toContain("import { defineStack } from '@objectstack/spec'");
170
+ expect(result).toContain('manifest');
171
+ });
172
+ });
173
+ });