create-dwy 0.25.0 → 0.25.2

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,24 @@
1
1
  # create-dwy
2
2
 
3
+ ## [0.25.2] - 2026-11-06
4
+
5
+ ### Patch Changes
6
+
7
+ - 恢复 AI 模板随 CLI 包分发,移除启动时的远端 Git 检查、下载和模板缓存读取,避免运行 `dwy` 长时间等待。Skills 变更通过发布 CLI 交付,`dwy refresh` 从当前包内模板刷新已安装副本;保留显式开发目录覆盖。
8
+ - 补齐普通项目交互的全局 Skills 禁选:有效全局入口已覆盖的条目标注“全局已启用”,禁止重复勾选;末步新增其他 CLI 时只补缺失入口,保留已有全局内容。
9
+ - 保留范围优先、CLI 末尾选择、全局规范与 Hooks 跳页以及升级后的实际版本校验。
10
+
11
+ ---
12
+
13
+ ## [0.25.1] - 2026-11-06
14
+
15
+ ### Patch Changes
16
+
17
+ - 修复 `dwy upgrade` 查询官方源却从滞后镜像安装 `latest`,实际仍是旧版却显示升级成功的问题。查询与安装使用同一 registry,并固定查询所得的正式版本,不修改用户的镜像配置。
18
+ - 安装后检查当前 PATH 中实际执行的 `dwy --version`,版本一致才刷新共享 Skills 并报告成功;刷新失败单独报错。补充旧版入口、真实子进程回读和 registry 输入校验回归。
19
+
20
+ ---
21
+
3
22
  ## [0.25.0] - 2026-11-06
4
23
 
