Skip to content

feat: migrate CLI architecture from Commander.js to oclif#704

Merged
hotlong merged 10 commits into
mainfrom
copilot/migrate-cli-architecture-to-oclif
Feb 18, 2026
Merged

feat: migrate CLI architecture from Commander.js to oclif#704
hotlong merged 10 commits into
mainfrom
copilot/migrate-cli-architecture-to-oclif

Conversation

Copilot AI commented Feb 18, 2026

Copy link
Copy Markdown
Contributor

Current CLI uses Commander.js with a custom loadPluginCommands mechanism that requires objectstack.config.ts to discover plugin commands — making cloud commands (os login, os push) unusable before project init (chicken-egg problem). Migrates to oclif for native plugin discovery, unified bin/os entry, and decoupled command extension.

Command Migration

  • All 16 commands converted from new Command('name').action(...)class extends Command { async run() }
  • plugin subcommands split into topic dir: commands/plugin/{list,info,add,remove}.ts
  • codemod subcommands split into topic dir: commands/codemod/v2-to-v3.ts
// Before (Commander.js)
export const compileCommand = new Command('compile')
  .description('Compile ObjectStack configuration')
  .option('-o, --output <path>', 'Output path', 'dist/objectstack.json')
  .action(async (configPath, options) => { ... });

// After (oclif)
export default class Compile extends Command {
  static override description = 'Compile ObjectStack configuration to JSON artifact';
  static override flags = {
    output: Flags.string({ char: 'o', description: 'Output path', default: 'dist/objectstack.json' }),
  };
  async run(): Promise<void> {
    const { args, flags } = await this.parse(Compile);
    ...
  }
}

Plugin System

  • Removed loadPluginCommands + plugin-commands.ts — replaced by oclif's native plugin system
  • @oclif/plugin-help for auto-generated help, @oclif/plugin-plugins for runtime plugin install
  • Cloud/third-party commands extend via os plugins install <pkg> — no project config needed

Build & Entry Points

  • Build changed from tsuptsc -p tsconfig.build.json (oclif needs preserved file structure for command discovery)
  • New bin/run.js / bin/run-dev.js using oclif execute(); removed old bin/objectstack.js
  • package.json gains oclif config section with pattern-based command discovery

Spec Updates

  • cli-extension.zod.ts documentation updated for oclif plugin model
  • Added OclifPluginConfigSchema for validating plugin package.json oclif sections
  • Removed CLIExtensionExportSchema (Commander.js-specific)

Tests

  • 35 CLI tests updated for oclif static property assertions
  • 5520 spec tests unchanged and passing
  • New tests for oclif package.json configuration validation
Original prompt

This section details on the original issue you should resolve

<issue_title>迁移 CLI 架构到 oclif,实现主 CLI 与插件自动分离</issue_title>
<issue_description>### 背景
当前 ObjectStack CLI 基于 Commander.js,自定义了一套插件注册机制(loadPluginCommands)进行命令扩展,但:

  • 存在命令入口冲突(spec 和 cloud 各有 @objectstack/cli,bin 均为 os)
  • 插件自动发现机制依赖项目 config 文件,导致 os login、os push 等云端命令在未初始化项目时无法使用(鸡蛋问题)
  • 插件注册、加载、失败处理、版本兼容等都需自维护

行动目标

迁移 ObjectStack 主 CLI 架构为 oclif,以便:

  • 主 CLI 与云端/第三方插件通过 oclif 内置机制解耦,bin/os 统一
  • 删除自定义 loadPluginCommands 等机制,利用 oclif 自动命令发现与插件注册
  • 按 oclif 典型结构将 packages/cli/src/commands 分为本地开发相关子命令包
  • 为 cloud 项目的云端命令预留 plugin 注册入口

工作内容

  1. 使用 oclif 脚手架生成新的主 CLI 骨架(bin: os)
  2. 将现有本地相关命令(init、dev、serve、plugin、compile、validate、generate、create、test、doctor、lint、diff、explain、codemod 等)迁移为 src/commands/ 下对应 TS class
  3. 保持主 CLI package.json 的 bin/os 唯一性,移除独立 cloud cli
  4. 移除 loadPluginCommands,相关扩展点迁移为 oclif plugins
  5. 提供插件注册与文档说明

