create-dwy 0.18.3 → 0.18.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # create-dwy
2
2
 
3
+ ## 0.18.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 按包勾选改用 `@clack/prompts` 的 `groupMultiselect`:Space 勾父级=全选子项,子项可单独取消。去掉「下一步再取消子项」那一屏。
8
+
9
+ ---
10
+
3
11
  ## 0.18.3
4
12
 
5
13
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-dwy",
3
- "version": "0.18.3",
3
+ "version": "0.18.4",
4
4
  "description": "项目脚手架与多平台 AI 配置同步工具",
5
5
  "type": "module",
6
6
  "bin": {
@@ -263,15 +263,7 @@ export function searchableMultiselect(opts) {
263
263
  ? styleText('green', S_CHECKBOX_SELECTED)
264
264
  : styleText('dim', S_CHECKBOX_INACTIVE)
265
265
  if (opt.disabled) {
266
- // 包内子条目:随父包勾选显示已勾,禁止点选,不要删除线
267
- const packOn = opt.packId && this.selectedValues.includes(opt.packId)
268
- if (packOn) {
269
- const previewBox = styleText('green', S_CHECKBOX_SELECTED)
270
- return active
271
- ? `${previewBox} ${label}`
272
- : `${previewBox} ${styleText('dim', label)}`
273
- }
274
- return `${styleText('dim', S_CHECKBOX_INACTIVE)} ${styleText('dim', label)}`
266
+ return `${styleText('dim', S_CHECKBOX_INACTIVE)} ${styleText(['strikethrough', 'dim'], label)}`
275
267
  }
276
268
  // 聚焦也不拼 hint,描述只在底部公共区
277
269
  if (active) {
package/src/sync-scene.js CHANGED
@@ -1,100 +1,118 @@
1
1
  /**
2
- * 按包勾选交互:技术栈包 → 场景包(包下挂子条目)→ 预勾子条目可取消。
2
+ * 按包勾选交互:技术栈包 → 场景包。
3
+ * 用 @clack/prompts groupMultiselect:勾父级=全选子项,子项可单独取消。
3
4
  * 由 dwy / dwy sync 在「按场景和技术栈」模式下调用。
4
5
  */
5
6
 
6
- import { isCancel } from '@clack/prompts'
7
- import { SEARCH_PLACEHOLDER, searchableMultiselect, searchableSelect } from './searchable-select.js'
7
+ import { groupMultiselect, isCancel } from '@clack/prompts'
8
+ import { SEARCH_PLACEHOLDER, searchableSelect } from './searchable-select.js'
8
9
  import {
9
10
  SCENE_PACKS,
10
11
  STACK_PACKS,
11
- expandPackSelection,
12
12
  listPackItems,
13
13
  normalizePacks,
14
- packLabelForItem,
15
14
  } from './sync-packs.js'
16
15
  import { chalk } from './utils.js'
17
16
 
17
+ /** 子 value 三段分隔:packId / type / name。packId 不含冒号。 */
18
+ const PACK_CHILD_RE = /^([^:]+):(skills|rules|hooks):(.+)$/
19
+
20
+ /**
21
+ * 子条目在 groupMultiselect 里的稳定 value。
22
+ * 带 packId:同一 skill 可同时出现在 Vue 与 Python,取消一边不能误伤另一边。
23
+ *
24
+ * @param {string} packId
25
+ * @param {'skills' | 'rules' | 'hooks'} type
26
+ * @param {string} name
27
+ */
28
+ export function packChildKey(packId, type, name) {
29
+ return `${packId}:${type}:${name}`
30
+ }
31
+
18
32
  /**
19
- * 包名 + 其下子条目。子条目 disabled,只展示不参与勾选;勾的是整包。
33
+ * 解析 packChildKey。对不上的丢掉,避免脏 initialValues 撑进结果。
34
+ *
35
+ * @param {unknown} key
36
+ * @returns {{ packId: string, type: 'skills' | 'rules' | 'hooks', name: string } | null}
37
+ */
38
+ export function parsePackChildKey(key) {
39
+ const match = String(key).match(PACK_CHILD_RE)
40
+ if (!match) return null
41
+ return { packId: match[1], type: match[2], name: match[3] }
42
+ }
43
+
44
+ /**
45
+ * groupMultiselect 的 options:key 是分组标题(包名),value 是可单独勾的子项。
46
+ * 空包跳过——clack 空组 `every()` 恒 true,会显示成已全选。
20
47
  *
21
48
  * @param {object[]} packs
22
49
  * @param {object} scans
23
50
  * @param {{ skillsOnly?: boolean }} [opts]
51
+ * @returns {Record<string, { value: string, label: string, hint?: string }[]>}
24
52
  */
25
- export function packOptionsWithChildren(packs, scans, { skillsOnly = false } = {}) {
26
- const rows = []
53
+ export function packGroupOptions(packs, scans, { skillsOnly = false } = {}) {
54
+ const groups = {}
27
55
  for (const pack of packs) {
28
56
  const items = listPackItems(pack, scans, { skillsOnly })
29
- const names = items.map(item => item.name)
30
- rows.push({
31
- value: pack.id,
32
- label: `${pack.label}(${items.length})`,
33
- description: names.length > 0 ? names.join('、') : pack.description,
34
- })
35
- for (const item of items) {
36
- rows.push({
37
- value: `hint:${pack.id}:${item.type}:${item.name}`,
38
- label: ` ${item.name}`,
39
- description: item.description || pack.label,
40
- disabled: true,
41
- packId: pack.id,
42
- })
43
- }
57
+ if (items.length === 0) continue
58
+ groups[`${pack.label}(${items.length})`] = items.map(item => ({
59
+ value: packChildKey(pack.id, item.type, item.name),
60
+ label: item.name,
61
+ hint: item.description || undefined,
62
+ }))
44
63
  }
45
- return rows
46
- }
47
-
48
- /**
49
- * 子条目在多选里的稳定 key。类型前缀避免 skill/rule 重名撞车。
50
- *
51
- * @param {'skills' | 'rules' | 'hooks'} type
52
- * @param {string} name
53
- */
54
- export function packItemKey(type, name) {
55
- return `${type}:${name}`
64
+ return groups
56
65
  }
57
66
 
58
67
  /**
59
- * 把包展开结果打成一条可搜索列表,按包名分组,便于对照取消。
68
+ * 已选包 → 其下全部子 value,给 groupMultiselect initialValues。
69
+ * 父级勾选态由 clack 根据「子是否全在 value 里」推导,不要传包名。
60
70
  *
61
- * @param {{ skills: object[], rules: object[], hooks: object[] }} selected
62
- * @param {string[]} packIds
71
+ * @param {object[]} packs
72
+ * @param {object} scans
73
+ * @param {string[]} selectedPackIds
74
+ * @param {{ skillsOnly?: boolean }} [opts]
63
75
  */
64
- export function buildPackItemOptions(selected, packIds = []) {
65
- const rows = []
66
- for (const type of ['skills', 'rules', 'hooks']) {
67
- for (const item of selected[type] || []) {
68
- const packLabel = packLabelForItem(item, type, packIds)
69
- rows.push({
70
- value: packItemKey(type, item.name),
71
- label: `${packLabel} / ${item.name}`,
72
- description: item.description,
73
- })
76
+ export function initialChildValues(packs, scans, selectedPackIds, { skillsOnly = false } = {}) {
77
+ const selected = new Set(selectedPackIds)
78
+ const values = []
79
+ for (const pack of packs) {
80
+ if (!selected.has(pack.id)) continue
81
+ for (const item of listPackItems(pack, scans, { skillsOnly })) {
82
+ values.push(packChildKey(pack.id, item.type, item.name))
74
83
  }
75
84
  }
76
- rows.sort((a, b) => a.label.localeCompare(b.label, 'zh'))
77
- return rows
85
+ return values
78
86
  }
79
87
 
80
88
  /**
81
- * 按用户勾选的 key 从展开结果里筛子条目。未勾的丢掉。
89
+ * 把勾上的子 value 还原成条目 + 所属包。
90
+ * 只认扫描结果里真实存在的名字,防止 key 伪造。
82
91
  *
83
- * @param {{ skills: object[], rules: object[], hooks: object[] }} expanded
84
92
  * @param {string[]} keys
93
+ * @param {object} scans
94
+ * @param {{ skillsOnly?: boolean }} [opts]
85
95
  */
86
- export function splitPackItemKeys(expanded, keys) {
87
- const set = new Set(keys)
96
+ export function itemsFromChildKeys(keys, scans, { skillsOnly = false } = {}) {
97
+ const parsed = keys.map(parsePackChildKey).filter(Boolean)
98
+ const packIds = [...new Set(parsed.map(item => item.packId))]
99
+ const names = { skills: new Set(), rules: new Set(), hooks: new Set() }
100
+ for (const item of parsed) names[item.type].add(item.name)
101
+ const pick = (type) => (scans[type] || []).filter(item => names[type].has(item.name))
102
+ if (skillsOnly) {
103
+ return { packIds, skills: pick('skills'), rules: [], commands: [], hooks: [] }
104
+ }
88
105
  return {
89
- skills: expanded.skills.filter(item => set.has(packItemKey('skills', item.name))),
90
- rules: expanded.rules.filter(item => set.has(packItemKey('rules', item.name))),
91
- hooks: expanded.hooks.filter(item => set.has(packItemKey('hooks', item.name))),
106
+ packIds,
107
+ skills: pick('skills'),
108
+ rules: pick('rules'),
92
109
  commands: [],
110
+ hooks: pick('hooks'),
93
111
  }
94
112
  }
95
113
 
96
114
  /**
97
- * 打印包展开条数,随后立刻列出预勾子条目。
115
+ * 打印本次按包勾选的包名与条数。
98
116
  *
99
117
  * @param {string[]} stacks
100
118
  * @param {string[]} scenes
@@ -110,7 +128,7 @@ function printPackSummary(stacks, scenes, selected) {
110
128
  }
111
129
 
112
130
  /**
113
- * 按包选择条目。选包时列出包下子条目;确认后默认全勾,用户可取消。
131
+ * 按包选择条目。Space 勾父级=该包全部子项;光标在子项上可单独取消。
114
132
  * 返回 { skills, rules, commands, hooks, packs } 或 null(取消)。
115
133
  *
116
134
  * @param {object} scans
@@ -119,24 +137,31 @@ function printPackSummary(stacks, scenes, selected) {
119
137
  */
120
138
  export async function interactiveSelectByPacks(scans, syncState, { skillsOnly = false } = {}) {
121
139
  const previous = normalizePacks(syncState?.packs)
122
- let stacks = previous.stacks.length > 0 ? previous.stacks : ['common']
140
+ const packOpts = { skillsOnly }
141
+ const defaultStacks = previous.stacks.length > 0 ? previous.stacks : ['common']
142
+ let stacks = defaultStacks
123
143
  let scenes = previous.scenes
144
+ // 返回重选时保留已取消的子项,不要用包 id 重新全勾
145
+ let stackKeys = initialChildValues(STACK_PACKS, scans, defaultStacks, packOpts)
146
+ let sceneKeys = initialChildValues(SCENE_PACKS, scans, scenes, packOpts)
124
147
  let step = 'stacks'
125
- const packOpts = { skillsOnly }
126
148
 
127
149
  while (true) {
128
150
  if (step === 'stacks') {
129
- const result = await searchableMultiselect({
130
- message: '选择技术栈(通用默认勾选;包下列出子条目,下一步可逐条取消)',
131
- options: packOptionsWithChildren(STACK_PACKS, scans, packOpts),
132
- initialValues: stacks,
151
+ const options = packGroupOptions(STACK_PACKS, scans, packOpts)
152
+ const result = await groupMultiselect({
153
+ message: '选择技术栈(Space 勾整包,也可单独取消某个子项)',
154
+ options,
155
+ initialValues: stackKeys,
133
156
  required: true,
134
157
  maxItems: 15,
135
- placeholder: SEARCH_PLACEHOLDER,
136
- descLines: 8,
158
+ selectableGroups: true,
137
159
  })
138
160
  if (isCancel(result)) return null
139
- stacks = result.filter(id => !String(id).startsWith('hint:'))
161
+ stackKeys = result
162
+ stacks = STACK_PACKS.map(pack => pack.id).filter(id =>
163
+ stackKeys.some(key => parsePackChildKey(key)?.packId === id),
164
+ )
140
165
 
141
166
  const nav = await searchableSelect({
142
167
  message: `已选 ${stacks.length} 个技术栈。下一步:`,
@@ -153,41 +178,33 @@ export async function interactiveSelectByPacks(scans, syncState, { skillsOnly =
153
178
  continue
154
179
  }
155
180
 
156
- if (step === 'scenes') {
157
- const result = await searchableMultiselect({
158
- message: '选择场景包(可全不选;包下列出子条目,下一步可逐条取消)',
159
- options: packOptionsWithChildren(SCENE_PACKS, scans, packOpts),
160
- initialValues: scenes,
181
+ const sceneOptions = packGroupOptions(SCENE_PACKS, scans, packOpts)
182
+ if (Object.keys(sceneOptions).length > 0) {
183
+ const result = await groupMultiselect({
184
+ message: '选择场景包(可全不选;Space 勾整包,也可单独取消某个子项)',
185
+ options: sceneOptions,
186
+ initialValues: sceneKeys,
161
187
  required: false,
162
188
  maxItems: 15,
163
- placeholder: SEARCH_PLACEHOLDER,
164
- descLines: 8,
189
+ selectableGroups: true,
165
190
  })
166
191
  if (isCancel(result)) return null
167
- scenes = result.filter(id => !String(id).startsWith('hint:'))
168
- step = 'items'
169
- continue
170
- }
171
-
172
- const expanded = expandPackSelection(scans, { stacks, scenes, skillsOnly })
173
- printPackSummary(stacks, scenes, expanded)
174
- const packIds = [...stacks, ...scenes]
175
- const options = buildPackItemOptions(expanded, packIds)
176
- if (options.length === 0) {
177
- return { skills: [], rules: [], commands: [], hooks: [], packs: { stacks, scenes } }
192
+ sceneKeys = result
193
+ scenes = SCENE_PACKS.map(pack => pack.id).filter(id =>
194
+ sceneKeys.some(key => parsePackChildKey(key)?.packId === id),
195
+ )
196
+ } else {
197
+ sceneKeys = []
198
+ scenes = []
178
199
  }
179
200
 
180
- const keys = await searchableMultiselect({
181
- message: '已按包预勾子条目,取消不需要的即可',
182
- options,
183
- initialValues: options.map(option => option.value),
184
- required: false,
185
- maxItems: 15,
186
- placeholder: SEARCH_PLACEHOLDER,
187
- })
188
- if (isCancel(keys)) return null
201
+ const selected = itemsFromChildKeys([...stackKeys, ...sceneKeys], scans, packOpts)
202
+ printPackSummary(stacks, scenes, selected)
189
203
  return {
190
- ...splitPackItemKeys(expanded, keys),
204
+ skills: selected.skills,
205
+ rules: selected.rules,
206
+ commands: selected.commands,
207
+ hooks: selected.hooks,
191
208
  packs: { stacks, scenes },
192
209
  }
193
210
  }