@mjh-odoo/cli 0.0.0-stage → 0.1.6
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.
- package/.agents/skills/odoo-cli/SKILL.md +34 -0
- package/.agents/skills/odoo-cli/agents/openai.yaml +4 -0
- package/.agents/skills/odoo-cli/references/database.md +43 -0
- package/.agents/skills/odoo-cli/references/development.md +37 -0
- package/.agents/skills/odoo-cli/references/logs.md +14 -0
- package/COPYRIGHT +3 -0
- package/LICENSE +859 -0
- package/README.md +84 -2
- package/bin/odoo.mjs +11 -0
- package/docs/README.md +11 -0
- package/docs/architecture.md +34 -0
- package/docs/development.md +40 -0
- package/docs/validation.md +19 -0
- package/package.json +40 -4
- package/src/archive-library.mjs +134 -0
- package/src/archive.mjs +260 -0
- package/src/archived-worktrees.mjs +65 -0
- package/src/cli.mjs +188 -0
- package/src/commands.mjs +469 -0
- package/src/lib/environment.mjs +99 -0
- package/src/lib/image.mjs +66 -0
- package/src/lib/logs.mjs +196 -0
- package/src/project.mjs +100 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { projectNameForPath } from './lib/environment.mjs';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { withArchiveCatalog } from './archive.mjs';
|
|
6
|
+
|
|
7
|
+
const managedVolumes = ['db-data', 'odoo-data'];
|
|
8
|
+
|
|
9
|
+
async function retired(root) {
|
|
10
|
+
return await withArchiveCatalog(root, false, (db, project) => {
|
|
11
|
+
const roots = db.prepare("SELECT root, name FROM environments WHERE projectId = ? AND kind = 'worktree'").all(project.id);
|
|
12
|
+
const names = new Map(roots.map(row => [row.root, row.name]));
|
|
13
|
+
const runtimes = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'project_runtimes'").get()
|
|
14
|
+
? db.prepare('SELECT root, composeProject FROM project_runtimes WHERE projectId = ?').all(project.id) : [];
|
|
15
|
+
const rows = new Map(roots.map(row => [row.root, projectNameForPath(row.root)]));
|
|
16
|
+
for (const row of runtimes) rows.set(row.root, row.composeProject);
|
|
17
|
+
return [...rows].filter(([directory]) => directory !== project.root && !fs.existsSync(directory))
|
|
18
|
+
.map(([directory, composeProject]) => ({
|
|
19
|
+
root: directory, composeProject, name: names.get(directory) || path.basename(directory),
|
|
20
|
+
managed: composeProject === projectNameForPath(directory), repository: project.root,
|
|
21
|
+
}));
|
|
22
|
+
}) || [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function exactVolumes(docker, composeProject) {
|
|
26
|
+
const raw = await docker(['volume', 'ls', '--quiet', '--filter', 'label=com.docker.compose.project=' + composeProject]);
|
|
27
|
+
return managedVolumes.map(kind => composeProject + '_' + kind).filter(name => raw.split(/\r?\n/).includes(name));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function archivedWorktrees(root, docker) {
|
|
31
|
+
const rows = await retired(root);
|
|
32
|
+
if (!rows.length) return [];
|
|
33
|
+
const usage = await docker(['system', 'df', '-v', '--format', 'json']).then(raw => JSON.parse(raw).Volumes || []).catch(() => []);
|
|
34
|
+
const sizes = new Map(usage.map(row => [row.Name, row.Size]));
|
|
35
|
+
return Promise.all(rows.map(async row => {
|
|
36
|
+
const volumes = row.managed ? await exactVolumes(docker, row.composeProject) : [];
|
|
37
|
+
return {...row, volumes: volumes.map(name => ({name, size: sizes.get(name) || null}))};
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function cleanArchivedWorktree(root, composeProject, docker) {
|
|
42
|
+
const row = (await retired(root)).find(row => row.composeProject === composeProject);
|
|
43
|
+
if (!row?.managed) throw new Error('仅可清理路径已移除且使用自动 Compose 标识的已归档工作树。');
|
|
44
|
+
const check = () => {
|
|
45
|
+
if (fs.existsSync(row.root)) throw new Error('工作树已恢复,请刷新列表。');
|
|
46
|
+
const registered = execFileSync('git', ['worktree', 'list', '--porcelain', '-z'], {
|
|
47
|
+
cwd: row.repository, timeout: 5000, encoding: 'utf8',
|
|
48
|
+
}).split('\0');
|
|
49
|
+
if (registered.includes('worktree ' + row.root)) throw new Error('Git 仍登记此工作树,请先完成归档。');
|
|
50
|
+
};
|
|
51
|
+
check();
|
|
52
|
+
const containers = await docker(['ps', '-a', '-q', '--filter', 'label=com.docker.compose.project=' + composeProject]);
|
|
53
|
+
if (containers) throw new Error('该环境仍有 Docker 容器,请先完成停止和归档。');
|
|
54
|
+
const volumes = await exactVolumes(docker, composeProject);
|
|
55
|
+
if (volumes.length) {
|
|
56
|
+
const inspected = JSON.parse(await docker(['volume', 'inspect', ...volumes]));
|
|
57
|
+
if (inspected.length !== volumes.length || inspected.some(row =>
|
|
58
|
+
row.Labels?.['com.docker.compose.project'] !== composeProject ||
|
|
59
|
+
row.Name !== composeProject + '_' + row.Labels?.['com.docker.compose.volume'] ||
|
|
60
|
+
!volumes.includes(row.Name))) throw new Error('卷归属发生变化,请刷新后再试。');
|
|
61
|
+
check();
|
|
62
|
+
await docker(['volume', 'rm', ...volumes]);
|
|
63
|
+
}
|
|
64
|
+
return volumes;
|
|
65
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseEnv } from 'node:util';
|
|
4
|
+
import { Command, Option, InvalidArgumentError } from 'commander';
|
|
5
|
+
import { findProject, openProject, databaseName, runDocker, projectVersion } from './project.mjs';
|
|
6
|
+
import { readEnv } from './lib/environment.mjs';
|
|
7
|
+
import { parseLogOptions, executeLogs } from './lib/logs.mjs';
|
|
8
|
+
import { imageBuildPlan, executeImageBuild } from './lib/image.mjs';
|
|
9
|
+
import { archivedWorktrees, cleanArchivedWorktree } from './archived-worktrees.mjs';
|
|
10
|
+
import { exportLibrary, importLibrary } from './archive-library.mjs';
|
|
11
|
+
import * as commands from './commands.mjs';
|
|
12
|
+
import { archiveDefault, archiveList, archiveTag, archiveTags, deleteArchive, tagName, archiveSeed, startupSeed } from './archive.mjs';
|
|
13
|
+
|
|
14
|
+
function integer(value) {
|
|
15
|
+
if (!/^\d+$/.test(value) || +value > 3600) throw new InvalidArgumentError('请输入 0..3600 的整数。');
|
|
16
|
+
return +value;
|
|
17
|
+
}
|
|
18
|
+
function positive(value) {
|
|
19
|
+
const n = integer(value);
|
|
20
|
+
if (!n) throw new InvalidArgumentError('等待时间必须大于 0。');
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
function argumentsFor(options, names) {
|
|
24
|
+
return names.flatMap(name => {
|
|
25
|
+
const key = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
26
|
+
const value = options[key];
|
|
27
|
+
return value === true ? ['--' + name] : value === undefined || value === false ? [] : ['--' + name + '=' + String(value)];
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createProgram({version, cwd = process.cwd(), runner = runDocker} = {}) {
|
|
32
|
+
const program = new Command();
|
|
33
|
+
program.name('odoo').description('Odoo 项目开发管理:环境、模块、数据库、日志与镜像。').version(version, '-V, --version', '显示管理 CLI 版本')
|
|
34
|
+
.option('-C, --project <directory>', '指定项目目录;默认从当前目录向上查找')
|
|
35
|
+
.enablePositionalOptions().addHelpCommand('help [command...]', '查看命令帮助').showHelpAfterError('(运行 odoo --help 查看命令)').showSuggestionAfterError(true);
|
|
36
|
+
program.addHelpText('after', '\n示例:\n odoo up\n odoo logs all --since 10m --json\n odoo module update my_module\n odoo db backup\n odoo image build --help\n\n全局 --project 放在命令前,例如 odoo --project /path/to/erp status。');
|
|
37
|
+
const root = () => findProject(program.opts().project || cwd);
|
|
38
|
+
const context = initialize => openProject(root(), {initialize, runner});
|
|
39
|
+
const waitOption = command => command.option('--wait-timeout <seconds>', '健康检查等待秒数', positive, 300);
|
|
40
|
+
const databaseOption = command => command.option('-d, --database <name>', '目标数据库,默认 PGDATABASE', databaseName);
|
|
41
|
+
|
|
42
|
+
program.command('version').description('显示管理 CLI 版本').action(() => console.log(version));
|
|
43
|
+
program.command('init').description('为当前 base 项目准备本机 .env').option('--json', '输出脱敏 JSON').action(async options => commands.initialize(await context(true), options));
|
|
44
|
+
program.command('doctor').description('检查 Docker、Compose、源码和配置').option('--json', '输出 JSON').action(async options => commands.doctor(await context(false), options));
|
|
45
|
+
program.command('config').description('输出项目脱敏配置及存档位置').action(async () => commands.configuration(await context(false)));
|
|
46
|
+
waitOption(program.command('up').description('后台启动项目并等待健康').option('--build', '构建开发镜像后启动(默认)', true).option('--no-build', '直接使用已有镜像,不构建')
|
|
47
|
+
.addOption(new Option('--pull <policy>', '镜像拉取策略').choices(['always', 'missing', 'never'])))
|
|
48
|
+
.action(async options => commands.up(await context(true), options));
|
|
49
|
+
program.command('down').description('停止项目,默认保留数据库和附件卷')
|
|
50
|
+
.option('-v, --volumes', '同时删除本项目数据库及附件卷').option('--timeout <seconds>', '停止等待秒数', integer, 30).option('--dry-run', '只显示操作计划,不执行停止或删除')
|
|
51
|
+
.action(async options => commands.down(await context(false), options));
|
|
52
|
+
waitOption(program.command('restart').description('重启服务,默认只重启 Odoo').argument('[services...]', 'odoo、db 或 all').option('--timeout <seconds>', '停止等待秒数', integer, 30))
|
|
53
|
+
.action(async (services, options) => commands.restart(await context(false), services || [], options));
|
|
54
|
+
program.command('status').description('查看项目容器状态,包含已停止容器').option('--json', '输出 JSON 数组').action(async options => commands.status(await context(false), options));
|
|
55
|
+
program.command('build').description('构建项目 Compose 开发镜像').option('--pull', '更新上游镜像').option('--no-cache', '不使用构建缓存').action(async options => commands.build(await context(false), options));
|
|
56
|
+
|
|
57
|
+
const logs = program.command('logs').description('默认读取最近 200 行快照;-f 才持续跟随').argument('[services...]', 'odoo、db 或 all,默认 odoo')
|
|
58
|
+
.option('-f, --follow', '持续查看,Ctrl+C 只退出查看').option('-n, --tail <lines>', '每容器行数,支持 0 或 all', '200')
|
|
59
|
+
.option('--since <time>', '起始时间,如 10m 或带时区 RFC3339').option('--until <time>', '截止时间')
|
|
60
|
+
.option('--level <level>', '最低级别:debug/info/warning/error/critical').option('--match <text>', '不区分大小写的字面筛选')
|
|
61
|
+
.option('--hide-health', '隐藏成功的 Odoo 健康检查').option('--context <events>', '筛选结果前后事件数,默认 2').option('--json', '输出完整 JSON 快照').option('--file <path>', '保存快照,拒绝覆盖已有文件');
|
|
62
|
+
logs.addHelpText('after', '\n筛选、JSON 和文件用于有限快照,不能与 -f 同用。保留完整异常堆栈;--tail 起点仍可能截到异常中间。');
|
|
63
|
+
logs.action(async (services, options) => {
|
|
64
|
+
const parsed = parseLogOptions([...(services || []), ...argumentsFor(options, ['follow', 'tail', 'since', 'until', 'level', 'match', 'context', 'hide-health', 'json', 'file'])]);
|
|
65
|
+
process.exitCode = await executeLogs(parsed, await context(false));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const module = program.command('module').description('查询、安装和升级模块');
|
|
69
|
+
databaseOption(module.command('list').description('列出已安装模块').option('--all', '包含未安装模块').option('--json', '输出 JSON')).action(async options => commands.listModules(await context(false), options));
|
|
70
|
+
for (const operation of ['install', 'update']) {
|
|
71
|
+
const command = databaseOption(module.command(operation).description(operation === 'install' ? '批量安装模块' : '批量升级已安装模块')
|
|
72
|
+
.argument(operation === 'install' ? '<modules...>' : '[modules...]', '用空格分隔多个模块名').option('--no-restart', '完成后保持 Odoo 停止'));
|
|
73
|
+
if (operation === 'update') command.option('--all', '升级全部已安装的项目扩展(不包含官方核心)');
|
|
74
|
+
command.action(async (names, options) => commands.maintainModules(await context(false), operation, names || [], options));
|
|
75
|
+
}
|
|
76
|
+
module.action(() => module.help());
|
|
77
|
+
|
|
78
|
+
const db = program.command('db').description('数据库查询及标准 ZIP 备份恢复');
|
|
79
|
+
db.command('list').description('列出项目 PostgreSQL 中的数据库').option('--json', '输出 JSON;current 表示当前配置的数据库').action(async options => commands.listDatabases(await context(false), options));
|
|
80
|
+
databaseOption(db.command('backup').description('导出数据库及附件 ZIP')
|
|
81
|
+
.addOption(new Option('--file <path>', '输出到指定路径').conflicts('name'))
|
|
82
|
+
.option('--name <title>', '按名称保存到 Odoo Manager 存档库').option('--note <text>', '存档备注(与 --name 一起使用)')
|
|
83
|
+
.option('--json', '输出存档 JSON(与 --name 一起使用)')).action(async options => {
|
|
84
|
+
if (options.name === undefined && (options.note !== undefined || options.json)) throw new Error('--note 和 --json 需要 --name。');
|
|
85
|
+
await commands.backup(await context(false), options);
|
|
86
|
+
});
|
|
87
|
+
databaseOption(db.command('restore').description('恢复 Odoo ZIP,默认中和外发任务')
|
|
88
|
+
.argument('[reference]', '本项目的标签或存档 ID')
|
|
89
|
+
.option('--to <name>', '恢复目标库', databaseName)
|
|
90
|
+
.option('--use', '恢复成功后切换到新库')
|
|
91
|
+
.addOption(new Option('--file <path>', '已有的 Odoo 20 ZIP').conflicts('archive'))
|
|
92
|
+
.option('--archive <reference>', '兼容入口,指定项目存档')
|
|
93
|
+
.option('--force', '允许覆盖已有目标数据库').option('--no-restart', '恢复后保持 Odoo 停止')).action(async (reference, options) => {
|
|
94
|
+
if ([reference, options.file, options.archive].filter(Boolean).length !== 1) throw new Error('请指定标签、存档 ID 或 --file <path>。');
|
|
95
|
+
if (options.to && options.database) throw new Error('请选择 --to 或 --database 中的一个。');
|
|
96
|
+
const database = options.to || options.database;
|
|
97
|
+
if ((reference || options.use) && !database) throw new Error('请用 --to 指定新的目标数据库名称。');
|
|
98
|
+
if (options.use && options.force) throw new Error('恢复并切换需要一个新库,不能同时指定 --force。');
|
|
99
|
+
await commands.restore(await context(false), {...options, archive: reference || options.archive, database, legacy: !reference});
|
|
100
|
+
});
|
|
101
|
+
db.command('recover').description('清理本次恢复产生的残留库').option('--yes', '确认清理')
|
|
102
|
+
.action(async options => {
|
|
103
|
+
if (!options.yes) throw new Error('清理恢复残留需要 --yes。');
|
|
104
|
+
await commands.recoverDatabase(await context(false));
|
|
105
|
+
});
|
|
106
|
+
db.command('use').description('切换当前环境使用的数据库').argument('<database>', '已有数据库', databaseName)
|
|
107
|
+
.action(async name => commands.useDatabase(await context(false), name));
|
|
108
|
+
db.command('delete').description('删除指定数据库及其附件')
|
|
109
|
+
.argument('<database>', '目标数据库', databaseName).option('--yes', '确认删除')
|
|
110
|
+
.action(async (name, options) => {
|
|
111
|
+
if (!options.yes) throw new Error('删除数据库及附件需要 --yes。');
|
|
112
|
+
await commands.deleteDatabase(await context(false), name);
|
|
113
|
+
});
|
|
114
|
+
db.action(() => db.help());
|
|
115
|
+
|
|
116
|
+
const archive = program.command('archive').description('管理项目数据库存档');
|
|
117
|
+
archive.command('seed').description('查看或设置项目的首次启动数据来源')
|
|
118
|
+
.argument('[reference]', 'tag:<标签>、id:<存档ID> 或 empty').option('--json', '输出 JSON')
|
|
119
|
+
.action((reference, options) => archiveSeed(root(), reference, options));
|
|
120
|
+
archive.command('source').description('预览当前环境首次启动的数据来源')
|
|
121
|
+
.action(async () => {
|
|
122
|
+
const directory = root();
|
|
123
|
+
const values = fs.existsSync(path.join(directory, '.env')) ? readEnv(directory) : parseEnv(fs.readFileSync(path.join(directory, '.env.example'), 'utf8'));
|
|
124
|
+
console.log(JSON.stringify(await startupSeed(directory, values, true)));
|
|
125
|
+
});
|
|
126
|
+
archive.command('list').description('列出本项目的存档、标签和 ID').option('--json', '输出 JSON').action(options => archiveList(root(), options));
|
|
127
|
+
databaseOption(archive.command('save').description('保存当前环境的 Odoo ZIP').argument('<title>', '存档名称')
|
|
128
|
+
.option('--note <text>', '存档备注').option('--tag <name>', '保存成功后创建或移动标签', tagName).option('--json', '输出 JSON'))
|
|
129
|
+
.action(async (name, options) => commands.backup(await context(false), {...options, name}));
|
|
130
|
+
archive.command('import').description('校验并导入 Odoo ZIP').argument('<file>', 'ZIP 文件')
|
|
131
|
+
.option('--name <title>', '存档名称').option('--json', '输出 JSON')
|
|
132
|
+
.action(async (file, options) => commands.importArchive(await context(true), file, options));
|
|
133
|
+
archive.command('delete').description('删除存档 ZIP').argument('<id>', '存档 ID').option('--yes', '确认删除')
|
|
134
|
+
.action((id, options) => {
|
|
135
|
+
if (!options.yes) throw new Error('删除存档 ZIP 需要 --yes。');
|
|
136
|
+
return deleteArchive(root(), id);
|
|
137
|
+
});
|
|
138
|
+
archive.command('default', {hidden: true}).argument('[id]').action(id => archiveDefault(root(), id));
|
|
139
|
+
const library = archive.command('library').description('迁移项目存档、标签和备注');
|
|
140
|
+
library.command('export').argument('<directory>', '新建的导出目录').description('导出项目存档库')
|
|
141
|
+
.action(async directory => console.log(JSON.stringify(await exportLibrary(root(), directory))));
|
|
142
|
+
library.command('import').argument('<directory>', '存档库目录').description('导入项目存档库')
|
|
143
|
+
.action(async directory => console.log(JSON.stringify(await importLibrary(root(), directory))));
|
|
144
|
+
library.action(() => library.help());
|
|
145
|
+
const tag = archive.command('tag').description('管理项目存档标签');
|
|
146
|
+
tag.command('list').description('列出标签及指向的存档').option('--json', '输出 JSON').action(options => archiveTags(root(), options));
|
|
147
|
+
tag.command('set').description('创建或移动标签').argument('<tag>').argument('<id>', '存档 ID').action((name, id) => archiveTag(root(), name, id));
|
|
148
|
+
tag.command('remove').description('移除标签').argument('<tag>').action(name => archiveTag(root(), name));
|
|
149
|
+
tag.action(() => tag.help());
|
|
150
|
+
archive.action(() => archive.help());
|
|
151
|
+
|
|
152
|
+
const worktree = program.command('worktree').description('查看已归档工作树的本地数据');
|
|
153
|
+
const runLocalDocker = (directory, args) => runner(args, {root: directory, env: process.env, capture: true});
|
|
154
|
+
worktree.command('archived').description('列出已归档工作树和数据卷').action(async () => {
|
|
155
|
+
const directory = root();
|
|
156
|
+
console.log(JSON.stringify(await archivedWorktrees(directory, args => runLocalDocker(directory, args))));
|
|
157
|
+
});
|
|
158
|
+
worktree.command('clean').description('删除已归档工作树的数据库和附件卷')
|
|
159
|
+
.argument('<compose-project>').option('--yes', '确认删除').action(async (project, options) => {
|
|
160
|
+
if (!options.yes) throw new Error('清理归档数据卷需要 --yes。');
|
|
161
|
+
const directory = root();
|
|
162
|
+
console.log(JSON.stringify(await cleanArchivedWorktree(directory, project, args => runLocalDocker(directory, args))));
|
|
163
|
+
});
|
|
164
|
+
worktree.action(() => worktree.help());
|
|
165
|
+
|
|
166
|
+
const image = program.command('image').description('构建基础镜像、项目镜像和离线包');
|
|
167
|
+
image.command('build').description('默认构建本地基础镜像')
|
|
168
|
+
.option('--platform <platforms>', 'amd64、arm64 或 amd64,arm64')
|
|
169
|
+
.option('--tag <name:version>', '镜像标签;推送和 production 必填')
|
|
170
|
+
.addOption(new Option('--target <stage>', '构建阶段').choices(['base', 'production']).default('base'))
|
|
171
|
+
.option('--file <path.tar>', '导出单架构离线包').option('--push', '明确推送到镜像仓库').option('--builder <name>', '使用已有 Buildx 构建器').option('--dry-run', '仅输出 JSON 计划')
|
|
172
|
+
.action(async options => {
|
|
173
|
+
const directory = root();
|
|
174
|
+
const plan = imageBuildPlan(argumentsFor(options, ['platform', 'tag', 'target', 'file', 'push', 'builder', 'dry-run']), {root: directory, version: projectVersion(directory), mirror: readEnv(directory).APT_MIRROR_BASE});
|
|
175
|
+
if (plan.dryRun) { console.log(JSON.stringify(plan, null, 2)); return; }
|
|
176
|
+
console.log(`构建 ${plan.target}: ${plan.tag} (${plan.platforms.join(',') || '当前构建器平台'})`);
|
|
177
|
+
await executeImageBuild(plan, (argv, stdio) => runner(argv, {root: directory, env: process.env, stdio}));
|
|
178
|
+
console.log(plan.file ? '离线包已保存: ' + plan.file : plan.output === 'registry' ? '镜像已推送: ' + plan.tag : '本地镜像就绪: ' + plan.tag);
|
|
179
|
+
});
|
|
180
|
+
image.action(() => image.help());
|
|
181
|
+
|
|
182
|
+
program.command('exec').description('在运行中的 Odoo 容器里转发官方命令;本地说明用 help exec')
|
|
183
|
+
.helpOption(false).allowUnknownOption(true).passThroughOptions().argument('[args...]', '原样传给官方 Odoo CLI 的参数')
|
|
184
|
+
.addHelpText('after', '\n示例:\n odoo exec -- --version\n odoo exec -- --help\n odoo exec -- shell -c /etc/odoo/odoo.conf')
|
|
185
|
+
.action(async args => commands.native(await context(false), args || []));
|
|
186
|
+
program.action(() => program.help());
|
|
187
|
+
return program;
|
|
188
|
+
}
|