验收标准

  • 用户全局安装 @objectstack/cli 后直接获得 "os" 主命令、所有子命令与自动 help
  • cloud 项目的命令可通过 plugin 引入自动获得
  • 项目内 objectstack.config.ts plugins 不再决定主 CLI 命令集合
  • 提供迁移文档、测试用例

如需临时平滑迁移方案,可支持 Commander.js/oClif 双栈过渡一段时间。</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@vercel

vercel Bot commented Feb 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-play Ready Ready Preview, Comment Feb 18, 2026 5:11am
spec Ready Ready Preview, Comment Feb 18, 2026 5:11am

Request Review

Copilot AI and others added 9 commits February 18, 2026 03:42
Convert the init command to use oclif Command class pattern:
- Replace Commander import with @oclif/core (Args, Command, Flags)
- Convert initCommand to Init class extending Command
- Map .argument() to static args, .option() to static flags
- Replace .action() handler with async run() method
- Replace process.exit(1) with this.error() for oclif convention
- Export TEMPLATES for external consumers
- Update bin.ts to remove Commander-based initCommand registration
- Update test to check oclif static properties

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Migrate the compile command to use oclif's class-based Command pattern,
matching the existing init command structure. This includes:
- Replace Commander.js fluent API with oclif Args/Flags static properties
- Export default class instead of named command instance
- Replace process.exit(1) with this.exit(1) and this.error()
- Remove compile command registration from bin.ts (oclif auto-discovers)
- Update test assertions to use oclif class properties

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Migrate the dev command to use oclif's class-based Command pattern with
static args/flags definitions instead of Commander.js's fluent API.

- Import Args, Command, Flags from @oclif/core
- Export default class Dev extending Command
- Define static args (package) and flags (watch, ui, verbose)
- Use this.parse(Dev) to extract args/flags
- Preserve all existing business logic unchanged

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…r.js to oclif

Convert three CLI command files from Commander.js pattern (named exports
with chained .description/.argument/.option/.action) to oclif class-based
pattern (default class exports with static args/flags and async run()).

- serve.ts: Serve class with port, dev, ui, server flags
- studio.ts: Studio class with config arg and port flag
- validate.ts: Validate class with config arg, strict and json flags
- Replace process.exit(1) with this.exit(1) in command context
- Replace options.X with flags.X, positional args with args.X

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
….js to oclif

Convert three CLI command files from Commander.js pattern to oclif pattern:
- info.ts: Convert to oclif Command class with Args/Flags
- create.ts: Convert to oclif Command class, preserve exported templates
- generate.ts: Convert main command and sub-commands to oclif pattern,
  extract internal Commander sub-commands into plain async functions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Convert test, doctor, lint, and diff commands from Commander.js pattern
to oclif class-based pattern with Args/Flags static properties.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… to oclif

- Convert explain command to oclif class pattern with Args/Flags
- Split codemod command into codemod/v2-to-v3.ts subcommand
- Delete old codemod.ts (oclif auto-discovers subcommands from directory)
- Update bin.ts to remove Commander.js imports and addCommand calls

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Convert all 16 commands to oclif Command classes
- Split plugin subcommands into topic structure (plugin/list, plugin/add, etc.)
- Split codemod subcommands into topic structure (codemod/v2-to-v3)
- Add oclif configuration to package.json
- Create oclif bin entry points (bin/run.js, bin/run-dev.js)
- Remove Commander.js dependency and loadPluginCommands mechanism
- Update all tests for oclif command classes (35 tests passing)

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
- Update CLIExtensionSpec to reference oclif plugin model
- Add OclifPluginConfigSchema for plugin package.json validation
- Update cli-extension.test.ts for new schemas (11 tests passing)
- Update README.md with oclif architecture, plugin docs, migration table
- Remove old bin/objectstack.js, add bin/run.js and bin/run-dev.js
- Update root package.json scripts to use new bin entry

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Migrate ObjectStack CLI architecture to oclif for better plugins feat: migrate CLI architecture from Commander.js to oclif Feb 18, 2026
Copilot AI requested a review from hotlong February 18, 2026 04:20
@hotlong
hotlong marked this pull request as ready for review February 18, 2026 04:26
Copilot AI review requested due to automatic review settings February 18, 2026 04:26
@hotlong
hotlong merged commit 6e47b65 into main Feb 18, 2026
5 of 7 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates the @objectstack/cli package from a Commander.js-based CLI (with custom plugin command loading) to an oclif-based CLI to enable native command auto-discovery and a decoupled plugin model, and updates the spec/kernel protocol docs + validation accordingly.

