lan-low-code-cli 2.4.0 → 2.6.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.
- package/README.md +118 -0
- package/dist/api/formDefine.js +31 -1
- package/dist/api/operation.js +27 -0
- package/dist/api/template.js +76 -0
- package/dist/api/view.js +23 -0
- package/dist/commands/form.js +181 -1
- package/dist/commands/operation.js +730 -0
- package/dist/commands/template.js +541 -0
- package/dist/index.js +9 -1
- package/package.json +2 -1
- package/skills/lan-low-code-cli/SKILL.md +264 -0
- package/skills/lan-low-code-cli/SKILL.zh-CN.md +247 -0
- package/skills/lan-low-code-cli/references/cli-contract.md +233 -0
- package/skills/lan-low-code-cli/references/cli-contract.zh-CN.md +223 -0
- package/skills/lan-low-code-cli/references/existing-app-flows.md +88 -0
- package/skills/lan-low-code-cli/references/existing-app-flows.zh-CN.md +86 -0
- package/skills/lan-low-code-cli/references/mkxform-api.md +2653 -0
- package/skills/lan-low-code-cli/references/operation-script-context.md +220 -0
- package/skills/lan-low-code-cli/scripts/self-update.mjs +252 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# 业务操作脚本(JS 模式)context API 参考
|
|
2
|
+
|
|
3
|
+
> 适用:表单业务操作 `fdType=3` + `fdViewType=5`(平台「脚本模式」)。
|
|
4
|
+
> 结论来源:sys-modeling 前端源码(MK-PaaS,`frontend/sys-modeling`)。
|
|
5
|
+
> **重要**:除 `fdOprId` / `fdIds` / `fdBtnCfg` / `destroyLoading` 是显式注入外,
|
|
6
|
+
> 其余字段来自组件内部 props 的浅拷贝,**不是公开契约**,平台升级可能变化——使用前请做 `typeof` 兜底。
|
|
7
|
+
|
|
8
|
+
## 1. 执行机制
|
|
9
|
+
|
|
10
|
+
点击「脚本模式」按钮后,前端先调 `sysModelingOperation` 相关接口拿到操作配置,再执行脚本:
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
// desktop/operation/other.ts(移动端 mobile/operation/other.ts 同构)
|
|
14
|
+
new Function('context', model.fdCfg?.fdJavaScript)({
|
|
15
|
+
...context, // 列表页传入的上下文(浅拷贝,见第 3 节)
|
|
16
|
+
fdOprId: operation.id,
|
|
17
|
+
fdIds: params['fdIds'],
|
|
18
|
+
fdBtnCfg: model.fdCfg,
|
|
19
|
+
destroyLoading, // 关闭「执行中...」提示
|
|
20
|
+
})
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
关键点:
|
|
24
|
+
|
|
25
|
+
1. 脚本在**全局作用域**求值,可直接用 `window.mk`、`window.MKXFORM`、`fetch` 等页面全局对象;
|
|
26
|
+
2. 脚本抛错会被前端 `try/catch` 吞掉(仅 `console.error`),页面只关闭 loading——**错误不会提示用户**,
|
|
27
|
+
脚本内部必须自己 try/catch 并给出可见反馈;
|
|
28
|
+
3. 平台**不会**在脚本执行后自动刷新列表(只有 AI 能力分支会 `refresh()`),需要刷新就自己调;
|
|
29
|
+
4. 脚本里调用 `context.destroyLoading()` 后,前端检测到即不再重复关闭;不调用则由前端关闭(无参数执行完也关)。
|
|
30
|
+
|
|
31
|
+
## 2. 显式注入字段
|
|
32
|
+
|
|
33
|
+
| 字段 | 类型 | 说明 |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `context.fdOprId` | `string` | 当前业务操作 id |
|
|
36
|
+
| `context.fdIds` | `string[] \| undefined` | 选中/当前行 id。**仅** `operation.selected === true` 时赋值:行内按钮、标准行操作;工具栏按钮通常为 `undefined` |
|
|
37
|
+
| `context.fdBtnCfg` | `object` | 操作配置(后端返回的 `fdCfg` 解析结果,含 `fdViewType` / `fdJavaScript` 等) |
|
|
38
|
+
| `context.destroyLoading` | `() => void` | 关闭「执行中...」提示并复位按钮 loading |
|
|
39
|
+
|
|
40
|
+
## 3. 列表页上下文(desktop)
|
|
41
|
+
|
|
42
|
+
`context` 由列表页 `useListOperations` 组装(`components/list-core/hooks/useListOperations.ts`),
|
|
43
|
+
是 `content.tsx` 传入 props 的浅拷贝 + 若干选中态字段。
|
|
44
|
+
|
|
45
|
+
### 3.1 选中数据
|
|
46
|
+
|
|
47
|
+
| 字段 | 类型 | 说明 |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `selectedId` | `string[]` | 勾选行的 `fd_id` 数组(组件 state,工具栏按钮场景) |
|
|
50
|
+
| `docId` | `string \| undefined` | 行内按钮:当前行 id(= `operation.selectedIds[0]`) |
|
|
51
|
+
| `fdIds` | `string[] \| undefined` | 见第 2 节;行内按钮/标准行操作才有 |
|
|
52
|
+
| `setSelectedId` | `(ids: string[]) => void` | 设置勾选行 |
|
|
53
|
+
| `setSelectedDocs` | `(docs: any[]) => void` | 设置勾选行的完整数据 |
|
|
54
|
+
|
|
55
|
+
**取值优先级建议**:`fdIds`(行内按钮)→ `selectedId`(工具栏勾选)→ `docId`(兜底)。
|
|
56
|
+
|
|
57
|
+
> 坑:`selectedId: selectedId || selectedIds`,空数组 `[]` 是 truthy——行内按钮点击时
|
|
58
|
+
> `selectedId` 往往就是 `[]`,此时必须用 `docId` / `fdIds`。
|
|
59
|
+
|
|
60
|
+
### 3.2 列表数据
|
|
61
|
+
|
|
62
|
+
| 字段 | 类型 | 说明 |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `data` | `any[]` | 当前页数据数组(= `$$contentviewProps.data`,行字段 `fd_id`) |
|
|
65
|
+
| `$$contentviewProps.data` | `any[]` | 同上;`$$contentviewProps` 还含 `refresh` / `query` / `queryChange` / `history` |
|
|
66
|
+
| `xlistRuntimeRef.current.getData()` | `() => { content: any[] }` | 列表运行时数据(**类型未声明**,运行时存在,需兜底) |
|
|
67
|
+
|
|
68
|
+
### 3.3 刷新与查询
|
|
69
|
+
|
|
70
|
+
| 入口 | 说明 |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `context.refresh(query?)` | 推荐。等价 `xlistRuntimeRef.current.loadData(query)` |
|
|
73
|
+
| `context.$$contentviewProps.refresh(query?)` | 同上(列表页透传的同一个函数) |
|
|
74
|
+
| `context.cacheListRef.current.refresh(query?)` | 列表页把 `refresh` 缓存在这里,供跨组件/iframe 使用 |
|
|
75
|
+
| `context.xlistRuntimeRef.current.loadData(query?)` | 最底层;支持传对象或函数式 updater |
|
|
76
|
+
| `context.$$contentviewProps.queryChange(newQuery)` | 在现有 query 上合并后重载:`loadData(q => ({...q, ...newQuery}))` |
|
|
77
|
+
| `context.cacheListRef.current.queryChange` | 同上(缓存副本) |
|
|
78
|
+
|
|
79
|
+
### 3.4 视图 / 权限 / 路由上下文
|
|
80
|
+
|
|
81
|
+
| 字段 | 说明 |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `formId` | 表单 id |
|
|
84
|
+
| `listviewId` | 列表视图 id |
|
|
85
|
+
| `fdRightGroupId` | 当前权限组 id |
|
|
86
|
+
| `appId` / `appCode` | 应用 id / 编码 |
|
|
87
|
+
| `fdCategoryModelCode` / `fdCategoryType` / `fdCategoryId` | 分类表单相关 |
|
|
88
|
+
| `menuId` / `baseUrl` | 菜单与路由前缀 |
|
|
89
|
+
| `fdOpenMode` / `drawerOptions` | 打开方式(`self`/`drawer`/`_blank`)与抽屉配置 |
|
|
90
|
+
| `isInDrawer` | 当前是否在抽屉内 |
|
|
91
|
+
| `templateId` / `commonIncParams` / `localQuery` / `mainTable` | 模板、公共入参、本地查询、主表标识 |
|
|
92
|
+
| `params` | 外部调用 `operations.execute(op, params)` 时传入的第二个参数 |
|
|
93
|
+
| `dispatch` | 通用弹窗 reducer:`dispatch({type:'modalProps', payload:{...}})`(内部状态,不建议依赖) |
|
|
94
|
+
| `setDoingOperation` | `(v: boolean) => void`,切换按钮「执行中」状态 |
|
|
95
|
+
|
|
96
|
+
### 3.5 工具栏按钮 vs 行内按钮
|
|
97
|
+
|
|
98
|
+
| 场景 | `selected` | `fdIds` | `docId` | `selectedId` |
|
|
99
|
+
|---|---|---|---|---|
|
|
100
|
+
| 列表工具栏按钮 | 通常 `undefined` | `undefined` | `undefined` | 勾选行数组(可能 `[]`) |
|
|
101
|
+
| 行内右侧按钮 | `true` | `[当前行 id]` | 当前行 id | 通常 `[]` |
|
|
102
|
+
| 外部 `operations.execute` | 取决于调用方 | 需自己传 | 需自己传 | 取决于调用方 |
|
|
103
|
+
|
|
104
|
+
> 平台配置的「勾选数据」(`fdConfig.fdListViewIdValid`)在脚本模式下**不会**转成 `selected`,
|
|
105
|
+
> 也不会触发「请选择数据」校验——脚本必须自己判断并提示。
|
|
106
|
+
|
|
107
|
+
## 4. 全局能力(window)
|
|
108
|
+
|
|
109
|
+
| API | 说明 |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `mk.openLink({ url, target, drawerOptions, forceTarget })` | 打开页面:`target` 取 `self` / `drawer` / `_blank` |
|
|
112
|
+
| `mk.normalizeUrl('sys://user/sys-modeling/<path>')` | 内部路由转可访问地址(业务操作的 `goto` 用它) |
|
|
113
|
+
| `mk.getSysConfig(key)` | `modulesUrlPrefix` / `apiUrlPrefix` / `currentUser` / `platform` / `standalone` 等 |
|
|
114
|
+
| `mk.emit(event, payload)` / `mk.on` / `mk.off` | 事件总线(如 `mk.emit('CHANGETAGSS')`、`mk.emit(MkModelingEventTypes.PageListStateChange, {})`) |
|
|
115
|
+
| `mk.userStore` | 全局 key-value(`mk.userStore.set({ key, value })` / `get`) |
|
|
116
|
+
| `window.MKXFORM` | 列表页已初始化(`components/list-core/components/content-area.tsx`):`tableModal` / `modal` / `HTMLModal`;表单上下文还有 `setValue`/`getValue` 等(见 `mkxform-api.md`) |
|
|
117
|
+
| `fetch` / `Blob` / `navigator.clipboard` / `window.open` | 标准浏览器能力,可自行调接口、导出文件 |
|
|
118
|
+
|
|
119
|
+
### 4.1 跨窗口 / iframe
|
|
120
|
+
|
|
121
|
+
列表页监听 `message`(`window` 与 `window.parent`):
|
|
122
|
+
|
|
123
|
+
| 事件名 | 效果 |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `mk-refresh-opener`(`ModelingDrawerViewEvents.RefreshPage`) | 触发列表刷新(`cacheListRef.current.refresh()`) |
|
|
126
|
+
| `PageInfo` / `Clear` / `TurnPage` | 分页信息交换 / 清缓存 / 翻页(列表页与抽屉 iframe 之间) |
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
window.parent.postMessage({ eventType: 'mk-refresh-opener' }, '*')
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## 5. 常用片段
|
|
133
|
+
|
|
134
|
+
### 5.1 读取选中数据 + 兜底
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
function resolveRows(ctx) {
|
|
138
|
+
var ids = (Array.isArray(ctx.fdIds) && ctx.fdIds.length ? ctx.fdIds : null) ||
|
|
139
|
+
(Array.isArray(ctx.selectedId) && ctx.selectedId.length ? ctx.selectedId : null) ||
|
|
140
|
+
(ctx.docId ? [ctx.docId] : [])
|
|
141
|
+
var list = (ctx.$$contentviewProps && ctx.$$contentviewProps.data) || ctx.data || []
|
|
142
|
+
if (!list.length && ctx.xlistRuntimeRef && ctx.xlistRuntimeRef.current) {
|
|
143
|
+
try { list = (ctx.xlistRuntimeRef.current.getData() || {}).content || [] } catch (e) { list = [] }
|
|
144
|
+
}
|
|
145
|
+
return list.filter(function (row) { return ids.indexOf(row.fd_id || row.fdId) > -1 })
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### 5.2 刷新列表(多级兜底)
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
function refreshList(query) {
|
|
153
|
+
var c = context
|
|
154
|
+
try {
|
|
155
|
+
if (typeof c.refresh === 'function') return c.refresh(query)
|
|
156
|
+
if (c.$$contentviewProps && typeof c.$$contentviewProps.refresh === 'function')
|
|
157
|
+
return c.$$contentviewProps.refresh(query)
|
|
158
|
+
if (c.cacheListRef && c.cacheListRef.current && typeof c.cacheListRef.current.refresh === 'function')
|
|
159
|
+
return c.cacheListRef.current.refresh(query)
|
|
160
|
+
if (c.xlistRuntimeRef && c.xlistRuntimeRef.current && typeof c.xlistRuntimeRef.current.loadData === 'function')
|
|
161
|
+
return c.xlistRuntimeRef.current.loadData(query)
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.warn('[script] refresh failed', e)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 5.3 打开页面 / 抽屉
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
window.mk.openLink({
|
|
172
|
+
url: window.mk.normalizeUrl('sys://user/sys-modeling/add/' + context.formId),
|
|
173
|
+
target: context.fdOpenMode || '_blank',
|
|
174
|
+
drawerOptions: context.drawerOptions,
|
|
175
|
+
})
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 5.4 批量调接口后刷新
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
fetch(window.mk.getSysConfig('apiUrlPrefix') + '/sys-modeling/<接口路径>', {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: { 'Content-Type': 'application/json' },
|
|
184
|
+
credentials: 'include',
|
|
185
|
+
body: JSON.stringify({ fdIds: context.selectedId }),
|
|
186
|
+
})
|
|
187
|
+
.then(function (r) { return r.json() })
|
|
188
|
+
.then(function () { refreshList(); context.setSelectedId([]); context.destroyLoading() })
|
|
189
|
+
.catch(function (e) { console.error(e); context.destroyLoading() })
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## 6. 移动端差异
|
|
193
|
+
|
|
194
|
+
`mobile/operation/other.ts` 同样是 `new Function('context', script)({ ...context, fdIds, destroyLoading })`:
|
|
195
|
+
|
|
196
|
+
- 可靠可用的:`fdOprId`、`fdIds`、`fdBtnCfg`、`destroyLoading`、`refresh`(查看/编辑页传的是整页 `refreshPage`);
|
|
197
|
+
- **不要依赖** `xlistRuntimeRef` / `$$contentviewProps` / `cacheListRef`(PC 列表专有),移动端列表是 `m-list-view`;
|
|
198
|
+
- 移动端 `refresh()` 是整页重渲染,不是 `loadData(query)`。
|
|
199
|
+
|
|
200
|
+
## 7. 已知边界与坑
|
|
201
|
+
|
|
202
|
+
1. **没有** `context.selectRow`、`context.requestData`、`context.showAddPopup`、`context.queryChange`(后者只在 `$$contentviewProps` 里)。
|
|
203
|
+
2. `callback` / `lcodeIncParams` / `defaultTemplateData` / `detailDataRowFilterOrder` 在列表页点击场景为 `undefined`(列表页未传)。
|
|
204
|
+
3. `getData()` 未出现在 `IXListRuntimeRef` 类型声明中,仅运行时存在。
|
|
205
|
+
4. 脚本异常被静默吞掉,务必自己 try/catch + 提示 + `destroyLoading()`。
|
|
206
|
+
5. 刷新后如需清空勾选:`context.setSelectedId([])`。
|
|
207
|
+
6. 不要直接修改 `$$contentviewProps.data`(不持久化,且与组件 state 不一致),写操作走后端接口。
|
|
208
|
+
7. 调试:脚本里 `console.log(context)` 后在浏览器控制台查看完整字段;`localStorage.getItem('mk-debugger') === 'true'` 时平台会打印部分调试信息。
|
|
209
|
+
|
|
210
|
+
## 8. 源码索引(核对版本用)
|
|
211
|
+
|
|
212
|
+
| 内容 | 位置(sys-modeling/src) |
|
|
213
|
+
|---|---|
|
|
214
|
+
| 脚本执行入口(PC) | `desktop/operation/other.ts` |
|
|
215
|
+
| 脚本执行入口(移动) | `mobile/operation/other.ts` |
|
|
216
|
+
| context 组装 | `components/list-core/hooks/useListOperations.ts` |
|
|
217
|
+
| 列表页 props / refresh | `desktop/pages/list/index.ts` |
|
|
218
|
+
| 列表内容区 / window.MKXFORM | `components/list-core/components/content.tsx`、`content-area.tsx` |
|
|
219
|
+
| 按钮点击 / 行内按钮 | `components/view-config/utils/operationsFun.tsx` |
|
|
220
|
+
| 统一执行入口 | `desktop/operation/execute.ts` |
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 技能自更新(零依赖,Node >= 18)。
|
|
4
|
+
*
|
|
5
|
+
* 对比「已安装 CLI 版本」与「本技能 frontmatter version」,大版本不一致时提示技能可能过期;
|
|
6
|
+
* `--apply` 时用本地技能发行包 / 目录 / 全局 CLI 包内的 skill 覆盖当前技能目录(先备份)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* node scripts/self-update.mjs # 仅检查(默认)
|
|
10
|
+
* node scripts/self-update.mjs --check # 同上
|
|
11
|
+
* node scripts/self-update.mjs --apply --from <skill.tgz|目录> # 用本地发行包更新
|
|
12
|
+
* node scripts/self-update.mjs --apply --from-cli # 从全局 CLI 包内的 skills/ 更新
|
|
13
|
+
* node scripts/self-update.mjs --apply --from <...> --force # 同版本也强制覆盖
|
|
14
|
+
*
|
|
15
|
+
* 环境变量:
|
|
16
|
+
* LLC_CLI llc 命令或 CLI 入口脚本路径(默认 `llc`)
|
|
17
|
+
* LLC_SKILL_SOURCE 等同 --from
|
|
18
|
+
* LLC_SKILL_DIR 目标技能目录(默认脚本所在目录的父目录)
|
|
19
|
+
*
|
|
20
|
+
* 退出码:0 一致 / 已更新;2 版本不一致(技能可能过期);1 错误。
|
|
21
|
+
*/
|
|
22
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
23
|
+
import { tmpdir } from 'node:os'
|
|
24
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import { spawnSync } from 'node:child_process'
|
|
27
|
+
|
|
28
|
+
const SELF = fileURLToPath(import.meta.url)
|
|
29
|
+
const DEFAULT_SKILL_DIR = dirname(dirname(SELF))
|
|
30
|
+
|
|
31
|
+
const HELP = `技能自更新
|
|
32
|
+
|
|
33
|
+
用法:
|
|
34
|
+
node scripts/self-update.mjs [选项]
|
|
35
|
+
|
|
36
|
+
--check 仅对比版本(默认行为)
|
|
37
|
+
--apply 执行覆盖更新(需配合 --from 或 --from-cli)
|
|
38
|
+
--from <tgz|目录> 技能发行包(.tgz/.tar.gz)或已解压的技能目录
|
|
39
|
+
--from-cli 从全局 npm 包 lan-low-code-cli 内的 skills/ 取技能
|
|
40
|
+
--dest <目录> 目标技能目录(默认脚本所在技能目录)
|
|
41
|
+
--force 目标版本与来源版本相同时仍覆盖
|
|
42
|
+
--json 以 JSON 输出检查结果
|
|
43
|
+
--help 显示本帮助
|
|
44
|
+
|
|
45
|
+
环境变量:LLC_CLI、LLC_SKILL_SOURCE、LLC_SKILL_DIR`
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const opts = { apply: false, force: false, json: false, from: process.env.LLC_SKILL_SOURCE, fromCli: false }
|
|
49
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
50
|
+
const arg = argv[i]
|
|
51
|
+
if (arg === '--check') continue
|
|
52
|
+
else if (arg === '--apply') opts.apply = true
|
|
53
|
+
else if (arg === '--force') opts.force = true
|
|
54
|
+
else if (arg === '--json') opts.json = true
|
|
55
|
+
else if (arg === '--from-cli') opts.fromCli = true
|
|
56
|
+
else if (arg === '--from') opts.from = argv[++i]
|
|
57
|
+
else if (arg === '--dest') opts.dest = argv[++i]
|
|
58
|
+
else if (arg === '--help' || arg === '-h') opts.help = true
|
|
59
|
+
else throw new Error(`未知参数:${arg}\n\n${HELP}`)
|
|
60
|
+
}
|
|
61
|
+
opts.dest = resolve(opts.dest || process.env.LLC_SKILL_DIR || DEFAULT_SKILL_DIR)
|
|
62
|
+
if (opts.apply && !opts.from && !opts.fromCli) {
|
|
63
|
+
throw new Error('--apply 需要指定来源:--from <tgz|目录> 或 --from-cli')
|
|
64
|
+
}
|
|
65
|
+
return opts
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 读取 frontmatter 中的 name / version */
|
|
69
|
+
function readSkillMeta(dir) {
|
|
70
|
+
const file = join(dir, 'SKILL.md')
|
|
71
|
+
if (!existsSync(file)) return null
|
|
72
|
+
const text = readFileSync(file, 'utf8')
|
|
73
|
+
const version = /^version:\s*(\S+)\s*$/m.exec(text)
|
|
74
|
+
const name = /^name:\s*(\S+)\s*$/m.exec(text)
|
|
75
|
+
if (!version) return null
|
|
76
|
+
return { name: name ? name[1] : basename(dir), version: version[1] }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseVersion(version) {
|
|
80
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(version).trim())
|
|
81
|
+
if (!m) return null
|
|
82
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 运行 llc -v,返回 { version, command } */
|
|
86
|
+
function detectCliVersion() {
|
|
87
|
+
const raw = process.env.LLC_CLI || 'llc'
|
|
88
|
+
const isScript = /\.(?:c|m)?js$/i.test(raw)
|
|
89
|
+
const cmd = isScript ? process.execPath : raw
|
|
90
|
+
const args = isScript ? [raw, '-v'] : ['-v']
|
|
91
|
+
const res = spawnSync(cmd, args, {
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
shell: !isScript && process.platform === 'win32',
|
|
94
|
+
timeout: 20000,
|
|
95
|
+
})
|
|
96
|
+
if (res.error || res.status !== 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`无法执行 \`${raw} -v\`:${res.error ? res.error.message : `exit=${res.status}`}\n` +
|
|
99
|
+
'请先安装 CLI(npm i -g lan-low-code-cli),或用 LLC_CLI 指定可执行文件路径。',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
const out = `${res.stdout ?? ''}${res.stderr ?? ''}`
|
|
103
|
+
const m = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(out)
|
|
104
|
+
if (!m) throw new Error(`无法从 \`${raw} -v\` 的输出中解析版本号:${out.trim()}`)
|
|
105
|
+
return { version: m[1], command: raw }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 推导已安装 CLI 的包根:优先用 LLC_CLI 指向的入口脚本,其次 npm root -g */
|
|
109
|
+
function cliPackageRoot() {
|
|
110
|
+
const raw = process.env.LLC_CLI
|
|
111
|
+
if (raw && /\.(?:c|m)?js$/i.test(raw)) {
|
|
112
|
+
const root = dirname(dirname(resolve(raw)))
|
|
113
|
+
if (existsSync(join(root, 'package.json'))) return root
|
|
114
|
+
}
|
|
115
|
+
const res = spawnSync('npm root -g', { encoding: 'utf8', shell: true, timeout: 20000 })
|
|
116
|
+
if (res.error || res.status !== 0) return null
|
|
117
|
+
const root = (res.stdout ?? '').trim()
|
|
118
|
+
return root ? join(root, 'lan-low-code-cli') : null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** CLI 包内随包分发的 skills/(npm 包 files 含 skills;未分发时返回 null) */
|
|
122
|
+
function skillFromGlobalCli() {
|
|
123
|
+
const root = cliPackageRoot()
|
|
124
|
+
if (!root) return null
|
|
125
|
+
const dir = join(root, 'skills', 'lan-low-code-cli')
|
|
126
|
+
return existsSync(join(dir, 'SKILL.md')) ? dir : null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 解压 tgz 到临时目录并定位技能根目录(压缩包顶层为 lan-low-code-cli/) */
|
|
130
|
+
function extractSkillPackage(file) {
|
|
131
|
+
const tmp = mkdtempSync(join(tmpdir(), 'llc-skill-'))
|
|
132
|
+
const res = spawnSync('tar', ['-xzf', file, '-C', tmp], { encoding: 'utf8', timeout: 60000 })
|
|
133
|
+
if (res.error || res.status !== 0) {
|
|
134
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
135
|
+
throw new Error(
|
|
136
|
+
`解压失败:${file}(${res.error ? res.error.message : `exit=${res.status}`})。` +
|
|
137
|
+
'Windows 10+ 自带 tar;zip 包请先手动解压后用 --from <目录>。',
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
const roots = [tmp, join(tmp, 'lan-low-code-cli')]
|
|
141
|
+
for (const entry of readdirSync(tmp)) roots.push(join(tmp, entry))
|
|
142
|
+
const found = roots.find((dir) => existsSync(join(dir, 'SKILL.md')))
|
|
143
|
+
if (!found) {
|
|
144
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
145
|
+
throw new Error(`发行包内未找到 SKILL.md:${file}`)
|
|
146
|
+
}
|
|
147
|
+
return { dir: found, cleanup: () => rmSync(tmp, { recursive: true, force: true }) }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveSource(opts) {
|
|
151
|
+
if (opts.fromCli) {
|
|
152
|
+
const dir = skillFromGlobalCli()
|
|
153
|
+
if (!dir) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
'已安装的 lan-low-code-cli 包内没有 skills/ 目录(该版本未随包分发技能)。\n' +
|
|
156
|
+
'请先升级 CLI:npm i -g lan-low-code-cli@latest;仍失败时改用技能发行包:\n' +
|
|
157
|
+
' node scripts/self-update.mjs --apply --from ./lan-low-code-cli-skill-<version>.tgz',
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
return { dir, cleanup: () => {} }
|
|
161
|
+
}
|
|
162
|
+
const source = resolve(opts.from)
|
|
163
|
+
if (!existsSync(source)) throw new Error(`来源不存在:${source}`)
|
|
164
|
+
if (statSync(source).isDirectory()) return { dir: source, cleanup: () => {} }
|
|
165
|
+
return extractSkillPackage(source)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sameVersion(a, b) {
|
|
169
|
+
return parseVersion(a) && parseVersion(b) && a === b
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function check(opts) {
|
|
173
|
+
const skill = readSkillMeta(opts.dest)
|
|
174
|
+
if (!skill) throw new Error(`目标目录不是技能目录(缺少 SKILL.md 或 version):${opts.dest}`)
|
|
175
|
+
const cli = detectCliVersion()
|
|
176
|
+
const skillVer = parseVersion(skill.version)
|
|
177
|
+
const cliVer = parseVersion(cli.version)
|
|
178
|
+
if (!skillVer || !cliVer) throw new Error(`版本号无法解析:skill=${skill.version} cli=${cli.version}`)
|
|
179
|
+
|
|
180
|
+
const stale = cliVer.major > skillVer.major
|
|
181
|
+
const older = cliVer.major < skillVer.major
|
|
182
|
+
if (opts.json) {
|
|
183
|
+
console.log(JSON.stringify({ skill: skill.version, cli: cli.version, command: cli.command, stale, older }, null, 2))
|
|
184
|
+
} else {
|
|
185
|
+
console.log(`技能版本:${skill.version}(${opts.dest})`)
|
|
186
|
+
console.log(`CLI 版本:${cli.version}(${cli.command})`)
|
|
187
|
+
if (stale) {
|
|
188
|
+
console.error(
|
|
189
|
+
`\n⚠ 已跨大版本(skill ${skillVer.major}.x → CLI ${cliVer.major}.x):本技能可能已过期。\n` +
|
|
190
|
+
' 请提示用户更新技能后再执行写操作:\n' +
|
|
191
|
+
' npm i -g lan-low-code-cli@latest\n' +
|
|
192
|
+
` node scripts/self-update.mjs --apply --from ./lan-low-code-cli-skill-${cli.version}.tgz`,
|
|
193
|
+
)
|
|
194
|
+
} else if (older) {
|
|
195
|
+
console.error(`\n⚠ CLI(${cli.version})比技能(${skill.version})旧,请先升级 CLI,不要降级技能。`)
|
|
196
|
+
} else if (skill.version === cli.version) {
|
|
197
|
+
console.log('\n✓ 版本一致')
|
|
198
|
+
} else {
|
|
199
|
+
console.log('\n✓ 同一大版本,可直接升级 CLI(技能仍适用)')
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return stale || older ? 2 : 0
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function apply(opts) {
|
|
206
|
+
const current = readSkillMeta(opts.dest)
|
|
207
|
+
if (!current) throw new Error(`目标目录不是技能目录(缺少 SKILL.md 或 version):${opts.dest}`)
|
|
208
|
+
const source = resolveSource(opts)
|
|
209
|
+
try {
|
|
210
|
+
const incoming = readSkillMeta(source.dir)
|
|
211
|
+
if (!incoming) throw new Error(`来源目录不是技能目录(缺少 SKILL.md 或 version):${source.dir}`)
|
|
212
|
+
if (incoming.name !== current.name) {
|
|
213
|
+
throw new Error(`技能名不一致(当前 ${current.name},来源 ${incoming.name}),已中止`)
|
|
214
|
+
}
|
|
215
|
+
if (!opts.force && sameVersion(incoming.version, current.version)) {
|
|
216
|
+
console.log(`已是最新:${current.version}(如需强制覆盖加 --force)`)
|
|
217
|
+
return 0
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const backup = `${opts.dest}.backup-${current.version}-${Date.now()}`
|
|
221
|
+
cpSync(opts.dest, backup, { recursive: true })
|
|
222
|
+
console.log(`已备份:${backup}`)
|
|
223
|
+
|
|
224
|
+
const entries = readdirSync(source.dir)
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
cpSync(join(source.dir, entry), join(opts.dest, entry), { recursive: true, force: true })
|
|
227
|
+
}
|
|
228
|
+
const dropped = readdirSync(opts.dest).filter((entry) => !entries.includes(entry))
|
|
229
|
+
if (dropped.length) {
|
|
230
|
+
console.log(`提示:以下文件不在新版本中,已保留(可手动删除):${dropped.join('、')}`)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const updated = readSkillMeta(opts.dest)
|
|
234
|
+
console.log(`✓ 技能已更新:${current.version} → ${updated ? updated.version : '(未知)'}`)
|
|
235
|
+
console.log(' 请重新读取 SKILL.md,再继续后续操作。')
|
|
236
|
+
return 0
|
|
237
|
+
} finally {
|
|
238
|
+
source.cleanup()
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const opts = parseArgs(process.argv.slice(2))
|
|
244
|
+
if (opts.help) {
|
|
245
|
+
console.log(HELP)
|
|
246
|
+
process.exit(0)
|
|
247
|
+
}
|
|
248
|
+
process.exit(opts.apply ? apply(opts) : check(opts))
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.error(`错误:${error.message}`)
|
|
251
|
+
process.exit(1)
|
|
252
|
+
}
|