dw-kit 1.9.0 → 1.9.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.
Files changed (53) hide show
  1. package/.claude/agents/planner.md +100 -100
  2. package/.claude/agents/quality-checker.md +86 -86
  3. package/.claude/agents/researcher.md +93 -93
  4. package/.claude/agents/reviewer.md +126 -126
  5. package/.claude/hooks/supply-chain-scan.sh +0 -0
  6. package/.claude/rules/code-style.md +37 -37
  7. package/.claude/settings.json +2 -28
  8. package/.claude/skills/dw-kit-report/SKILL.md +38 -7
  9. package/.claude/skills/dw-plan/template-plan.md +47 -47
  10. package/.claude/skills/dw-research/template-research.md +51 -51
  11. package/.claude/skills/dw-review/checklist.md +88 -88
  12. package/.claude/skills/dw-thinking/THINKING.md +91 -91
  13. package/.claude/templates/agent-report.md +35 -35
  14. package/.claude/templates/en/task-context.md +77 -73
  15. package/.claude/templates/en/task-plan.md +83 -79
  16. package/.claude/templates/en/task-progress.md +69 -65
  17. package/.claude/templates/pr-template.md +56 -56
  18. package/.claude/templates/task-context.md +77 -73
  19. package/.claude/templates/task-plan.md +83 -79
  20. package/.claude/templates/task-progress.md +69 -65
  21. package/.dw/adapters/claude-cli/extensions/README.md +36 -36
  22. package/.dw/adapters/claude-cli/generated/README.md +23 -23
  23. package/.dw/adapters/claude-cli/overrides/README.md +37 -37
  24. package/.dw/adapters/generic/README.md +21 -21
  25. package/.dw/config/presets/enterprise.yml +52 -52
  26. package/.dw/config/presets/small-team.yml +39 -39
  27. package/.dw/config/presets/solo-quick.yml +37 -37
  28. package/.dw/core/AGENTS.md +53 -53
  29. package/.dw/core/QUALITY.md +220 -220
  30. package/.dw/core/THINKING.md +126 -126
  31. package/.dw/core/WORKFLOW.md +17 -12
  32. package/.dw/core/templates/v2/spec.md +2 -0
  33. package/.dw/core/templates/v2/tracking.md +2 -0
  34. package/.dw/core/templates/v3/task.md +15 -22
  35. package/.dw/core/templates/vi/task-context.md +96 -92
  36. package/.dw/core/templates/vi/task-plan.md +97 -93
  37. package/.dw/core/templates/vi/task-progress.md +60 -56
  38. package/LICENSE +201 -201
  39. package/NOTICE +26 -26
  40. package/README.md +1 -1
  41. package/bin/dw.mjs +28 -28
  42. package/package.json +1 -1
  43. package/src/commands/claude-vn-fix.mjs +267 -267
  44. package/src/commands/prompt.mjs +112 -112
  45. package/src/commands/validate.mjs +102 -102
  46. package/src/lib/clipboard.mjs +24 -24
  47. package/src/lib/goal-store.mjs +2 -14
  48. package/src/lib/platform.mjs +39 -39
  49. package/src/lib/prompt-suggest.mjs +84 -84
  50. package/src/lib/timeline-parser.mjs +54 -15
  51. package/src/lib/ui.mjs +66 -66
  52. package/src/lib/update-checker.mjs +73 -73
  53. package/.dw/security/advisory-snapshot.json +0 -157