Changes:

  • Replaced Commander.js command definitions with oclif Command classes (including topic-based plugin/* and codemod/* subcommands).
  • Switched CLI build/entrypoints to oclif-friendly tsc output structure and added oclif runtime config/plugins in package.json.
  • Updated spec protocol docs/schema/tests for the new oclif plugin configuration model.

Reviewed changes

Copilot reviewed 33 out of 37 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
pnpm-lock.yaml Adds oclif dependencies and updates lockfile snapshot graph accordingly.
packages/spec/src/kernel/cli-extension.zod.ts Updates CLI extension protocol docs and introduces OclifPluginConfigSchema.
packages/spec/src/kernel/cli-extension.test.ts Replaces Commander export-shape tests with oclif config validation tests.
packages/cli/tsconfig.build.json Adds a dedicated tsc build config emitting dist/ with preserved structure.
packages/cli/test/plugin.test.ts Updates plugin command tests to assert oclif static properties.
packages/cli/test/plugin-commands.test.ts Replaces tests for removed custom plugin loader with package.json oclif config assertions.
packages/cli/test/commands.test.ts Updates command tests to import/assert oclif command classes.
packages/cli/src/utils/plugin-helpers.ts Extracts shared plugin name/version/type helpers for oclif plugin commands.
packages/cli/src/utils/plugin-commands.ts Removes Commander-specific loadPluginCommands implementation.
packages/cli/src/index.ts Updates public exports to re-export oclif command classes for programmatic use/tests.
packages/cli/src/commands/compile.ts Migrates compile to oclif Command.
packages/cli/src/commands/create.ts Migrates create to oclif Command.
packages/cli/src/commands/dev.ts Migrates dev to oclif Command.
packages/cli/src/commands/diff.ts Migrates diff to oclif Command.
packages/cli/src/commands/doctor.ts Migrates doctor to oclif Command.
packages/cli/src/commands/explain.ts Migrates explain to oclif Command.
packages/cli/src/commands/generate.ts Migrates generate to oclif and routes sub-modes internally.
packages/cli/src/commands/info.ts Migrates info to oclif Command.
packages/cli/src/commands/init.ts Migrates init to oclif Command.
packages/cli/src/commands/lint.ts Migrates lint to oclif Command.
packages/cli/src/commands/serve.ts Migrates serve to oclif Command.
packages/cli/src/commands/studio.ts Migrates studio to oclif Command and delegates to serve.
packages/cli/src/commands/test.ts Migrates test to oclif Command.
packages/cli/src/commands/validate.ts Migrates validate to oclif Command.
packages/cli/src/commands/plugin/add.ts Introduces os plugin add as an oclif subcommand.
packages/cli/src/commands/plugin/info.ts Introduces os plugin info as an oclif subcommand.
packages/cli/src/commands/plugin/list.ts Introduces os plugin list as an oclif subcommand.
packages/cli/src/commands/plugin/remove.ts Introduces os plugin remove as an oclif subcommand.
packages/cli/src/commands/codemod/v2-to-v3.ts Migrates codemod to an oclif topic subcommand (codemod v2-to-v3).
packages/cli/src/commands/plugin.ts Removes old Commander aggregated plugin command implementation.
packages/cli/src/bin.ts Replaces Commander bootstrap with an oclif execute export.
packages/cli/package.json Adds oclif config/plugins, updates bin entry, and switches build scripts to tsc.
packages/cli/bin/run.js New oclif runtime entrypoint for published CLI.
packages/cli/bin/run-dev.js New tsx-based dev runner for oclif.
packages/cli/bin/objectstack.js Removes old bin wrapper for Commander-based entrypoint.
packages/cli/README.md Updates CLI docs for oclif architecture and plugin model.
package.json Updates repo-level scripts to invoke the new oclif runner.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

Comment on lines +83 to +86
static override flags = {
dev: Flags.boolean({ char: 'd', description: 'Add as a dev-only plugin' }),
config: Flags.string({ char: 'c', description: 'Configuration file path' }),
};

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--dev is defined for PluginAdd, but the flag is never used when editing objectstack.config.ts (the plugin is always added to plugins). Either implement --dev by inserting into devPlugins, or remove the flag to avoid a misleading option.

Copilot uses AI. Check for mistakes.
console.log('');
printError(error.message || String(error));
process.exit(1);
this.error(error.message || String(error));

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the non-JSON error path, this prints the error via printError(...) and then calls this.error(...), which will also print/format an error and throw. This can lead to duplicate error output. Consider using either this.error(...) alone, or printing via printError(...) and then this.exit(1).

Suggested change
this.error(error.message || String(error));
this.exit(1);

Copilot uses AI. Check for mistakes.
Comment on lines +872 to +875
static override args = {
type: Args.string({ description: 'Metadata type to generate (object, view, action, flow, agent, dashboard, app)', required: true }),
name: Args.string({ description: 'Name for the metadata (use kebab-case)', required: false }),
};

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generate now declares type as a required arg. This changes the UX from the previous implementation where running os generate with no args printed available subcommands/types; oclif will now error immediately for missing args. If you want to preserve the old behavior, make type optional and handle the empty case in run() (or split types/client/migration/schema into their own oclif commands).

Copilot uses AI. Check for mistakes.
// In dev mode, Studio UI is enabled by default (use --no-ui to disable).
// Always serves the pre-built dist/ — no Vite dev server, no extra port.
const enableUI = options.ui ?? isDev;
const enableUI = flags.ui || isDev;

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const enableUI = flags.ui || isDev; prevents users from disabling UI in dev mode (an explicit --no-ui would still result in enableUI === true when isDev is true). If the intent is “UI defaults to enabled in dev mode but can be explicitly turned off,” compute enableUI so an explicit user choice overrides the dev default.

Suggested change
const enableUI = flags.ui || isDev;
const enableUI = flags.ui ?? isDev;

Copilot uses AI. Check for mistakes.
Comment on lines 209 to 213
if (!template) {
printError(`Unknown template: ${options.template}`);
printError(`Unknown template: ${flags.template}`);
console.log(chalk.dim(` Available: ${Object.keys(TEMPLATES).join(', ')}`));
process.exit(1);
this.error(`Unknown template: ${flags.template}`);
}

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch prints an error with printError(...) and then calls this.error(...), which also reports an error and throws. That can result in duplicated messaging and inconsistent formatting. Consider choosing one mechanism (either this.error(...) only, or printError(...) + this.exit(1)).

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +35
* "commands": {
* "strategy": "pattern",
* "target": "./dist/commands",
* "glob": "**\/*.js"
* }

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON example shows "glob": "**\/*.js", which isn’t a valid glob pattern (the backslash is literal in JSON). This looks like an accidental escape; it should be "**/*.js" to match typical oclif configs.

Copilot uses AI. Check for mistakes.
if (!args.name) {
printError('Missing required argument: <name>');
console.log(chalk.dim(' Usage: objectstack generate <type> <name>'));
process.exit(1);

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inside an oclif Command, using process.exit(1) (here for missing <name>) bypasses oclif’s exit/error handling and can make testing/hook behavior inconsistent. Prefer this.exit(1) or this.error(...) for command failures.

Suggested change
process.exit(1);
this.exit(1);

Copilot uses AI. Check for mistakes.
Comment thread packages/cli/package.json
"@oclif/plugin-help": "^6.2.37",
"@oclif/plugin-plugins": "^5.4.56",
"@types/node": "^25.2.2",
"tsup": "^8.0.2",

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tsup is still listed in devDependencies, but the package scripts now build/watch via tsc -p tsconfig.build.json and there are no remaining script references to tsup. If it’s no longer needed, consider removing it (and any related config) to reduce dependency surface.

Suggested change
"tsup": "^8.0.2",

Copilot uses AI. Check for mistakes.
static override flags = {
port: Flags.string({ char: 'p', description: 'Server port', default: '3000' }),
dev: Flags.boolean({ description: 'Run in development mode (load devPlugins)' }),
ui: Flags.boolean({ description: 'Enable Studio UI at /_studio/ (default: true in dev mode)' }),

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ui flag is described as supporting --no-ui, but it’s declared without allowNo: true, so oclif won’t accept --no-ui. If you want the documented behavior, define the flag with allowNo: true (and choose an appropriate default).

Suggested change
ui: Flags.boolean({ description: 'Enable Studio UI at /_studio/ (default: true in dev mode)' }),
ui: Flags.boolean({ description: 'Enable Studio UI at /_studio/ (default: true in dev mode)', allowNo: true }),

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

迁移 CLI 架构到 oclif,实现主 CLI 与插件自动分离

3 participants