draftgo-cli 1.0.0 → 1.1.1

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.
@@ -1,12 +1,18 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  DraftGo Sync Script
4
- 推送本地页面 / 导航栏 / DB Meta 修改到云端。
4
+ 推送本地修改到云端。覆盖所有 init 拉取的类型:
5
+ pages / nav / db_meta / aihub / external_apis / system_config / roles / users
5
6
 
6
7
  用法:
7
- python draftgo_sync.py pages [page_id ...]
8
- python draftgo_sync.py nav [nav_id ...]
9
- python draftgo_sync.py db_meta [db_meta_id ...]
8
+ python draftgo_sync.py pages [page_id ...]
9
+ python draftgo_sync.py nav [nav_id ...]
10
+ python draftgo_sync.py db_meta [db_meta_id ...]
11
+ python draftgo_sync.py aihub [aihub_id ...]
12
+ python draftgo_sync.py external_apis [api_id ...]
13
+ python draftgo_sync.py system_config [config_key ...]
14
+ python draftgo_sync.py roles [role_id ...] ⚠️ 涉及权限,调用前应人工确认
15
+ python draftgo_sync.py users [user_id ...] ⚠️ 涉及账号,调用前应人工确认
10
16
  """
11
17
  import json, sys
12
18
  from pathlib import Path
@@ -131,19 +137,113 @@ def sync_nav(server, token, ids=None):
131
137
  print(f" {'OK' if ok else 'ERR'} [{name}] nav_id={nid}{'' if ok else ' -> ' + str(info)}")
132
138
 
133
139
 
140
+ def sync_aihub(server, token, ids=None):
141
+ items = _load_index("aihub/index.json")
142
+ if ids:
143
+ items = [it for it in items if str(it.get("id")) in ids]
144
+ for it in items:
145
+ iid = it.get("id")
146
+ name = it.get("name", iid)
147
+ # 服务端 AIHubUpdate 接受的字段子集
148
+ payload = {k: it.get(k) for k in (
149
+ "type", "name", "data", "priority", "version",
150
+ "tags", "describe", "permission", "status",
151
+ ) if it.get(k) is not None}
152
+ ok, info = api_call("PUT", server, token, f"/api/aihub/{iid}", payload)
153
+ print(f" {'OK' if ok else 'ERR'} [{name}] aihub_id={iid}{'' if ok else ' -> ' + str(info)}")
154
+
155
+
156
+ def sync_external_apis(server, token, ids=None):
157
+ items = _load_index("external_apis/index.json")
158
+ if ids:
159
+ items = [it for it in items if str(it.get("id")) in ids]
160
+ for it in items:
161
+ iid = it.get("id")
162
+ code = it.get("code", iid)
163
+ # 服务端 ExternalAPIUpdate 接受字段
164
+ payload = {k: it.get(k) for k in (
165
+ "name", "base_url", "method", "path", "headers",
166
+ "auth_type", "auth_config", "timeout_ms", "permission",
167
+ "param_schema", "tags", "description", "status",
168
+ ) if it.get(k) is not None}
169
+ ok, info = api_call("PUT", server, token, f"/api/external-apis/{iid}", payload)
170
+ print(f" {'OK' if ok else 'ERR'} [{code}] api_id={iid}{'' if ok else ' -> ' + str(info)}")
171
+
172
+
173
+ def sync_system_config(server, token, keys=None):
174
+ items = _load_index("system_config/index.json")
175
+ if keys:
176
+ items = [it for it in items if str(it.get("config_key")) in keys]
177
+ for it in items:
178
+ ck = it.get("config_key")
179
+ if not ck:
180
+ continue
181
+ # 优先使用 parsed_value(init 时 GET 返回的解析值),其次 config_value
182
+ value = it.get("parsed_value") if "parsed_value" in it else it.get("config_value")
183
+ payload = {
184
+ "config_value": value,
185
+ "value_type": it.get("value_type"),
186
+ "category": it.get("category"),
187
+ "description": it.get("description"),
188
+ "is_sensitive": it.get("is_sensitive"),
189
+ "status": it.get("status"),
190
+ }
191
+ payload = {k: v for k, v in payload.items() if v is not None}
192
+ ok, info = api_call("PUT", server, token, f"/api/system/{ck}", payload)
193
+ print(f" {'OK' if ok else 'ERR'} [{ck}]{'' if ok else ' -> ' + str(info)}")
194
+
195
+
196
+ def sync_roles(server, token, ids=None):
197
+ items = _load_index("roles/index.json")
198
+ if ids:
199
+ items = [it for it in items if str(it.get("id")) in ids]
200
+ for it in items:
201
+ rid = it.get("id")
202
+ code = it.get("code", rid)
203
+ # RoleUpdateRequest 字段
204
+ payload = {k: it.get(k) for k in (
205
+ "name", "description", "status", "sort_order", "user_visible",
206
+ ) if it.get(k) is not None}
207
+ ok, info = api_call("PUT", server, token, f"/api/roles/{rid}", payload)
208
+ print(f" {'OK' if ok else 'ERR'} [{code}] role_id={rid}{'' if ok else ' -> ' + str(info)}")
209
+
210
+
211
+ def sync_users(server, token, ids=None):
212
+ items = _load_index("users/index.json")
213
+ if ids:
214
+ items = [it for it in items if str(it.get("id")) in ids]
215
+ for it in items:
216
+ uid = it.get("id")
217
+ uname = it.get("username", uid)
218
+ # UserUpdateRequest 字段子集(不下发 password / role_ids 之类敏感修改)
219
+ payload = {k: it.get(k) for k in (
220
+ "username", "email", "phone_number", "nickname",
221
+ "avatar", "status", "notes",
222
+ ) if it.get(k) is not None}
223
+ ok, info = api_call("PUT", server, token, f"/api/users/{uid}", payload)
224
+ print(f" {'OK' if ok else 'ERR'} [{uname}] user_id={uid}{'' if ok else ' -> ' + str(info)}")
225
+
226
+
227
+ HANDLERS = {
228
+ "pages": sync_pages,
229
+ "nav": sync_nav,
230
+ "db_meta": sync_db_meta,
231
+ "aihub": sync_aihub,
232
+ "external_apis": sync_external_apis,
233
+ "system_config": sync_system_config,
234
+ "roles": sync_roles,
235
+ "users": sync_users,
236
+ }
237
+
238
+
134
239
  def main():
135
- if len(sys.argv) < 2 or sys.argv[1] not in ("pages", "nav", "db_meta"):
136
- print("usage: draftgo_sync.py pages|nav|db_meta [id ...]")
240
+ if len(sys.argv) < 2 or sys.argv[1] not in HANDLERS:
241
+ print(f"usage: draftgo_sync.py {{{'|'.join(HANDLERS)}}} [id ...]")
137
242
  sys.exit(1)
138
243
  mode = sys.argv[1]
139
244
  ids = sys.argv[2:] or None
140
245
  server, token = load_config()
141
- if mode == "pages":
142
- sync_pages(server, token, ids)
143
- elif mode == "db_meta":
144
- sync_db_meta(server, token, ids)
145
- else:
146
- sync_nav(server, token, ids)
246
+ HANDLERS[mode](server, token, ids)
147
247
 
148
248
 
149
249
  if __name__ == "__main__":
@@ -1,45 +1,102 @@
1
1
  ---
2
2
  name: draftgo-sync
3
- description: Use this skill when the user says "同步页面", "同步导航", "同步数据库", "sync pages", "sync nav", "sync db_meta", "/draftgo sync", or wants to push local page/navigation/db_meta changes to the DraftGo server.
4
- version: 1.0.0
3
+ description: Use this skill when the user says "同步页面", "同步导航", "同步数据库", "同步AI资产", "同步外部API", "同步系统配置", "同步角色", "同步用户", "sync pages", "sync nav", "sync db_meta", "sync aihub", "sync external_apis", "sync system_config", "sync roles", "sync users", "/draftgo sync", or wants to push local changes to the DraftGo server.
4
+ version: 1.1.0
5
5
  allowed-tools: Bash(python:*), Read, Glob
6
6
  ---
7
7
 
8
8
  # DraftGo 同步
9
9
 
10
- > **STOP — 禁止手动调用 API、禁止用 curl、禁止自己写 Python 上传逻辑。**
11
- > 唯一正确方式:运行下方 Python 脚本。脚本已处理所有字段结构、token 读取、错误处理。
10
+ > **STOP — 禁止用 curl、禁止自己写 Python 上传逻辑。**
11
+ > 唯一正确方式:运行下方 Python 脚本。脚本覆盖 init 拉取的全部 8 种类型,已处理字段结构、token 读取、错误处理。
12
12
 
13
- 脚本固定位于 `.draftgo/skill/scripts/draftgo_sync.py`。
13
+ 脚本路径取决于安装方式:
14
+ - CLI 安装:`.draftgo/skill/scripts/draftgo_sync.py`
15
+ - Claude Code 安装:`.claude/skills/draftgo/scripts/draftgo_sync.py`
14
16
 
15
17
  ## 同步页面("同步页面" / "sync pages")
16
18
 
17
19
  ```
18
- !python .draftgo/skill/scripts/draftgo_sync.py pages
19
- !python .draftgo/skill/scripts/draftgo_sync.py pages <page_id>
20
+ !python <skill_scripts>/draftgo_sync.py pages
21
+ !python <skill_scripts>/draftgo_sync.py pages <page_id>
20
22
  ```
21
23
 
22
24
  ## 同步数据库元数据("同步数据库" / "sync db_meta")
23
25
 
24
26
  ```
25
- !python .draftgo/skill/scripts/draftgo_sync.py db_meta
26
- !python .draftgo/skill/scripts/draftgo_sync.py db_meta <db_meta_id>
27
+ !python <skill_scripts>/draftgo_sync.py db_meta
28
+ !python <skill_scripts>/draftgo_sync.py db_meta <db_meta_id>
27
29
  ```
28
30
 
29
31
  ## 同步导航栏("同步导航" / "sync nav")
30
32
 
31
33
  ```
32
- !python .draftgo/skill/scripts/draftgo_sync.py nav
33
- !python .draftgo/skill/scripts/draftgo_sync.py nav <nav_id>
34
+ !python <skill_scripts>/draftgo_sync.py nav
35
+ !python <skill_scripts>/draftgo_sync.py nav <nav_id>
34
36
  ```
35
37
 
36
- 脚本会自动读取 `.draftgo/config.json` 和 `.draftgo/token`。
38
+ 脚本会自动读取 `.draftgo/config.json`。
39
+
40
+ ## 同步 AI 资产("同步AI资产" / "sync aihub")
41
+
42
+ ```
43
+ !python <skill_scripts>/draftgo_sync.py aihub
44
+ !python <skill_scripts>/draftgo_sync.py aihub <aihub_id>
45
+ ```
46
+
47
+ 读取 `.draftgo/aihub/index.json`,按 `AIHubUpdate` schema 推送。
48
+
49
+ ## 同步外部 API("同步外部API" / "sync external_apis")
50
+
51
+ ```
52
+ !python <skill_scripts>/draftgo_sync.py external_apis
53
+ !python <skill_scripts>/draftgo_sync.py external_apis <api_id>
54
+ ```
55
+
56
+ 读取 `.draftgo/external_apis/index.json`(已包含 init 时合并的 detail 字段),按 `ExternalAPIUpdate` schema 推送。
57
+
58
+ ## 同步系统配置("同步系统配置" / "sync system_config")
59
+
60
+ ```
61
+ !python <skill_scripts>/draftgo_sync.py system_config
62
+ !python <skill_scripts>/draftgo_sync.py system_config <config_key>
63
+ ```
64
+
65
+ 读取 `.draftgo/system_config/index.json`,按 `config_key` 调用 `PUT /api/system/{config_key}`。脚本优先使用 `parsed_value`。
66
+
67
+ ## 同步角色("同步角色" / "sync roles")⚠️ 需二次确认
68
+
69
+ roles 涉及权限安全,**强制要求人工确认**:
70
+
71
+ 1. 读取 `.draftgo/roles/index.json`
72
+ 2. **向用户展示即将同步的变更内容**
73
+ 3. **等待用户明确确认**
74
+ 4. 确认后运行:
75
+ ```
76
+ !python <skill_scripts>/draftgo_sync.py roles
77
+ !python <skill_scripts>/draftgo_sync.py roles <role_id>
78
+ ```
79
+ 5. 用户拒绝则不同步
80
+
81
+ ## 同步用户("同步用户" / "sync users")⚠️ 需二次确认
82
+
83
+ users 涉及账号安全,**强制要求人工确认**:
84
+
85
+ 1. 读取 `.draftgo/users/index.json`
86
+ 2. **向用户展示即将同步的变更内容**
87
+ 3. **等待用户明确确认**
88
+ 4. 确认后运行:
89
+ ```
90
+ !python <skill_scripts>/draftgo_sync.py users
91
+ !python <skill_scripts>/draftgo_sync.py users <user_id>
92
+ ```
93
+ 5. 脚本不会下发 password / role_ids;如需修改请走专用接口
37
94
 
38
95
  ## 同步方式
39
96
 
40
- **必须用 Python 脚本同步,禁止用 curl。**
97
+ **所有类型必须用 Python 脚本同步,禁止用 curl。**
41
98
 
42
- curl 在 Windows/Git Bash 环境下传输大 HTML 时会报 `Argument list too long`(exit 126)。
99
+ curl 在 Windows/Git Bash 环境下传输大 HTML / JSON 时会报 `Argument list too long`(exit 126)。
43
100
  统一使用 `draftgo_sync.py` 或临时 Python 脚本(`urllib.request`)进行同步。
44
101
 
45
102
  ## 接口说明
@@ -62,6 +119,18 @@ payload 必须包含完整元数据(从 `pages/index.json` 读取)+ HTML:
62
119
 
63
120
  **导航栏同步**:`PUT /api/navigations/{id}`,payload 为 `{"html": "..."}` 字符串。
64
121
 
122
+ **DB Meta 同步**:`PUT /api/db-meta/{id}`,payload 含 `type`, `label`, `describe`, `schema`, `permission`, `schema_validation`, `extra`。
123
+
124
+ **AI 资产同步**:`PUT /api/aihub/{id}`,payload 子集:`type`, `name`, `data`, `priority`, `version`, `tags`, `describe`, `permission`, `status`。
125
+
126
+ **外部 API 同步**:`PUT /api/external-apis/{id}`,payload 子集:`name`, `base_url`, `method`, `path`, `headers`, `auth_type`, `auth_config`, `timeout_ms`, `permission`, `param_schema`, `tags`, `description`, `status`。
127
+
128
+ **系统配置同步**:`PUT /api/system/{config_key}`,payload:`config_value`(parsed), `value_type`, `category`, `description`, `is_sensitive`, `status`。
129
+
130
+ **角色同步**:`PUT /api/roles/{id}`,payload 含 `name`, `description`, `status`, `sort_order`, `user_visible`。
131
+
132
+ **用户同步**:`PUT /api/users/{id}`,payload 子集:`username`, `email`, `phone_number`, `nickname`, `avatar`, `status`, `notes`(不含 password / role_ids)。
133
+
65
134
  ## 失败处理
66
135
 
67
136
  如果报错 `未找到 .draftgo/config.json`,提示用户先运行 `/draftgo init`。
@@ -5,8 +5,9 @@ const { all } = require('../installers');
5
5
  const { detectTargets } = require('../detect');
6
6
  const { findPython } = require('../python');
7
7
  const { readInstalledVersion, getPackageVersion } = require('../skill');
8
+ const { fetchLatestVersion, cmpSemver } = require('../updateCheck');
8
9
 
9
- function doctor(projectDir) {
10
+ async function doctor(projectDir, flags = {}) {
10
11
  log.title('draftgo doctor');
11
12
 
12
13
  log.info(`Node 版本:${process.version}`);
@@ -19,6 +20,20 @@ function doctor(projectDir) {
19
20
  if (py) log.ok(`Python:${py.bin} (${py.version})`);
20
21
  else log.err('Python 未检测到(DraftGo init/sync 脚本需要 Python 3.9+)。');
21
22
 
23
+ console.log('');
24
+ if (!flags['skip-update-check'] && process.env.DRAFTGO_NO_UPDATE_CHECK !== '1') {
25
+ const current = getPackageVersion();
26
+ const latest = await fetchLatestVersion();
27
+ if (!latest) {
28
+ log.dim('CLI 最新版查询失败(可能离线或 registry 不可达)');
29
+ } else if (cmpSemver(latest, current) > 0) {
30
+ log.warn(`CLI 有新版:${current} → ${latest}`);
31
+ log.dim(' 下次运行 `draftgo update` 时会自动升级。');
32
+ } else {
33
+ log.ok(`CLI 已是最新:${current}`);
34
+ }
35
+ }
36
+
22
37
  console.log('');
23
38
  log.info('项目中检测到的 AI 工具信号:');
24
39
  const hits = detectTargets(projectDir);
@@ -10,21 +10,27 @@ function help() {
10
10
  Usage:
11
11
  draftgo init [<target>...] Install skill. No target = auto-detect.
12
12
  Use "all" to install every target.
13
- draftgo update [<target>...] Update skill body + entry files (preserves
14
- config/iteration/bugs/local data).
13
+ draftgo update [<target>...] Update skill + entry files. If a newer CLI
14
+ version exists on npm, upgrades the CLI
15
+ automatically then reruns itself.
15
16
  draftgo uninstall [<target>...] Remove entry files for target(s).
16
17
  --purge also removes .draftgo/ (runtime data).
17
18
  draftgo status Show installed targets and skill version.
18
- draftgo doctor Diagnose environment (python, targets).
19
+ draftgo doctor Diagnose environment (python, targets,
20
+ CLI freshness).
19
21
  draftgo list-targets List supported AI tools.
20
22
  draftgo -v | --version Print CLI version.
21
23
  draftgo -h | --help Show this help.
22
24
 
23
25
  Flags:
24
- --project <dir> Operate on <dir> instead of the current working directory.
25
- --force Overwrite existing skill body during install/update.
26
- --purge (uninstall) Also remove the entire .draftgo/ directory.
27
- --yes Assume "yes" for interactive prompts.
26
+ --project <dir> Operate on <dir> instead of the current directory.
27
+ --force Overwrite existing skill body during install/update.
28
+ --purge (uninstall) Also remove the entire .draftgo/ directory.
29
+ --skip-update-check Do not contact npm to check for a newer CLI.
30
+ --yes Assume "yes" for interactive prompts.
31
+
32
+ Environment:
33
+ DRAFTGO_NO_UPDATE_CHECK=1 Disable the automatic CLI-freshness check.
28
34
 
29
35
  Targets:
30
36
  ${targets}
@@ -34,7 +40,7 @@ Examples:
34
40
  draftgo init claudecode # install for Claude Code only
35
41
  draftgo init claudecode kiro # install for both
36
42
  draftgo init all # install for every supported target
37
- draftgo update # refresh skill content to latest
43
+ draftgo update # upgrade CLI if needed, then refresh skill
38
44
  draftgo uninstall --purge # full removal incl. runtime data
39
45
  `);
40
46
  }
@@ -1,14 +1,81 @@
1
1
  'use strict';
2
2
 
3
+ const { spawnSync } = require('child_process');
3
4
  const log = require('../logger');
4
5
  const { all, resolveTargets } = require('../installers');
5
6
  const { updateSkillBody, readInstalledVersion, getPackageVersion } = require('../skill');
7
+ const { fetchLatestVersion, cmpSemver } = require('../updateCheck');
8
+
9
+ // Env flag we set before re-exec to avoid infinite upgrade loops.
10
+ const REENTRY_FLAG = 'DRAFTGO_UPDATE_REENTERED';
11
+
12
+ function runNpmInstallLatest() {
13
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
14
+ log.step('执行:npm install -g draftgo-cli@latest');
15
+ const r = spawnSync(npm, ['install', '-g', 'draftgo-cli@latest'], { stdio: 'inherit' });
16
+ if (r.error) { log.err(`升级失败:${r.error.message}`); return false; }
17
+ if (r.status !== 0) {
18
+ log.err(`npm install 返回码 ${r.status}`);
19
+ log.dim(' 常见原因:权限不足(macOS/Linux 用 sudo,或用 nvm 管理 Node)');
20
+ return false;
21
+ }
22
+ return true;
23
+ }
24
+
25
+ function reExecSameCommand(projectDir) {
26
+ // Re-run `draftgo update` from the freshly installed global CLI, so the user
27
+ // gets the newest skill content written into .draftgo/skill in the same call.
28
+ const cmd = process.platform === 'win32' ? 'draftgo.cmd' : 'draftgo';
29
+ const args = ['update', '--project', projectDir, '--skip-update-check'];
30
+ log.step('以新版 CLI 重新执行:draftgo update');
31
+ const r = spawnSync(cmd, args, {
32
+ stdio: 'inherit',
33
+ env: { ...process.env, [REENTRY_FLAG]: '1' },
34
+ });
35
+ if (r.error) {
36
+ log.err(`重新执行失败:${r.error.message}`);
37
+ log.dim(' 请手动在项目目录再跑一次:draftgo update');
38
+ return 1;
39
+ }
40
+ return r.status || 0;
41
+ }
42
+
43
+ async function maybeAutoUpgrade(projectDir, flags) {
44
+ if (flags['skip-update-check']) return { upgraded: false };
45
+ if (process.env[REENTRY_FLAG] === '1') return { upgraded: false };
46
+ if (process.env.DRAFTGO_NO_UPDATE_CHECK === '1') return { upgraded: false };
47
+
48
+ const current = getPackageVersion();
49
+ const latest = await fetchLatestVersion();
50
+ if (!latest) {
51
+ log.dim(' (查询最新版失败,按当前 CLI 继续)');
52
+ return { upgraded: false };
53
+ }
54
+ if (cmpSemver(latest, current) <= 0) {
55
+ log.dim(` CLI 已是最新(${current})`);
56
+ return { upgraded: false };
57
+ }
58
+
59
+ log.warn(`draftgo-cli 有新版:${current} → ${latest},自动升级中……`);
60
+ const ok = runNpmInstallLatest();
61
+ if (!ok) {
62
+ log.warn('自动升级失败,回退到当前 CLI 继续执行。');
63
+ return { upgraded: false };
64
+ }
65
+ log.ok(`CLI 已升级到 ${latest}。`);
66
+ const code = reExecSameCommand(projectDir);
67
+ return { upgraded: true, exitCode: code };
68
+ }
6
69
 
7
70
  async function update(projectDir, positional, flags) {
8
71
  log.title('draftgo update');
9
72
 
10
73
  const prev = readInstalledVersion(projectDir);
11
- log.info(`当前已装:${prev || '未安装'},CLI 版本:${getPackageVersion()}`);
74
+ log.info(`当前已装 skill:${prev || '未安装'},CLI 版本:${getPackageVersion()}`);
75
+
76
+ // Auto-upgrade-and-rerun path.
77
+ const { upgraded, exitCode } = await maybeAutoUpgrade(projectDir, flags);
78
+ if (upgraded) return exitCode;
12
79
 
13
80
  // Determine which entry files to refresh:
14
81
  // - no positional → refresh only targets that are currently installed
package/src/index.js CHANGED
@@ -34,7 +34,7 @@ async function run(argv) {
34
34
  case 'status':
35
35
  return require('./commands/status')(projectDir);
36
36
  case 'doctor':
37
- return require('./commands/doctor')(projectDir);
37
+ return await require('./commands/doctor')(projectDir, flags);
38
38
  case 'list-targets':
39
39
  case 'targets':
40
40
  return require('./commands/listTargets')();
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ // Fetch the latest draftgo-cli version from the npm registry.
4
+ // No cache — every call hits the network. Returns null on any failure so
5
+ // callers can gracefully fall back.
6
+
7
+ const https = require('https');
8
+ const { getPackageVersion } = require('./skill');
9
+
10
+ const REGISTRY_URL = 'https://registry.npmjs.org/draftgo-cli/latest';
11
+
12
+ function fetchLatestVersion(timeoutMs = 3000) {
13
+ return new Promise((resolve) => {
14
+ let req;
15
+ try {
16
+ req = https.get(
17
+ REGISTRY_URL,
18
+ { timeout: timeoutMs, headers: { accept: 'application/json' } },
19
+ (res) => {
20
+ if (res.statusCode !== 200) { res.resume(); return resolve(null); }
21
+ let body = '';
22
+ res.setEncoding('utf8');
23
+ res.on('data', (c) => { body += c; });
24
+ res.on('end', () => {
25
+ try { resolve(JSON.parse(body).version || null); }
26
+ catch { resolve(null); }
27
+ });
28
+ }
29
+ );
30
+ } catch {
31
+ return resolve(null);
32
+ }
33
+ req.on('error', () => resolve(null));
34
+ req.on('timeout', () => { req.destroy(); resolve(null); });
35
+ });
36
+ }
37
+
38
+ // Compare 'a.b.c' ignoring prerelease tags. Returns 1 / 0 / -1.
39
+ function cmpSemver(a, b) {
40
+ const pa = String(a).split('-')[0].split('.').map((x) => Number(x) || 0);
41
+ const pb = String(b).split('-')[0].split('.').map((x) => Number(x) || 0);
42
+ for (let i = 0; i < 3; i++) {
43
+ const d = (pa[i] || 0) - (pb[i] || 0);
44
+ if (d !== 0) return d > 0 ? 1 : -1;
45
+ }
46
+ return 0;
47
+ }
48
+
49
+ // Report-only helper used by doctor. Never throws.
50
+ async function getFreshness() {
51
+ if (process.env.DRAFTGO_NO_UPDATE_CHECK === '1') return null;
52
+ const current = getPackageVersion();
53
+ const latest = await fetchLatestVersion();
54
+ if (!latest) return null;
55
+ return { current, latest, outdated: cmpSemver(latest, current) > 0 };
56
+ }
57
+
58
+ module.exports = { fetchLatestVersion, cmpSemver, getFreshness };