@@ -1,84 +1,84 @@
1
- import { execSync } from 'node:child_process';
2
-
3
- const TEMPLATE_SUGGESTIONS = [
4
- 'fix: ',
5
- 'fix authentication redirect after login',
6
- 'fix null pointer / undefined error in ',
7
- 'fix performance issue in ',
8
- 'feat: add ',
9
- 'feat: implement ',
10
- 'feat: support ',
11
- 'refactor: simplify ',
12
- 'refactor: extract ',
13
- 'refactor: rename ',
14
- 'perf: optimize ',
15
- 'perf: reduce load time of ',
16
- 'chore: update dependencies',
17
- 'docs: update README',
18
- 'test: add tests for ',
19
- ];
20
-
21
- // Common verbs to detect whether a description has intent
22
- const INTENT_VERBS = [
23
- 'fix', 'add', 'update', 'remove', 'delete', 'create', 'implement', 'refactor',
24
- 'rename', 'move', 'improve', 'optimize', 'support', 'extract', 'migrate',
25
- 'replace', 'upgrade', 'enable', 'disable', 'integrate', 'patch',
26
- ];
27
-
28
- function getTemplateSuggestions() {
29
- return [...TEMPLATE_SUGGESTIONS];
30
- }
31
-
32
- export function getGitSuggestions(cwd) {
33
- try {
34
- const out = execSync('git log --oneline -50 --no-merges', {
35
- cwd,
36
- encoding: 'utf-8',
37
- timeout: 3000,
38
- stdio: ['ignore', 'pipe', 'ignore'],
39
- });
40
- return out
41
- .trim()
42
- .split('\n')
43
- .filter(Boolean)
44
- .map((line) => line.replace(/^[a-f0-9]+ /, '').trim()) // strip hash
45
- .filter((msg) => msg.length > 5)
46
- .slice(0, 30);
47
- } catch {
48
- return [];
49
- }
50
- }
51
-
52
- export function getSuggestions(cwd = process.cwd()) {
53
- const git = getGitSuggestions(cwd);
54
- const templates = getTemplateSuggestions();
55
- // git log first (most relevant to this repo), then templates
56
- const merged = [...git, ...templates];
57
- // dedupe
58
- return [...new Set(merged)].slice(0, 20);
59
- }
60
-
61
- /**
62
- * Returns true if the description is likely too vague to give Claude good context.
63
- */
64
- export function isVague(text) {
65
- const trimmed = (text || '').trim();
66
- if (trimmed.length < 50) return true;
67
- const lower = trimmed.toLowerCase();
68
- const hasVerb = INTENT_VERBS.some((v) => lower.startsWith(v) || lower.includes(` ${v} `));
69
- return !hasVerb;
70
- }
71
-
72
- /**
73
- * Expand a short description into a structured prompt.
74
- * @param {string} text - core task description
75
- * @param {{ area?: string, outcome?: string }} extras
76
- * @returns {string}
77
- */
78
- export function expandTemplate(text, { area = '', outcome = '' } = {}) {
79
- const base = text.trim();
80
- const parts = [base];
81
- if (area) parts.push(`Scope: ${area.trim()}.`);
82
- if (outcome) parts.push(`Expected: ${outcome.trim()}.`);
83
- return parts.join('\n');
84
- }
1
+ import { execSync } from 'node:child_process';
2
+
3
+ const TEMPLATE_SUGGESTIONS = [
4
+ 'fix: ',
5
+ 'fix authentication redirect after login',
6
+ 'fix null pointer / undefined error in ',
7
+ 'fix performance issue in ',
8
+ 'feat: add ',
9
+ 'feat: implement ',
10
+ 'feat: support ',
11
+ 'refactor: simplify ',
12
+ 'refactor: extract ',
13
+ 'refactor: rename ',
14
+ 'perf: optimize ',
15
+ 'perf: reduce load time of ',
16
+ 'chore: update dependencies',
17
+ 'docs: update README',
18
+ 'test: add tests for ',
19
+ ];
20
+
21
+ // Common verbs to detect whether a description has intent
22
+ const INTENT_VERBS = [
23
+ 'fix', 'add', 'update', 'remove', 'delete', 'create', 'implement', 'refactor',
24
+ 'rename', 'move', 'improve', 'optimize', 'support', 'extract', 'migrate',
25
+ 'replace', 'upgrade', 'enable', 'disable', 'integrate', 'patch',
26
+ ];
27
+
28
+ function getTemplateSuggestions() {
29
+ return [...TEMPLATE_SUGGESTIONS];
30
+ }
31
+
32
+ export function getGitSuggestions(cwd) {
33
+ try {
34
+ const out = execSync('git log --oneline -50 --no-merges', {
35
+ cwd,
36
+ encoding: 'utf-8',
37
+ timeout: 3000,
38
+ stdio: ['ignore', 'pipe', 'ignore'],
39
+ });
40
+ return out
41
+ .trim()
42
+ .split('\n')
43
+ .filter(Boolean)
44
+ .map((line) => line.replace(/^[a-f0-9]+ /, '').trim()) // strip hash
45
+ .filter((msg) => msg.length > 5)
46
+ .slice(0, 30);
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ export function getSuggestions(cwd = process.cwd()) {
53
+ const git = getGitSuggestions(cwd);
54
+ const templates = getTemplateSuggestions();
55
+ // git log first (most relevant to this repo), then templates
56
+ const merged = [...git, ...templates];
57
+ // dedupe
58
+ return [...new Set(merged)].slice(0, 20);
59
+ }
60
+
61
+ /**
62
+ * Returns true if the description is likely too vague to give Claude good context.
63
+ */
64
+ export function isVague(text) {
65
+ const trimmed = (text || '').trim();
66
+ if (trimmed.length < 50) return true;
67
+ const lower = trimmed.toLowerCase();
68
+ const hasVerb = INTENT_VERBS.some((v) => lower.startsWith(v) || lower.includes(` ${v} `));
69
+ return !hasVerb;
70
+ }
71
+
72
+ /**
73
+ * Expand a short description into a structured prompt.
74
+ * @param {string} text - core task description
75
+ * @param {{ area?: string, outcome?: string }} extras
76
+ * @returns {string}
77
+ */
78
+ export function expandTemplate(text, { area = '', outcome = '' } = {}) {
79
+ const base = text.trim();
80
+ const parts = [base];
81
+ if (area) parts.push(`Scope: ${area.trim()}.`);
82
+ if (outcome) parts.push(`Expected: ${outcome.trim()}.`);
83
+ return parts.join('\n');
84
+ }
@@ -29,29 +29,68 @@ export function parseTimeline(content) {
29
29
  };
30
30
  }
31
31
 
32
+ // Header-driven so the column ORDER is not load-bearing (#24): the §3 tracker
33
+ // may be `# | Subtask | Status | Date | Notes` (legacy) or
34
+ // `# | Subtask | Acceptance | Est | Status | Notes` (current) — we read columns
35
+ // by name. Falls back to legacy positional mapping when no header row is found,
36
+ // so older / hand-written tables keep parsing.
37
+ const TRACKER_COLS = {
38
+ id: ['#', 'id'],
39
+ name: ['subtask', 'task', 'name'],
40
+ status: ['status'],
41
+ date: ['date'],
42
+ notes: ['notes'],
43
+ acceptance: ['acceptance'],
44
+ est: ['est', 'estimate'],
45
+ };
46
+ const LEGACY_POS = { id: 0, name: 1, status: 2, date: 3, notes: 4 };
47
+
48
+ function splitRow(line) {
49
+ // Drop the empty cells produced by the leading/trailing pipes.
50
+ return line.split('|').map((c) => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1);
51
+ }
52
+
32
53
  export function parseSubtaskTracker(sectionText) {
33
54
  if (!sectionText) return [];
34
55
  const lines = sectionText.split('\n').map((l) => l.trim()).filter(Boolean);
56
+ let colMap = null; // canonical-name -> column index
35
57
  const rows = [];
36
- let inTable = false;
37
58
  for (const line of lines) {
38
- if (line.startsWith('|---') || line.match(/^\|[\s|:-]+\|$/)) { inTable = true; continue; }
39
- if (!line.startsWith('|')) { inTable = false; continue; }
40
- const cells = line.split('|').map((c) => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1);
59
+ if (!line.startsWith('|')) continue;
60
+ if (line.match(/^\|[\s|:-]+\|$/)) continue; // separator row
61
+ const cells = splitRow(line);
41
62
  if (cells.length < 2) continue;
42
- if (!inTable && (cells[0].toLowerCase().startsWith('#') || cells[0].toLowerCase() === 'ws' || cells[0].toLowerCase() === 'st')) {
43
- // header row
63
+
64
+ const low = cells.map((c) => c.toLowerCase());
65
+ // Header row: first cell is '#'/'id', or it names known columns. Detect once.
66
+ const looksLikeHeader = low[0] === '#' || low[0] === 'id'
67
+ || low.includes('status') || low.includes('subtask');
68
+ if (!colMap && looksLikeHeader) {
69
+ colMap = {};
70
+ for (const [canon, aliases] of Object.entries(TRACKER_COLS)) {
71
+ const idx = low.findIndex((c) => aliases.includes(c));
72
+ if (idx >= 0) colMap[canon] = idx;
73
+ }
44
74
  continue;
45
75
  }
46
- if (cells.length >= 3) {
47
- rows.push({
48
- id: cells[0],
49
- name: cells[1],
50
- status: cells[2],
51
- date: cells[3] || '',
52
- notes: cells[4] || '',
53
- });
54
- }
76
+
77
+ // Data row. Skip stray non-subtask rows when using positional fallback.
78
+ if (!colMap && !/^(ST|WS)-/i.test(cells[0]) && !/^\d/.test(cells[0])) continue;
79
+ if (cells.length < 3 && !colMap) continue;
80
+
81
+ const get = (canon) => {
82
+ const idx = colMap ? colMap[canon] : LEGACY_POS[canon];
83
+ return idx != null && idx >= 0 ? (cells[idx] ?? '') : '';
84
+ };
85
+ rows.push({
86
+ id: get('id'),
87
+ name: get('name'),
88
+ status: get('status'),
89
+ date: get('date'),
90
+ notes: get('notes'),
91
+ acceptance: get('acceptance'),
92
+ est: get('est'),
93
+ });
55
94
  }
56
95
  return rows;
57
96
  }
package/src/lib/ui.mjs CHANGED
@@ -1,66 +1,66 @@
1
- import chalk from 'chalk';
2
- import { createInterface } from 'node:readline/promises';
3
- import { stdin, stdout } from 'node:process';
4
-
5
- export const BANNER = `
6
- ██████╗ ██╗ ██╗ ██╗ ██╗██╗████████╗
7
- ██╔══██╗██║ ██║ ██║ ██╔╝██║╚══██╔══╝
8
- ██║ ██║██║ █╗ ██║ █████╔╝ ██║ ██║
9
- ██║ ██║██║███╗██║ ██╔═██╗ ██║ ██║
10
- ██████╔╝╚███╔███╔╝ ██║ ██╗██║ ██║
11
- ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝`;
12
-
13
- export function banner(subtitle) {
14
- console.log(chalk.cyan.bold(BANNER));
15
- if (subtitle) console.log(chalk.cyan(` ${subtitle}`));
16
- console.log();
17
- }
18
-
19
- export function header(text) {
20
- console.log();
21
- console.log(chalk.bold(`══════════════════════════════════════════`));
22
- console.log(chalk.bold(` ${text}`));
23
- console.log(chalk.bold(`══════════════════════════════════════════`));
24
- }
25
-
26
- export const log = (msg) => console.log(` ${msg}`);
27
- export const ok = (msg) => console.log(chalk.green(` ✓ ${msg}`));
28
- export const warn = (msg) => console.log(chalk.yellow(` ⚠ ${msg}`));
29
- export const err = (msg) => console.log(chalk.red(` ✗ ${msg}`));
30
- export const info = (msg) => { console.log(); console.log(chalk.cyan(`▶ ${msg}`)); };
31
- export const dry = (msg) => console.log(chalk.dim(` [dry-run] ${msg}`));
32
-
33
- export async function ask(question, defaultValue) {
34
- const rl = createInterface({ input: stdin, output: stdout });
35
- const suffix = defaultValue ? ` [${defaultValue}]` : '';
36
- try {
37
- const answer = await rl.question(chalk.bold(` ${question}${suffix}: `));
38
- return answer.trim() || defaultValue || '';
39
- } finally {
40
- rl.close();
41
- }
42
- }
43
-
44
- export async function choose(question, options, defaultValue) {
45
- console.log(chalk.bold(` ${question}`));
46
- for (const opt of options) {
47
- const marker = opt.value === defaultValue ? chalk.cyan(' [default]') : '';
48
- console.log(` ${opt.value} = ${opt.label}${marker}`);
49
- }
50
- const allowed = new Set(options.map((o) => o.value));
51
- while (true) {
52
- const answer = await ask('>', defaultValue);
53
- if (allowed.has(answer)) return answer;
54
- warn(`Invalid choice: "${answer}". Allowed: ${[...allowed].join(', ')}`);
55
- }
56
- }
57
-
58
- export async function multiSelect(question, options, defaultValues) {
59
- console.log(chalk.bold(` ${question}`));
60
- for (const opt of options) {
61
- const marker = defaultValues?.includes(opt.value) ? chalk.dim(' (included)') : '';
62
- console.log(` ${opt.key} = ${opt.label}${marker}`);
63
- }
64
- const hint = defaultValues ? defaultValues.join(',') : '';
65
- return ask('Enter numbers separated by comma', hint);
66
- }
1
+ import chalk from 'chalk';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { stdin, stdout } from 'node:process';
4
+
5
+ export const BANNER = `
6
+ ██████╗ ██╗ ██╗ ██╗ ██╗██╗████████╗
7
+ ██╔══██╗██║ ██║ ██║ ██╔╝██║╚══██╔══╝
8
+ ██║ ██║██║ █╗ ██║ █████╔╝ ██║ ██║
9
+ ██║ ██║██║███╗██║ ██╔═██╗ ██║ ██║
10
+ ██████╔╝╚███╔███╔╝ ██║ ██╗██║ ██║
11
+ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝`;
12
+
13
+ export function banner(subtitle) {
14
+ console.log(chalk.cyan.bold(BANNER));
15
+ if (subtitle) console.log(chalk.cyan(` ${subtitle}`));
16
+ console.log();
17
+ }
18
+
19
+ export function header(text) {
20
+ console.log();
21
+ console.log(chalk.bold(`══════════════════════════════════════════`));
22
+ console.log(chalk.bold(` ${text}`));
23
+ console.log(chalk.bold(`══════════════════════════════════════════`));
24
+ }
25
+
26
+ export const log = (msg) => console.log(` ${msg}`);
27
+ export const ok = (msg) => console.log(chalk.green(` ✓ ${msg}`));
28
+ export const warn = (msg) => console.log(chalk.yellow(` ⚠ ${msg}`));
29
+ export const err = (msg) => console.log(chalk.red(` ✗ ${msg}`));
30
+ export const info = (msg) => { console.log(); console.log(chalk.cyan(`▶ ${msg}`)); };
31
+ export const dry = (msg) => console.log(chalk.dim(` [dry-run] ${msg}`));
32
+
33
+ export async function ask(question, defaultValue) {
34
+ const rl = createInterface({ input: stdin, output: stdout });
35
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
36
+ try {
37
+ const answer = await rl.question(chalk.bold(` ${question}${suffix}: `));
38
+ return answer.trim() || defaultValue || '';
39
+ } finally {
40
+ rl.close();
41
+ }
42
+ }
43
+
44
+ export async function choose(question, options, defaultValue) {
45
+ console.log(chalk.bold(` ${question}`));
46
+ for (const opt of options) {
47
+ const marker = opt.value === defaultValue ? chalk.cyan(' [default]') : '';
48
+ console.log(` ${opt.value} = ${opt.label}${marker}`);
49
+ }
50
+ const allowed = new Set(options.map((o) => o.value));
51
+ while (true) {
52
+ const answer = await ask('>', defaultValue);
53
+ if (allowed.has(answer)) return answer;
54
+ warn(`Invalid choice: "${answer}". Allowed: ${[...allowed].join(', ')}`);
55
+ }
56
+ }
57
+
58
+ export async function multiSelect(question, options, defaultValues) {
59
+ console.log(chalk.bold(` ${question}`));
60
+ for (const opt of options) {
61
+ const marker = defaultValues?.includes(opt.value) ? chalk.dim(' (included)') : '';
62
+ console.log(` ${opt.key} = ${opt.label}${marker}`);
63
+ }
64
+ const hint = defaultValues ? defaultValues.join(',') : '';
65
+ return ask('Enter numbers separated by comma', hint);
66
+ }
@@ -1,73 +1,73 @@
1
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { homedir } from 'node:os';
4
-
5
- const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
6
- const REGISTRY_URL = 'https://registry.npmjs.org/dw-kit/latest';
7
- const CACHE_DIR = join(homedir(), '.dw-kit');
8
- const CACHE_FILE = join(CACHE_DIR, 'update-cache.json');
9
-
10
- function parseSemver(v) {
11
- const parts = String(v).replace(/^v/, '').split('.').map(Number);
12
- return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 };
13
- }
14
-
15
- function isNewer(latest, current) {
16
- const l = parseSemver(latest);
17
- const c = parseSemver(current);
18
- if (l.major !== c.major) return l.major > c.major;
19
- if (l.minor !== c.minor) return l.minor > c.minor;
20
- return l.patch > c.patch;
21
- }
22
-
23
- function readCache() {
24
- try {
25
- return JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
26
- } catch {
27
- return null;
28
- }
29
- }
30
-
31
- function writeCache(data) {
32
- try {
33
- mkdirSync(CACHE_DIR, { recursive: true });
34
- writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf-8');
35
- } catch {
36
- // ignore write errors (permission, disk full, etc.)
37
- }
38
- }
39
-
40
- async function fetchLatestVersion() {
41
- const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(3000) });
42
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
43
- const data = await res.json();
44
- return data.version;
45
- }
46
-
47
- /**
48
- * Returns latest version string if an update is available (from cache), null otherwise.
49
- * Never throws, never makes network calls.
50
- */
51
- export function getUpdateNotice(currentVersion) {
52
- if (process.env.DW_NO_UPDATE_CHECK) return null;
53
- const cache = readCache();
54
- if (!cache?.latest) return null;
55
- return isNewer(cache.latest, currentVersion) ? cache.latest : null;
56
- }
57
-
58
- /**
59
- * Fires off an async check against npm registry and updates the cache.
60
- * Non-blocking — caller should NOT await this.
61
- * Skips the check if cache is still fresh (< 24h).
62
- */
63
- export function scheduleUpdateCheck(currentVersion) {
64
- if (process.env.DW_NO_UPDATE_CHECK) return;
65
-
66
- const cache = readCache();
67
- const now = Date.now();
68
- if (cache?.checkedAt && now - cache.checkedAt < CACHE_TTL_MS) return;
69
-
70
- fetchLatestVersion()
71
- .then((latest) => writeCache({ latest, checkedAt: now, current: currentVersion }))
72
- .catch(() => {});
73
- }
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
6
+ const REGISTRY_URL = 'https://registry.npmjs.org/dw-kit/latest';
7
+ const CACHE_DIR = join(homedir(), '.dw-kit');
8
+ const CACHE_FILE = join(CACHE_DIR, 'update-cache.json');
9
+
10
+ function parseSemver(v) {
11
+ const parts = String(v).replace(/^v/, '').split('.').map(Number);
12
+ return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 };
13
+ }
14
+
15
+ function isNewer(latest, current) {
16
+ const l = parseSemver(latest);
17
+ const c = parseSemver(current);
18
+ if (l.major !== c.major) return l.major > c.major;
19
+ if (l.minor !== c.minor) return l.minor > c.minor;
20
+ return l.patch > c.patch;
21
+ }
22
+
23
+ function readCache() {
24
+ try {
25
+ return JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function writeCache(data) {
32
+ try {
33
+ mkdirSync(CACHE_DIR, { recursive: true });
34
+ writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf-8');
35
+ } catch {
36
+ // ignore write errors (permission, disk full, etc.)
37
+ }
38
+ }
39
+
40
+ async function fetchLatestVersion() {
41
+ const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(3000) });
42
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
43
+ const data = await res.json();
44
+ return data.version;
45
+ }
46
+
47
+ /**
48
+ * Returns latest version string if an update is available (from cache), null otherwise.
49
+ * Never throws, never makes network calls.
50
+ */
51
+ export function getUpdateNotice(currentVersion) {
52
+ if (process.env.DW_NO_UPDATE_CHECK) return null;
53
+ const cache = readCache();
54
+ if (!cache?.latest) return null;
55
+ return isNewer(cache.latest, currentVersion) ? cache.latest : null;
56
+ }
57
+
58
+ /**
59
+ * Fires off an async check against npm registry and updates the cache.
60
+ * Non-blocking — caller should NOT await this.
61
+ * Skips the check if cache is still fresh (< 24h).
62
+ */
63
+ export function scheduleUpdateCheck(currentVersion) {
64
+ if (process.env.DW_NO_UPDATE_CHECK) return;
65
+
66
+ const cache = readCache();
67
+ const now = Date.now();
68
+ if (cache?.checkedAt && now - cache.checkedAt < CACHE_TTL_MS) return;
69
+
70
+ fetchLatestVersion()
71
+ .then((latest) => writeCache({ latest, checkedAt: now, current: currentVersion }))
72
+ .catch(() => {});
73
+ }