5
24
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-dwy",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "项目脚手架与多平台 AI 配置同步工具",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli-help.js CHANGED
@@ -17,7 +17,7 @@ export function buildHelpText(version) {
17
17
  ' dwy 选择范围、配置和 CLI,预览清单后同步',
18
18
  ' dwy sync 同上',
19
19
  ' dwy upgrade 升级到 npm 最新正式版,并刷新共享 Skills',
20
- ' dwy refresh 用最新模板刷新已安装的共享 Skills,无需升级 CLI',
20
+ ' dwy refresh 用当前 CLI 包内模板刷新已安装的共享 Skills',
21
21
  ' dwy migrate 一键迁移老项目已有的托管 Skills,原目录先备份',
22
22
  ' dwy --help 显示本说明',
23
23
  ' dwy --version 显示版本',
@@ -59,16 +59,16 @@ export function buildHelpText(version) {
59
59
  '用户主目录不能作为项目范围同步,请选择全局或进入项目目录。',
60
60
  '关闭选项只移除当前范围托管内容;项目操作不移除全局配置。',
61
61
  'Skills 副本集中在 ~/.dwy/skills;项目和全局使用目录链接,同平台全局入口优先。',
62
- '项目 Skills 全部适用平台均有全局入口时显示「全局已启用」,不能重复勾选。',
63
- '部分平台已有全局入口时仍可选,只为缺少全局入口的平台安装项目链接。',
62
+ '项目 Skills 全部适用平台均有有效全局入口时显示「全局已启用」,不能重复勾选。',
63
+ '部分平台已有有效全局入口时仍可选,只为缺少全局入口的平台安装项目链接。',
64
64
  'Hooks 与 Skills 独立;整组管理,项目仅安装缺少有效全局注册的 Hooks。',
65
65
  '全局 Hook 的脚本与对应事件、matcher、完整命令注册均有效时,项目禁止重复安装。',
66
- '任一适用 CLI 已有 dwy 全局通用规范时,项目继承并清理重复基线;最终 CLI 中缺失的全局规范需补齐。',
66
+ '任一适用 CLI 已有 dwy 全局通用规范时,项目继承并清理重复基线;其他 CLI 缺失的全局规范另选全局范围补齐。',
67
67
  '手写 AGENTS.md 本身不算全局已安装;通用规范与 Rules 共用文件但独立管理。',
68
68
  '移除通用规范仅删除基线块;项目 Rules 独立管理,保留手写内容。',
69
69
  '全局启用会明确列出当前项目重复项清理;全局移除不会自动向项目补装。',
70
70
  '项目同步只清理项目重复入口,不改写有效全局提供者;真实目录先备份。',
71
- '确认前仅可更新模板缓存;确认后才改项目/全局配置,并刷新所选共享 Skills。',
71
+ '确认前不改项目/全局配置或已安装共享内容;确认后才同步配置,并刷新所选共享 Skills。',
72
72
  '由有效全局入口提供的 Skills 不借项目同步改写;全部已安装副本用 dwy refresh 更新。',
73
73
  'sync / migrate 脚本接口仍须显式指定范围。',
74
74
  '产品流程缺失的外部依赖也列入清单,确认后联网补齐,已有内容保留。',
@@ -77,10 +77,9 @@ export function buildHelpText(version) {
77
77
  'Rules / Hooks 的模板更新仍需执行 dwy sync;已有会话缓存的 Skill 可能需要重新加载。',
78
78
  '',
79
79
  '模板更新:',
80
- ' 固定公开仓库 https://github.com/danweiyuancircle/dwy-ai-experience.git 的 master 分支 dwy-cli/templates/ai-tools。',
81
- ' DWY_TEMPLATE_SOURCE_DIR 开发覆盖优先;远端模板缓存在 ~/.dwy/template-source。',
82
- ' 未变更只检查 commit;离线保留有效缓存,无有效缓存时回退包内模板并提示。',
83
- ' 新增 Skills 只需更新模板分发分支,无需发布 CLI;dwy upgrade 更新程序。',
80
+ ' 模板随 CLI 包分发,启动不联网检查模板。',
81
+ ' DWY_TEMPLATE_SOURCE_DIR 开发覆盖优先;未设置时读取当前 CLI 的 dwy-cli/templates/ai-tools。',
82
+ ' Skills 新增或变更需要 CLI 发版;dwy upgrade 更新程序与包内模板。',
84
83
  '',
85
84
  ].join('\n')
86
85
  }
@@ -4,15 +4,36 @@
4
4
  * 源码目录 / 非全局安装拒绝执行,避免把仓库工作树覆盖成 npm 包。
5
5
  */
6
6
 
7
- import { spawn } from 'node:child_process'
7
+ import { execFile, spawn } from 'node:child_process'
8
8
  import { readFileSync } from 'node:fs'
9
9
  import path from 'node:path'
10
+ import { promisify } from 'node:util'
10
11
  import { PACKAGE_ROOT, chalk } from './utils.js'
11
12
 
12
13
  /** npm 包名,bin 是 dwy */
13
14
  export const PACKAGE_NAME = 'create-dwy'
14
15
 
15
16
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'
17
+ const execFileAsync = promisify(execFile)
18
+
19
+ function upgradeRegistry() {
20
+ const value = process.env.npm_config_registry || DEFAULT_REGISTRY
21
+ let registry
22
+ try {
23
+ registry = new URL(value)
24
+ } catch {
25
+ throw new Error('npm_config_registry 必须是有效的 HTTP(S) registry 地址')
26
+ }
27
+ if (process.platform === 'win32' && /[&|<>^%!"()\r\n]/.test(value + registry.href)) {
28
+ throw new Error('npm_config_registry 含 Windows 命令解释器不支持的字符')
29
+ }
30
+ if (!['http:', 'https:'].includes(registry.protocol) || registry.search || registry.hash) {
31
+ throw new Error('npm_config_registry 必须是无查询参数及片段的 HTTP(S) registry 地址')
32
+ }
33
+ // 原生 fetch 不支持 URL 凭据,直接传入会在错误消息里泄露原地址。
34
+ if (registry.username || registry.password) throw new Error('npm_config_registry 不支持 URL 内嵌凭据')
35
+ return registry.href.replace(/\/?$/, '/')
36
+ }
16
37
 
17
38
  /**
18
39
  * 比较 a、b 两个三段版本号。a>b 返回 1,相等 0,a<b -1。
@@ -49,17 +70,18 @@ export function detectInstaller(pkgRoot) {
49
70
  }
50
71
 
51
72
  /**
52
- * 构造升级命令。一律钉 @latest,不用 @next/@beta。
73
+ * 固定本轮查询到的正式版本及 registry,避免安装器的镜像 latest 滞后导致降回旧包。
53
74
  *
54
75
  * @param {'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown'} installer
76
+ * @param {string} version 来自已校验的正式版本查询结果
55
77
  * @returns {[string, string[]] | null}
56
78
  */
57
- export function buildUpgradeCommand(installer) {
58
- const spec = `${PACKAGE_NAME}@latest`
59
- if (installer === 'pnpm') return ['pnpm', ['add', '-g', spec]]
60
- if (installer === 'yarn') return ['yarn', ['global', 'add', spec]]
61
- if (installer === 'bun') return ['bun', ['add', '-g', spec]]
62
- if (installer === 'npm') return ['npm', ['install', '-g', spec]]
79
+ export function buildUpgradeCommand(installer, version) {
80
+ const spec = `${PACKAGE_NAME}@${version}`, registry = `--registry=${upgradeRegistry()}`
81
+ if (installer === 'pnpm') return ['pnpm', ['add', '-g', spec, registry]]
82
+ if (installer === 'yarn') return ['yarn', ['global', 'add', spec, registry]]
83
+ if (installer === 'bun') return ['bun', ['add', '-g', spec, registry]]
84
+ if (installer === 'npm') return ['npm', ['install', '-g', spec, registry]]
63
85
  return null
64
86
  }
65
87
 
@@ -79,16 +101,17 @@ function readCurrentVersion(pkgRoot = PACKAGE_ROOT) {
79
101
  * @param {typeof fetch} [fetchFn]
80
102
  */
81
103
  export async function fetchLatestVersion(fetchFn = globalThis.fetch) {
82
- const registry = (process.env.npm_config_registry || DEFAULT_REGISTRY).replace(/\/?$/, '/')
104
+ const registry = upgradeRegistry()
83
105
  const url = `${registry}${PACKAGE_NAME}/latest`
84
106
  const response = await fetchFn(url, { headers: { Accept: 'application/json', 'User-Agent': 'create-dwy' } })
85
107
  if (!response.ok) {
86
- throw new Error(`查询最新版本失败:${url} HTTP ${response.status}`)
108
+ const { origin, pathname } = new URL(url)
109
+ throw new Error(`查询最新版本失败:${origin}${pathname} HTTP ${response.status}`)
87
110
  }
88
111
  const body = await response.json()
89
112
  const version = body?.version
90
- if (!version || typeof version !== 'string') {
91
- throw new Error(`npm latest 未返回 version:${url}`)
113
+ if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) {
114
+ throw new Error('npm latest 未返回有效的正式 version')
92
115
  }
93
116
  return version
94
117
  }
@@ -114,6 +137,15 @@ function runSpawn(cmd, args) {
114
137
  })
115
138
  }
116
139
 
140
+ async function readActiveVersion() {
141
+ const { stdout } = await execFileAsync('dwy', ['--version'], {
142
+ shell: process.platform === 'win32', timeout: 10000,
143
+ })
144
+ const version = stdout.trim()
145
+ if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error('无法从当前 PATH 中的 dwy 读取有效版本')
146
+ return version
147
+ }
148
+
117
149
  /**
118
150
  * 执行自升级。返回结构化结果供测试断言;失败抛错。
119
151
  *
@@ -122,17 +154,18 @@ function runSpawn(cmd, args) {
122
154
  * @param {string} [opts.pkgRoot]
123
155
  * @param {() => Promise<string>} [opts.fetchLatest]
124
156
  * @param {(cmd: string, args: string[]) => Promise<void>} [opts.runCommand]
157
+ * @param {() => Promise<string>} [opts.readInstalledVersion] 回读 PATH 中实际将用于刷新的 CLI 版本
125
158
  */
126
159
  export async function selfUpgrade({
127
160
  currentVersion,
128
161
  pkgRoot = PACKAGE_ROOT,
129
162
  fetchLatest = fetchLatestVersion,
130
163
  runCommand = runSpawn,
164
+ readInstalledVersion = readActiveVersion,
131
165
  } = {}) {
132
166
  const current = currentVersion || readCurrentVersion(pkgRoot)
133
167
  const installer = detectInstaller(pkgRoot)
134
- const command = buildUpgradeCommand(installer)
135
- if (!command) {
168
+ if (installer === 'unknown') {
136
169
  throw new Error(
137
170
  `当前不是全局安装的 ${PACKAGE_NAME}(路径 ${pkgRoot}),无法自升级。请先 npm i -g ${PACKAGE_NAME},或在源码仓 git pull。`,
138
171
  )
@@ -140,25 +173,30 @@ export async function selfUpgrade({
140
173
 
141
174
  const latest = await fetchLatest()
142
175
  const cmp = compareVersions(current, latest)
143
- if (cmp === 0) {
144
- await runCommand('dwy', ['refresh'])
145
- console.log(chalk.green(`已是最新正式版 ${current}`))
146
- return { status: 'up-to-date', current, latest, installer }
147
- }
148
176
  if (cmp > 0) {
149
177
  console.log(chalk.yellow(`当前 ${current} 新于 npm latest ${latest},不降级`))
150
178
  return { status: 'newer-local', current, latest, installer }
151
179
  }
152
180
 
153
- const [cmd, args] = command
154
- console.log(chalk.blue(`升级 ${PACKAGE_NAME} ${current} → ${latest}`))
155
- console.log(chalk.gray(` ${cmd} ${args.join(' ')}`))
156
- await runCommand(cmd, args)
181
+ if (cmp < 0) {
182
+ const [cmd, args] = buildUpgradeCommand(installer, latest)
183
+ console.log(chalk.blue(`升级 ${PACKAGE_NAME} ${current} → ${latest}`))
184
+ console.log(chalk.gray(` ${cmd} ${args.join(' ')}`))
185
+ await runCommand(cmd, args)
186
+ }
187
+ const installed = await readInstalledVersion()
188
+ if (installed !== latest) {
189
+ throw new Error(`升级未生效:目标 ${latest},当前 PATH 中 dwy --version 为 ${installed};未刷新共享 Skills。请检查全局安装路径与 registry。`)
190
+ }
157
191
  try {
158
192
  // 必须启动新安装的 CLI;pnpm 升级后旧进程的模块路径仍可能指向旧版本。
159
193
  await runCommand('dwy', ['refresh'])
160
194
  } catch (error) {
161
- throw new Error(`CLI 已安装新版本,但共享 Skills 刷新失败:${error.message}`, { cause: error })
195
+ throw new Error(`CLI ${installed} 已就绪,但共享 Skills 刷新失败:${error.message}`, { cause: error })
196
+ }
197
+ if (cmp === 0) {
198
+ console.log(chalk.green(`已是最新正式版 ${installed}`))
199
+ return { status: 'up-to-date', current, latest, installer }
162
200
  }
163
201
  console.log(chalk.green(`已升级到 ${latest}`))
164
202
  return { status: 'upgraded', current, latest, installer }
package/src/sync-all.js CHANGED
@@ -279,8 +279,7 @@ export async function syncAll({
279
279
  unionSets(...platforms.map(platform => platform === 'cursor' && type === 'rules' ? cursorRules : existing[platform][type]))]))
280
280
  const defaults = buildSelectionDefaultsFromSyncState(current, targetState, platforms)
281
281
  for (const type of ['skills', 'rules', 'hooks']) defaults[type] = unionSets(defaults[type], current[type])
282
- // CLI 尚未选择时允许勾选所有 Skills;最终计划按实际 CLI 去重,避免提前禁用其他 CLI 所需条目。
283
- const choices = platformsOverride && !global && contentTypes.includes('skills')
282
+ const choices = !global && contentTypes.includes('skills')
284
283
  ? { ...scans, skills: await projectSkillChoices(scans.skills, platforms, { homeDir, codexHome }) } : scans
285
284
  const selected = selectedOverride
286
285
  ? { skills: selectedOverride.skills || [], rules: selectedOverride.rules || [], hooks: [], commands: [] }
@@ -324,6 +323,8 @@ export async function syncAll({
324
323
  platforms = await selectPlatforms(targetRoot, scope, platforms)
325
324
  if (platforms === null) { console.log(chalk.yellow('已取消同步。')); return }
326
325
  }
326
+ // 禁选的全局 Skills 仍处于启用状态;末步新增 CLI 时只补缺失入口,已有全局源保持原样。
327
+ if (!selectedOverride && !global) selected.skills.push(...choices.skills.filter(skill => skill.disabled))
327
328
  const options = contentOptions(scope, platforms)
328
329
  if (mainFlow) contentTypes = contentTypes.filter(type => options.some(option => option.value === type))
329
330
  const stale = staleEntries(scans, targetState, existing, platforms)
package/src/sync.js CHANGED
@@ -2,9 +2,8 @@ import fs from 'fs-extra'
2
2
  import path from 'path'
3
3
  import { isCancel } from '@clack/prompts'
4
4
  import { tabMultiselect } from './tab-multiselect.js'
5
- import { chalk } from './utils.js'
5
+ import { chalk, PACKAGE_ROOT } from './utils.js'
6
6
  import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
7
- import { resolveTemplateSource } from './template-source.js'
8
7
 
9
8
  const CATEGORIES = [
10
9
  { key: 'skills', label: 'Skills' },
@@ -391,9 +390,14 @@ async function syncSettings(sourceDir, targetDir, selectedHookNames, global, sta
391
390
  return 1
392
391
  }
393
392
 
394
- /** 解析模板源并检查公开模板更新;调用方可注入 homeDir 隔离用户缓存。 */
395
- export async function resolveSourceDir(options) {
396
- return resolveTemplateSource(options)
393
+ /** 只读取当前 CLI 的随包模板;开发时允许显式覆盖,不联网检查更新。 */
394
+ export async function resolveSourceDir() {
395
+ const override = process.env.DWY_TEMPLATE_SOURCE_DIR
396
+ const sourceDir = override || path.join(PACKAGE_ROOT, 'templates', 'ai-tools')
397
+ if (!await fs.pathExists(sourceDir)) {
398
+ throw new Error(override ? `DWY_TEMPLATE_SOURCE_DIR not found: ${override}` : 'ai-tools templates not found in package')
399
+ }
400
+ return sourceDir
397
401
  }
398
402
 
399
403
  /** 同步 Claude Skills 与 Hooks;基线和项目 Rules 由总同步入口分别处理。 */
@@ -1,218 +0,0 @@
1
- import fs from 'fs-extra'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
- import { execFile } from 'node:child_process'
5
- import { promisify } from 'node:util'
6
- import { randomUUID } from 'node:crypto'
7
- import { spinner } from '@clack/prompts'
8
- import { PACKAGE_ROOT } from './utils.js'
9
-
10
- const execFileAsync = promisify(execFile)
11
- const REMOTE = 'https://github.com/danweiyuancircle/dwy-ai-experience.git'
12
- const TEMPLATE_PATH = ['dwy-cli', 'templates', 'ai-tools']
13
- const REVISION_FILE = '.dwy-source-revision'
14
-
15
- async function statEntry(file) {
16
- return fs.lstat(file).catch(error => {
17
- if (error.code === 'ENOENT') return null
18
- throw error
19
- })
20
- }
21
-
22
- async function requireDirectory(directory) {
23
- const stat = await fs.lstat(directory)
24
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`模板目录不能是软链接或文件:${directory}`)
25
- }
26
-
27
- async function validateTemplates(directory) {
28
- await requireDirectory(directory)
29
- await validateTree(directory)
30
- for (const name of ['skills', 'rules', 'hooks']) await requireDirectory(path.join(directory, name))
31
- const baseline = path.join(directory, 'AGENTS.md')
32
- if (!(await fs.lstat(baseline)).isFile() || !(await fs.readFile(baseline, 'utf8')).trim()) {
33
- throw new Error('模板缺少有效 AGENTS.md')
34
- }
35
- }
36
-
37
- async function validateTree(directory) {
38
- for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
39
- if (entry.isSymbolicLink()) throw new Error(`模板不能包含软链接:${entry.name}`)
40
- if (entry.isDirectory()) await validateTree(path.join(directory, entry.name))
41
- }
42
- }
43
-
44
- async function deadLockExists(lockPath) {
45
- const stat = await statEntry(lockPath)
46
- if (!stat) return false
47
- if (!stat.isFile()) throw new Error(`模板锁无效,保留原文件:${lockPath}`)
48
- const owner = await fs.readFile(lockPath, 'utf8').catch(error => {
49
- if (error.code === 'ENOENT') return null
50
- throw error
51
- })
52
- if (owner === null) return false
53
- const pid = Number(owner.trim())
54
- if (!/^[1-9]\d*\s*$/.test(owner) || !Number.isSafeInteger(pid) || pid > 2147483647) {
55
- throw new Error(`模板锁无效,保留原文件:${lockPath}`)
56
- }
57
- try {
58
- process.kill(pid, 0)
59
- } catch (error) {
60
- if (error.code === 'ESRCH') return true
61
- if (error.code !== 'EPERM') throw error
62
- }
63
- throw new Error(`进程 ${pid} 正在更新模板,保留现有锁。`)
64
- }
65
-
66
- async function acquireLock(lockPath, candidate) {
67
- while (true) {
68
- try {
69
- await fs.link(candidate, lockPath)
70
- return
71
- } catch (error) {
72
- if (error.code !== 'EEXIST') throw error
73
- }
74
- if (!await deadLockExists(lockPath)) continue
75
- // 回收也须互斥,避免两个进程删掉对方的新锁;回收中断时按同一协议恢复。
76
- const recovery = `${lockPath}.recovery`
77
- await acquireLock(recovery, candidate)
78
- try {
79
- if (await deadLockExists(lockPath)) await fs.unlink(lockPath)
80
- try {
81
- await fs.link(candidate, lockPath)
82
- return
83
- } catch (error) {
84
- if (error.code !== 'EEXIST') throw error
85
- }
86
- } finally {
87
- await fs.unlink(recovery)
88
- }
89
- }
90
- }
91
-
92
- async function git(args, cwd) {
93
- try {
94
- const { stdout } = await execFileAsync('git', ['-c', 'core.hooksPath=/dev/null', ...args], {
95
- cwd,
96
- // 不加载用户安装的 hooks 或过滤器,避免检出远端内容时执行脚本。
97
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' },
98
- // 首次检出要获取整套模板,不能沿用轻量版本检查的短超时。
99
- timeout: ['clone', 'checkout', 'fetch', 'reset'].includes(args[0]) ? 300000 : 15000,
100
- maxBuffer: 1024 * 1024,
101
- })
102
- return stdout.trim()
103
- } catch (error) {
104
- throw new Error(`Git 获取模板失败(${error.code || error.signal})`)
105
- }
106
- }
107
-
108
- async function refreshTemplates(root, cache) {
109
- await fs.ensureDir(root)
110
- await requireDirectory(root)
111
- const lockPath = path.join(root, 'template-source.lock')
112
- const candidate = path.join(root, `.template-source-lock-${randomUUID()}`)
113
- await fs.writeFile(candidate, `${process.pid}\n`, { flag: 'wx' })
114
- try {
115
- await acquireLock(lockPath, candidate)
116
- } finally {
117
- await fs.unlink(candidate)
118
- }
119
- let staging
120
- const previous = path.join(root, '.template-source.previous')
121
- try {
122
- if (await statEntry(cache)) await requireDirectory(cache)
123
- if (await statEntry(previous)) {
124
- await validateTemplates(previous)
125
- if (await statEntry(cache)) {
126
- await validateTemplates(cache)
127
- await fs.remove(previous)
128
- } else {
129
- await fs.rename(previous, cache)
130
- }
131
- }
132
- const revision = (await git(['ls-remote', '--exit-code', REMOTE, 'refs/heads/master'], root)).split(/\s/)[0]
133
- if (!/^[a-f\d]{40,64}$/.test(revision)) throw new Error('远端 master 版本无效')
134
- if (await statEntry(cache)) {
135
- try {
136
- await validateTemplates(cache)
137
- if ((await fs.readFile(path.join(cache, REVISION_FILE), 'utf8')).trim() === revision) return
138
- } catch {
139
- // 本地缓存不完整时重新获取,不能只凭版本标记跳过内容校验。
140
- }
141
- }
142
- staging = await fs.mkdtemp(path.join(root, '.template-source-'))
143
- const storedRepository = path.join(root, 'template-repository')
144
- const reuse = !!await statEntry(storedRepository)
145
- const repository = reuse ? storedRepository : path.join(staging, 'repository')
146
- if (reuse) {
147
- await requireDirectory(repository)
148
- await requireDirectory(path.join(repository, '.git'))
149
- await git(['fetch', '--depth=1', '--filter=blob:none', REMOTE, 'master'], repository)
150
- await git(['reset', '--hard', 'FETCH_HEAD'], repository)
151
- } else {
152
- await git(['clone', '--depth=1', '--filter=blob:none', '--no-checkout', '--single-branch',
153
- '--branch', 'master', REMOTE, repository], staging)
154
- await git(['sparse-checkout', 'set', '--cone', TEMPLATE_PATH.join('/')], repository)
155
- await git(['checkout', '--force', 'master'], repository)
156
- }
157
- let candidate = repository
158
- for (const segment of TEMPLATE_PATH) {
159
- candidate = path.join(candidate, segment)
160
- await requireDirectory(candidate)
161
- }
162
- await validateTemplates(candidate)
163
- const next = path.join(staging, 'templates')
164
- await fs.copy(candidate, next)
165
- await fs.writeFile(path.join(next, REVISION_FILE), await git(['rev-parse', 'HEAD'], repository))
166
- if (!reuse) await fs.rename(repository, storedRepository)
167
- const hadCache = !!await statEntry(cache)
168
- if (hadCache) await fs.rename(cache, previous)
169
- try {
170
- await fs.rename(next, cache)
171
- } catch (error) {
172
- if (hadCache) await fs.rename(previous, cache)
173
- throw error
174
- }
175
- if (hadCache) await fs.remove(previous)
176
- } finally {
177
- try {
178
- if (staging) await fs.remove(staging)
179
- } finally {
180
- await fs.unlink(lockPath)
181
- }
182
- }
183
- }
184
-
185
- /**
186
- * 从固定公开仓库更新用户模板缓存;只读取已验证的目录,不执行模板脚本。
187
- * 显式目录优先;DWY_HOME 优先于注入 homeDir,更新失败保留缓存,无有效缓存才用随包模板。
188
- */
189
- export async function resolveTemplateSource({ homeDir = os.homedir() } = {}) {
190
- const explicitSourceDir = process.env.DWY_TEMPLATE_SOURCE_DIR
191
- if (explicitSourceDir) {
192
- if (!await fs.pathExists(explicitSourceDir)) {
193
- throw new Error(`DWY_TEMPLATE_SOURCE_DIR not found: ${explicitSourceDir}`)
194
- }
195
- return explicitSourceDir
196
- }
197
- const root = process.env.DWY_HOME || path.join(homeDir, '.dwy')
198
- const cache = path.join(root, 'template-source')
199
- const progress = process.stdout.isTTY ? spinner() : null
200
- progress?.start('正在检查 AI 模板更新,首次获取可能较慢')
201
- try {
202
- await refreshTemplates(root, cache)
203
- progress?.stop('AI 模板已就绪')
204
- return cache
205
- } catch (error) {
206
- progress?.stop('模板更新未完成,正在检查本地来源')
207
- try {
208
- await validateTemplates(cache)
209
- } catch {
210
- const bundled = path.join(PACKAGE_ROOT, 'templates', 'ai-tools')
211
- await validateTemplates(bundled)
212
- console.warn(`模板更新失败(${error.message});无有效缓存,回退随包模板。`)
213
- return bundled
214
- }
215
- console.warn(`模板更新失败(${error.message});继续使用本地缓存。`)
216
- return cache
217
- }
218
- }