feat: CLI command extension protocol for plugins#607
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
- Replace `any` with `Record<string, unknown>` for config type - Replace `catch (error: any)` with `catch (error: unknown)` and proper narrowing - Refactor isCommandInstance to use intermediate typed variable instead of repeated `as any` Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
|
@copilot 更新软件包文档和官网文档,编写例子 |
- Update guides/plugins.mdx with comprehensive CLI extension section including architecture diagram, step-by-step guide, and full examples (marketplace plugin, deployment plugin) - Add reference page for cli-extension schemas (content/docs/references/kernel/cli-extension.mdx) - Update kernel meta.json to include cli-extension page - Update @objectstack/cli README with plugin CLI extension docs - Update directory structure convention to include cli.ts Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Done in 2cb3d63. Added:
|
There was a problem hiding this comment.
Pull request overview
Introduces a plugin CLI command extension protocol across @objectstack/spec (manifest + kernel schemas) and @objectstack/cli (runtime command discovery/loading), with corresponding tests and documentation/reference updates.
Changes:
- Add
contributes.commandstoManifestSchemaplus new kernel schemas (CLICommandContributionSchema,CLIExtensionExportSchema) and tests. - Implement CLI-side
loadPluginCommands()and wire it intobin.ts, with tests and package exports. - Expand docs/guides and reference indexes to document CLI extensions and add missing reference pages (e.g., UI i18n, API realtime-shared).
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/spec/src/kernel/manifest.zod.ts | Adds contributes.commands manifest contribution point. |
| packages/spec/src/kernel/manifest.test.ts | Tests acceptance/rejection of CLI command contributions in manifests. |
| packages/spec/src/kernel/index.ts | Exports new kernel CLI extension schemas. |
| packages/spec/src/kernel/cli-extension.zod.ts | Adds CLI extension protocol schemas/types. |
| packages/spec/src/kernel/cli-extension.test.ts | Adds tests for new CLI extension schemas. |
| packages/cli/src/utils/plugin-commands.ts | Implements discovery + dynamic import of plugin-contributed Commander commands. |
| packages/cli/test/plugin-commands.test.ts | Adds unit tests for plugin command loading behavior. |
| packages/cli/src/bin.ts | Loads plugin commands before program.parse(). |
| packages/cli/src/index.ts | Re-exports loadPluginCommands. |
| packages/cli/README.md | Documents how plugins can extend the CLI. |
| content/docs/guides/plugins.mdx | Adds a CLI Command Extensions guide section + directory convention update. |
| content/docs/references/kernel/* | Adds CLI extension reference page + navigation entries. |
| content/docs/references/ui/* | Adds i18n reference page and updates related reference pages/indexes. |
| content/docs/references/api/* | Adds realtime-shared reference page and navigation entries. |
| import { BasePresence, PresenceStatus, RealtimeRecordAction } from '@objectstack/spec/api'; | ||
| import type { BasePresence, PresenceStatus, RealtimeRecordAction } from '@objectstack/spec/api'; | ||
|
|
||
| // Validate data | ||
| const result = BasePresence.parse(data); |
There was a problem hiding this comment.
The TypeScript usage snippet imports BasePresence as a runtime value and calls BasePresence.parse(...), but the runtime Zod schema is BasePresenceSchema (and BasePresence is a type alias). Update the snippet to import/use BasePresenceSchema for validation, and keep BasePresence as a type-only import.
| import { BasePresence, PresenceStatus, RealtimeRecordAction } from '@objectstack/spec/api'; | |
| import type { BasePresence, PresenceStatus, RealtimeRecordAction } from '@objectstack/spec/api'; | |
| // Validate data | |
| const result = BasePresence.parse(data); | |
| import { BasePresenceSchema } from '@objectstack/spec/api'; | |
| import type { BasePresence, PresenceStatus, RealtimeRecordAction } from '@objectstack/spec/api'; | |
| // Validate data | |
| const result = BasePresenceSchema.parse(data); |
| try { | ||
| const loaded = await loadConfig(); | ||
| config = loaded.config; | ||
| } catch { | ||
| // No config file found — nothing to load | ||
| return; | ||
| } |
There was a problem hiding this comment.
loadPluginCommands calls loadConfig() unconditionally. The current loadConfig/resolveConfigPath implementation exits the process (process.exit(1)) when no config is found, which means running os --help (or any CLI command) outside a project directory could terminate before parsing arguments. To make “no config file found — nothing to load” actually work, use a non-exiting config probe (e.g., check for config existence first, or add a tryLoadConfig helper that returns undefined instead of calling process.exit).
| for (const plugin of plugins) { | ||
| if (!plugin || typeof plugin !== 'object') continue; | ||
| const p = plugin as Record<string, unknown>; | ||
|
|
||
| const manifest = p.manifest as Record<string, unknown> | undefined; | ||
| const contributes = (manifest?.contributes ?? p.contributes) as Record<string, unknown> | undefined; | ||
| if (!contributes) continue; |
There was a problem hiding this comment.
This code ignores string plugin entries (typeof plugin !== 'object'), but the stack definition schema explicitly allows plugins to be specified as package-name strings. As a result, CLI command contributions won’t be discovered for configs that list plugins as strings. Handle typeof plugin === 'string' by importing the package and reading its manifest/contributions (or otherwise resolving its manifest) before scanning contributes.commands.
| * Resolve a human-readable name from a plugin object. | ||
| */ | ||
| function resolvePluginName(plugin: Record<string, unknown>): string { | ||
| if (typeof plugin.name === 'string') return plugin.name; | ||
| const manifest = plugin.manifest as Record<string, unknown> | undefined; | ||
| if (manifest && typeof manifest.name === 'string') return manifest.name; | ||
| if (plugin.constructor && plugin.constructor.name !== 'Object') return plugin.constructor.name; |
There was a problem hiding this comment.
resolvePluginName() falls back to manifest.name / constructor name, but pluginName is later used as an ESM module specifier for dynamic import(). Human-readable names like "Marketplace Plugin" are not valid module IDs, so command loading will silently fail for common plugin objects (e.g., those imported via os plugin add). Consider storing an explicit packageName/module field in config entries, or deriving the import specifier from string plugin entries, and avoid using manifest.name as an import target.
| * Resolve a human-readable name from a plugin object. | |
| */ | |
| function resolvePluginName(plugin: Record<string, unknown>): string { | |
| if (typeof plugin.name === 'string') return plugin.name; | |
| const manifest = plugin.manifest as Record<string, unknown> | undefined; | |
| if (manifest && typeof manifest.name === 'string') return manifest.name; | |
| if (plugin.constructor && plugin.constructor.name !== 'Object') return plugin.constructor.name; | |
| * Resolve a stable, machine-oriented plugin identifier. | |
| * | |
| * This value may be used as a package / module specifier, so it must | |
| * prefer explicit technical identifiers over human-readable labels. | |
| */ | |
| function resolvePluginName(plugin: Record<string, unknown>): string { | |
| // Prefer an explicit package or module identifier if present | |
| const pkgName = (plugin as { packageName?: unknown }).packageName; | |
| if (typeof pkgName === 'string' && pkgName.trim() !== '') { | |
| return pkgName; | |
| } | |
| const moduleId = (plugin as { module?: unknown }).module; | |
| if (typeof moduleId === 'string' && moduleId.trim() !== '') { | |
| return moduleId; | |
| } | |
| // Fallback to `name` only; avoid manifest names or constructor names, | |
| // which are typically human-readable and not valid module specifiers. | |
| if (typeof plugin.name === 'string' && plugin.name.trim() !== '') { | |
| return plugin.name; | |
| } |
| commands: z.array(z.object({ | ||
| /** CLI command name (e.g., "marketplace", "deploy"). Must be a valid CLI identifier. */ | ||
| name: z.string() | ||
| .regex(/^[a-z][a-z0-9-]*$/, 'Command name must be lowercase alphanumeric with hyphens') | ||
| .describe('CLI command name'), | ||
| /** Brief description shown in `os --help` */ | ||
| description: z.string().optional().describe('Command description for help text'), | ||
| /** | ||
| * Optional module path (relative to package root) that exports the Commander.js commands. | ||
| * If omitted, the CLI will import from the package's main entry point. | ||
| * The module must export a `commands` array of Commander.js `Command` instances, | ||
| * or a single `Command` instance as default export. | ||
| */ | ||
| module: z.string().optional().describe('Module path exporting Commander.js commands'), | ||
| })).optional().describe('CLI command contributions'), |
There was a problem hiding this comment.
ManifestSchema defines the contributes.commands entry shape inline, but this PR also introduces CLICommandContributionSchema in cli-extension.zod.ts with the same fields. Reusing the shared schema here (instead of duplicating) would prevent the manifest and protocol schema from drifting over time.
| import { CLICommandContribution, CLIExtensionExport } from '@objectstack/spec/kernel'; | ||
| import type { CLICommandContribution, CLIExtensionExport } from '@objectstack/spec/kernel'; | ||
|
|
||
| // Validate data | ||
| const result = CLICommandContribution.parse(data); |
There was a problem hiding this comment.
The TypeScript usage snippet imports CLICommandContribution / CLIExtensionExport as runtime values and calls CLICommandContribution.parse(...), but only CLICommandContributionSchema / CLIExtensionExportSchema are runtime Zod schemas. Update the snippet to import and use the *Schema exports for .parse(...), and import the non-Schema names as type only.
| import { CLICommandContribution, CLIExtensionExport } from '@objectstack/spec/kernel'; | |
| import type { CLICommandContribution, CLIExtensionExport } from '@objectstack/spec/kernel'; | |
| // Validate data | |
| const result = CLICommandContribution.parse(data); | |
| import { CLICommandContributionSchema, CLIExtensionExportSchema } from '@objectstack/spec/kernel'; | |
| import type { CLICommandContribution, CLIExtensionExport } from '@objectstack/spec/kernel'; | |
| // Validate data | |
| const result = CLICommandContributionSchema.parse(data); |
| // Load and register each contributed command | ||
| for (const contribution of contributions) { | ||
| try { | ||
| const commands = await importPluginCommands(contribution); | ||
| for (const cmd of commands) { |
There was a problem hiding this comment.
loadPluginCommands imports and registers commands once per manifest entry. If a plugin contributes multiple commands that point to the same module (or the module exports multiple commands), this loop will import the same module repeatedly and can register the same Command instances multiple times, leading to duplicate/ambiguous commands in Commander. Consider de-duping by moduleId (import once per module) and/or de-duping added commands by cmd.name() before calling program.addCommand().
| // Load and register each contributed command | |
| for (const contribution of contributions) { | |
| try { | |
| const commands = await importPluginCommands(contribution); | |
| for (const cmd of commands) { | |
| const moduleCommandsCache = new Map<string, Command[]>(); | |
| const registeredCommandNames = new Set<string>(); | |
| // Load and register each contributed command | |
| for (const contribution of contributions) { | |
| try { | |
| const moduleKey = contribution.module ?? `${contribution.pluginName}:${contribution.name}`; | |
| let commands: Command[]; | |
| if (moduleCommandsCache.has(moduleKey)) { | |
| commands = moduleCommandsCache.get(moduleKey)!; | |
| } else { | |
| commands = await importPluginCommands(contribution); | |
| moduleCommandsCache.set(moduleKey, commands); | |
| } | |
| for (const cmd of commands) { | |
| const commandName = cmd.name(); | |
| if (registeredCommandNames.has(commandName)) { | |
| continue; | |
| } | |
| registeredCommandNames.add(commandName); |
| // Resolve the module specifier | ||
| const moduleId = contribution.module | ||
| ? `${contribution.pluginName}/${contribution.module.replace(/^\.\//, '')}` | ||
| : contribution.pluginName; |
There was a problem hiding this comment.
contribution.module is concatenated into an import specifier with only a leading ./ stripped. Values like ../other or absolute paths could escape the intended package subpath (or at least produce surprising resolution). Since this value comes from project config/manifest, it should be validated/sanitized (e.g., reject paths containing .., backslashes, or starting with /).
| import { AriaProps, I18nLabel, I18nObject } from '@objectstack/spec/ui'; | ||
| import type { AriaProps, I18nLabel, I18nObject } from '@objectstack/spec/ui'; | ||
|
|
||
| // Validate data | ||
| const result = AriaProps.parse(data); |
There was a problem hiding this comment.
The TypeScript usage snippet imports AriaProps / I18nLabel / I18nObject as runtime values and calls AriaProps.parse(...), but the runtime Zod schemas are AriaPropsSchema, I18nLabelSchema, and I18nObjectSchema. Update the snippet to use the *Schema exports for parsing, and keep the non-Schema names as type-only imports.
| import { AriaProps, I18nLabel, I18nObject } from '@objectstack/spec/ui'; | |
| import type { AriaProps, I18nLabel, I18nObject } from '@objectstack/spec/ui'; | |
| // Validate data | |
| const result = AriaProps.parse(data); | |
| import { AriaPropsSchema, I18nLabelSchema, I18nObjectSchema } from '@objectstack/spec/ui'; | |
| import type { AriaProps, I18nLabel, I18nObject } from '@objectstack/spec/ui'; | |
| // Validate data | |
| const result = AriaPropsSchema.parse(data); |
|
|
||
| const label: I18nObject = \{ | ||
|
|
||
| key: 'views.task_list.label', | ||
|
|
||
| defaultValue: 'Task List', | ||
|
|
||
| params: \{ count: 5 \}, | ||
|
|
||
| \}; | ||
|
|
There was a problem hiding this comment.
The example code block has escaped braces (\{ / \}) and extra blank lines, which makes the TypeScript example invalid/copy-paste unfriendly. Inside a fenced code block, these escapes aren’t necessary—please render it as valid TypeScript object literal syntax.
| const label: I18nObject = \{ | |
| key: 'views.task_list.label', | |
| defaultValue: 'Task List', | |
| params: \{ count: 5 \}, | |
| \}; | |
| const label: I18nObject = { | |
| key: 'views.task_list.label', | |
| defaultValue: 'Task List', | |
| params: { count: 5 }, | |
| }; |
commandscontribution point to ManifestSchemacontributessectionCLICommandContributionSchemaandCLIExtensionExportSchemaplugin-commands.ts)bin.tsto load plugin commands before parsingloadPluginCommandsfrom CLI package indexcontent/docs/guides/plugins.mdxwith CLI Command Extensions section (architecture diagram, step-by-step guide, marketplace example, deployment example)content/docs/references/kernel/cli-extension.mdxreference pagemeta.jsonto includecli-extensionpackages/cli/README.mdwith Plugin CLI Extensions documentationcli.ts✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.