@localsummer/incspec 0.0.6 → 0.0.8
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 +76 -15
- package/commands/analyze.mjs +28 -12
- package/commands/apply.mjs +78 -33
- package/commands/archive.mjs +25 -3
- package/commands/collect-dep.mjs +2 -2
- package/commands/collect-req.mjs +10 -2
- package/commands/design.mjs +2 -2
- package/commands/help.mjs +20 -11
- package/commands/list.mjs +2 -1
- package/commands/merge.mjs +64 -33
- package/commands/reset.mjs +166 -0
- package/commands/status.mjs +30 -7
- package/commands/sync.mjs +210 -0
- package/commands/update.mjs +2 -1
- package/index.mjs +13 -6
- package/lib/agents.mjs +1 -1
- package/lib/claude.mjs +144 -0
- package/lib/config.mjs +13 -10
- package/lib/cursor.mjs +20 -5
- package/lib/terminal.mjs +108 -0
- package/lib/workflow.mjs +123 -29
- package/package.json +1 -1
- package/templates/AGENTS.md +89 -36
- package/templates/INCSPEC_BLOCK.md +1 -1
- package/templates/WORKFLOW.md +1 -0
- package/templates/cursor-commands/analyze-codeflow.md +12 -1
- package/templates/cursor-commands/apply-increment-code.md +129 -1
- package/templates/cursor-commands/merge-to-baseline.md +87 -1
- package/templates/cursor-commands/structured-requirements-collection.md +6 -0
- package/templates/inc-spec-skill/SKILL.md +286 -0
- package/templates/inc-spec-skill/references/analyze-codeflow.md +368 -0
- package/templates/inc-spec-skill/references/analyze-increment-codeflow.md +246 -0
- package/templates/inc-spec-skill/references/apply-increment-code.md +520 -0
- package/templates/inc-spec-skill/references/inc-archive.md +278 -0
- package/templates/inc-spec-skill/references/merge-to-baseline.md +415 -0
- package/templates/inc-spec-skill/references/structured-requirements-collection.md +129 -0
- package/templates/inc-spec-skill/references/ui-dependency-collection.md +143 -0
- package/commands/cursor-sync.mjs +0 -116
package/lib/claude.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code integration utilities
|
|
3
|
+
* - Sync inc-spec-skill to global or project
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
/** Claude skills directory name */
|
|
12
|
+
const CLAUDE_SKILLS_DIR_NAME = 'skills';
|
|
13
|
+
|
|
14
|
+
/** Skill name */
|
|
15
|
+
const SKILL_NAME = 'inc-spec-skill';
|
|
16
|
+
|
|
17
|
+
/** Global Claude skills directory */
|
|
18
|
+
const GLOBAL_CLAUDE_SKILLS_DIR = path.join(os.homedir(), '.claude', CLAUDE_SKILLS_DIR_NAME);
|
|
19
|
+
|
|
20
|
+
/** Project Claude skills directory (relative to project root) */
|
|
21
|
+
const PROJECT_CLAUDE_SKILLS_DIR = path.join('.claude', CLAUDE_SKILLS_DIR_NAME);
|
|
22
|
+
|
|
23
|
+
/** Skill template source directory */
|
|
24
|
+
const SKILL_TEMPLATE_DIR = fileURLToPath(new URL('../templates/inc-spec-skill', import.meta.url));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Copy directory recursively
|
|
28
|
+
* @param {string} src - Source directory
|
|
29
|
+
* @param {string} dest - Destination directory
|
|
30
|
+
*/
|
|
31
|
+
function copyDirRecursive(src, dest) {
|
|
32
|
+
if (!fs.existsSync(dest)) {
|
|
33
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
37
|
+
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
const srcPath = path.join(src, entry.name);
|
|
40
|
+
const destPath = path.join(dest, entry.name);
|
|
41
|
+
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
copyDirRecursive(srcPath, destPath);
|
|
44
|
+
} else {
|
|
45
|
+
fs.copyFileSync(srcPath, destPath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Count files in directory recursively
|
|
52
|
+
* @param {string} dir - Directory path
|
|
53
|
+
* @returns {number} File count
|
|
54
|
+
*/
|
|
55
|
+
function countFiles(dir) {
|
|
56
|
+
let count = 0;
|
|
57
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
58
|
+
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
count += countFiles(path.join(dir, entry.name));
|
|
62
|
+
} else {
|
|
63
|
+
count++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return count;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Sync skill to global Claude skills directory
|
|
72
|
+
* @returns {{count: number, targetDir: string}}
|
|
73
|
+
*/
|
|
74
|
+
export function syncToGlobalClaude() {
|
|
75
|
+
const targetDir = path.join(GLOBAL_CLAUDE_SKILLS_DIR, SKILL_NAME);
|
|
76
|
+
|
|
77
|
+
// Remove existing if present
|
|
78
|
+
if (fs.existsSync(targetDir)) {
|
|
79
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Copy skill template
|
|
83
|
+
copyDirRecursive(SKILL_TEMPLATE_DIR, targetDir);
|
|
84
|
+
|
|
85
|
+
const count = countFiles(targetDir);
|
|
86
|
+
|
|
87
|
+
return { count, targetDir };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Sync skill to project Claude skills directory
|
|
92
|
+
* @param {string} projectRoot - Project root path
|
|
93
|
+
* @returns {{count: number, targetDir: string}}
|
|
94
|
+
*/
|
|
95
|
+
export function syncToProjectClaude(projectRoot) {
|
|
96
|
+
const targetDir = path.join(projectRoot, PROJECT_CLAUDE_SKILLS_DIR, SKILL_NAME);
|
|
97
|
+
|
|
98
|
+
// Remove existing if present
|
|
99
|
+
if (fs.existsSync(targetDir)) {
|
|
100
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Copy skill template
|
|
104
|
+
copyDirRecursive(SKILL_TEMPLATE_DIR, targetDir);
|
|
105
|
+
|
|
106
|
+
const count = countFiles(targetDir);
|
|
107
|
+
|
|
108
|
+
return { count, targetDir };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if Claude skill exists
|
|
113
|
+
* @param {string} projectRoot - Optional project root
|
|
114
|
+
* @returns {{project: boolean, global: boolean}}
|
|
115
|
+
*/
|
|
116
|
+
export function checkClaudeSkill(projectRoot = null) {
|
|
117
|
+
const globalDir = path.join(GLOBAL_CLAUDE_SKILLS_DIR, SKILL_NAME);
|
|
118
|
+
const result = {
|
|
119
|
+
global: fs.existsSync(globalDir) && fs.readdirSync(globalDir).length > 0,
|
|
120
|
+
project: false,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (projectRoot) {
|
|
124
|
+
const projectDir = path.join(projectRoot, PROJECT_CLAUDE_SKILLS_DIR, SKILL_NAME);
|
|
125
|
+
result.project = fs.existsSync(projectDir) && fs.readdirSync(projectDir).length > 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get skill template info
|
|
133
|
+
* @returns {{fileCount: number, hasSkillMd: boolean, hasReferences: boolean}}
|
|
134
|
+
*/
|
|
135
|
+
export function getSkillTemplateInfo() {
|
|
136
|
+
const skillMdPath = path.join(SKILL_TEMPLATE_DIR, 'SKILL.md');
|
|
137
|
+
const referencesDir = path.join(SKILL_TEMPLATE_DIR, 'references');
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
fileCount: countFiles(SKILL_TEMPLATE_DIR),
|
|
141
|
+
hasSkillMd: fs.existsSync(skillMdPath),
|
|
142
|
+
hasReferences: fs.existsSync(referencesDir) && fs.readdirSync(referencesDir).length > 0,
|
|
143
|
+
};
|
|
144
|
+
}
|
package/lib/config.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
|
+
import { formatLocalDate } from './terminal.mjs';
|
|
11
12
|
|
|
12
13
|
/** incspec directory name */
|
|
13
14
|
export const INCSPEC_DIR = 'incspec';
|
|
@@ -51,7 +52,9 @@ export function findProjectRoot(startDir) {
|
|
|
51
52
|
|
|
52
53
|
while (currentDir !== root) {
|
|
53
54
|
const incspecPath = path.join(currentDir, INCSPEC_DIR);
|
|
54
|
-
|
|
55
|
+
const projectFile = path.join(incspecPath, FILES.project);
|
|
56
|
+
// 检查 project.md 是否存在,避免 macOS 大小写不敏感导致的误判
|
|
57
|
+
if (fs.existsSync(projectFile)) {
|
|
55
58
|
return currentDir;
|
|
56
59
|
}
|
|
57
60
|
currentDir = path.dirname(currentDir);
|
|
@@ -197,23 +200,23 @@ export function writeProjectConfig(projectRoot, config) {
|
|
|
197
200
|
*/
|
|
198
201
|
function generateProjectContent(config) {
|
|
199
202
|
const templatePath = path.join(getTemplatesDir(), 'project.md');
|
|
200
|
-
|
|
203
|
+
|
|
201
204
|
if (fs.existsSync(templatePath)) {
|
|
202
205
|
let content = fs.readFileSync(templatePath, 'utf-8');
|
|
203
|
-
|
|
206
|
+
|
|
204
207
|
// Replace variables
|
|
205
208
|
content = content.replace(/\{\{name\}\}/g, config.name || '');
|
|
206
209
|
content = content.replace(/\{\{version\}\}/g, config.version || '1.0.0');
|
|
207
210
|
content = content.replace(/\{\{source_dir\}\}/g, config.source_dir || 'src');
|
|
208
|
-
content = content.replace(/\{\{created_at\}\}/g, config.created_at || new Date()
|
|
209
|
-
|
|
211
|
+
content = content.replace(/\{\{created_at\}\}/g, config.created_at || formatLocalDate(new Date()));
|
|
212
|
+
|
|
210
213
|
// Handle tech_stack array
|
|
211
214
|
const techStackLines = (config.tech_stack || []).map(item => ` - ${item}`).join('\n');
|
|
212
215
|
content = content.replace(/\{\{tech_stack\}\}/g, techStackLines);
|
|
213
|
-
|
|
216
|
+
|
|
214
217
|
return content;
|
|
215
218
|
}
|
|
216
|
-
|
|
219
|
+
|
|
217
220
|
// Fallback to hardcoded content if template not found
|
|
218
221
|
return generateFallbackProjectContent(config);
|
|
219
222
|
}
|
|
@@ -234,7 +237,7 @@ function generateFallbackProjectContent(config) {
|
|
|
234
237
|
* @returns {Object}
|
|
235
238
|
*/
|
|
236
239
|
export function getDefaultConfig(options = {}) {
|
|
237
|
-
const now = new Date()
|
|
240
|
+
const now = formatLocalDate(new Date());
|
|
238
241
|
|
|
239
242
|
return {
|
|
240
243
|
name: options.name || path.basename(process.cwd()),
|
|
@@ -318,7 +321,7 @@ function writeAgentsFile(projectRoot) {
|
|
|
318
321
|
## 快速开始
|
|
319
322
|
|
|
320
323
|
1. 运行 \`incspec status\` 查看当前工作流状态
|
|
321
|
-
2. 按顺序执行
|
|
324
|
+
2. 按顺序执行7步工作流: analyze → collect-req → collect-dep → design → apply → merge → archive
|
|
322
325
|
3. 使用 \`incspec help\` 获取更多帮助
|
|
323
326
|
`;
|
|
324
327
|
fs.writeFileSync(agentsPath, fallbackContent, 'utf-8');
|
|
@@ -332,7 +335,7 @@ function writeAgentsFile(projectRoot) {
|
|
|
332
335
|
*/
|
|
333
336
|
export function ensureInitialized(cwd) {
|
|
334
337
|
const projectRoot = findProjectRoot(cwd);
|
|
335
|
-
|
|
338
|
+
|
|
336
339
|
if (!projectRoot || !isInitialized(projectRoot)) {
|
|
337
340
|
throw new Error(
|
|
338
341
|
`incspec 未初始化。请先运行 'incspec init' 初始化项目。`
|
package/lib/cursor.mjs
CHANGED
|
@@ -68,6 +68,13 @@ const COMMAND_MAP = [
|
|
|
68
68
|
label: '步骤6: 合并到基线',
|
|
69
69
|
description: '[incspec] 将增量融合为新的代码流基线快照',
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
source: 'inc-archive.md',
|
|
73
|
+
target: 'inc-archive.md',
|
|
74
|
+
step: 7,
|
|
75
|
+
label: '步骤7: 归档工作流产出',
|
|
76
|
+
description: '[incspec] 归档工作流产出文件到历史记录目录',
|
|
77
|
+
},
|
|
71
78
|
];
|
|
72
79
|
|
|
73
80
|
/**
|
|
@@ -174,16 +181,24 @@ description: [incspec] 显示帮助信息
|
|
|
174
181
|
|
|
175
182
|
## 工作流步骤
|
|
176
183
|
|
|
184
|
+
**完整模式 (7步):**
|
|
177
185
|
1. \`/incspec/inc-analyze\` - 分析代码流程,生成基线快照
|
|
178
186
|
2. \`/incspec/inc-collect-req\` - 收集结构化需求
|
|
179
187
|
3. \`/incspec/inc-collect-dep\` - 采集UI依赖
|
|
180
188
|
4. \`/incspec/inc-design\` - 生成增量设计蓝图
|
|
181
189
|
5. \`/incspec/inc-apply\` - 应用代码变更
|
|
182
190
|
6. \`/incspec/inc-merge\` - 合并到新基线
|
|
191
|
+
7. \`/incspec/inc-archive\` - 归档工作流产出
|
|
192
|
+
|
|
193
|
+
**快速模式 (5步):**
|
|
194
|
+
1. \`/incspec/inc-analyze --quick\` - 分析代码流程 (快速模式)
|
|
195
|
+
2. \`/incspec/inc-collect-req\` - 收集结构化需求
|
|
196
|
+
5. \`/incspec/inc-apply\` - 应用代码变更
|
|
197
|
+
6. \`/incspec/inc-merge\` - 合并到新基线
|
|
198
|
+
7. \`/incspec/inc-archive\` - 归档工作流产出
|
|
183
199
|
|
|
184
200
|
## 辅助命令
|
|
185
201
|
|
|
186
|
-
- \`/incspec/inc-archive\` - 归档规范文件到 archives 目录
|
|
187
202
|
- \`/incspec/inc-status\` - 查看当前工作流状态
|
|
188
203
|
- \`/incspec/inc-help\` - 显示帮助信息
|
|
189
204
|
|
|
@@ -194,7 +209,7 @@ incspec init # 初始化项目
|
|
|
194
209
|
incspec status # 查看工作流状态
|
|
195
210
|
incspec list # 列出规范文件
|
|
196
211
|
incspec validate # 验证规范完整性
|
|
197
|
-
incspec
|
|
212
|
+
incspec sync # 同步 IDE 命令
|
|
198
213
|
incspec help # 显示帮助
|
|
199
214
|
\`\`\`
|
|
200
215
|
|
|
@@ -255,14 +270,14 @@ incspec archive <file-path> --keep --yes
|
|
|
255
270
|
*/
|
|
256
271
|
export function syncToProject(projectRoot) {
|
|
257
272
|
const targetDir = path.join(projectRoot, CURSOR_COMMANDS_DIR);
|
|
258
|
-
|
|
273
|
+
|
|
259
274
|
// Create directory
|
|
260
275
|
if (!fs.existsSync(targetDir)) {
|
|
261
276
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
262
277
|
}
|
|
263
278
|
|
|
264
279
|
const commands = generateCursorCommands(projectRoot);
|
|
265
|
-
|
|
280
|
+
|
|
266
281
|
for (const cmd of commands) {
|
|
267
282
|
const filePath = path.join(targetDir, cmd.name);
|
|
268
283
|
fs.writeFileSync(filePath, cmd.content, 'utf-8');
|
|
@@ -282,7 +297,7 @@ export function syncToGlobal() {
|
|
|
282
297
|
}
|
|
283
298
|
|
|
284
299
|
const commands = generateCursorCommands();
|
|
285
|
-
|
|
300
|
+
|
|
286
301
|
for (const cmd of commands) {
|
|
287
302
|
const filePath = path.join(GLOBAL_CURSOR_DIR, cmd.name);
|
|
288
303
|
fs.writeFileSync(filePath, cmd.content, 'utf-8');
|
package/lib/terminal.mjs
CHANGED
|
@@ -241,6 +241,88 @@ export async function select({ message, choices }) {
|
|
|
241
241
|
});
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Interactive checkbox (multi-select) prompt
|
|
246
|
+
* @param {Object} options
|
|
247
|
+
* @param {string} options.message
|
|
248
|
+
* @param {Array<{name: string, value: any, checked?: boolean}>} options.choices
|
|
249
|
+
* @returns {Promise<any[]>}
|
|
250
|
+
*/
|
|
251
|
+
export async function checkbox({ message, choices }) {
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
let cursor = 0;
|
|
254
|
+
const selected = choices.map(c => c.checked || false);
|
|
255
|
+
|
|
256
|
+
const rl = readline.createInterface({
|
|
257
|
+
input: process.stdin,
|
|
258
|
+
output: process.stdout,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (process.stdin.isTTY) {
|
|
262
|
+
process.stdin.setRawMode(true);
|
|
263
|
+
}
|
|
264
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
265
|
+
|
|
266
|
+
const render = () => {
|
|
267
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
268
|
+
|
|
269
|
+
console.log(colorize(message, colors.bold, colors.cyan));
|
|
270
|
+
console.log(colorize('(space: toggle, a: toggle all, enter: confirm)', colors.dim));
|
|
271
|
+
console.log();
|
|
272
|
+
|
|
273
|
+
choices.forEach((choice, index) => {
|
|
274
|
+
const isCursor = cursor === index;
|
|
275
|
+
const isSelected = selected[index];
|
|
276
|
+
const pointer = isCursor ? colorize('>', colors.cyan) : ' ';
|
|
277
|
+
const check = isSelected ? colorize('[x]', colors.green) : '[ ]';
|
|
278
|
+
const name = isCursor ? colorize(choice.name, colors.bold) : choice.name;
|
|
279
|
+
|
|
280
|
+
console.log(`${pointer} ${check} ${name}`);
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const cleanup = () => {
|
|
285
|
+
if (process.stdin.isTTY) {
|
|
286
|
+
process.stdin.setRawMode(false);
|
|
287
|
+
}
|
|
288
|
+
rl.close();
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const handleKeypress = (str, key) => {
|
|
292
|
+
if (!key) return;
|
|
293
|
+
|
|
294
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
295
|
+
cursor = cursor > 0 ? cursor - 1 : choices.length - 1;
|
|
296
|
+
render();
|
|
297
|
+
} else if (key.name === 'down' || key.name === 'j') {
|
|
298
|
+
cursor = cursor < choices.length - 1 ? cursor + 1 : 0;
|
|
299
|
+
render();
|
|
300
|
+
} else if (key.name === 'space') {
|
|
301
|
+
selected[cursor] = !selected[cursor];
|
|
302
|
+
render();
|
|
303
|
+
} else if (str === 'a') {
|
|
304
|
+
const allSelected = selected.every(s => s);
|
|
305
|
+
selected.fill(!allSelected);
|
|
306
|
+
render();
|
|
307
|
+
} else if (key.name === 'return') {
|
|
308
|
+
cleanup();
|
|
309
|
+
process.stdin.removeListener('keypress', handleKeypress);
|
|
310
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
311
|
+
const result = choices.filter((_, i) => selected[i]).map(c => c.value);
|
|
312
|
+
resolve(result);
|
|
313
|
+
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
314
|
+
cleanup();
|
|
315
|
+
process.stdin.removeListener('keypress', handleKeypress);
|
|
316
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
317
|
+
process.exit(0);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
process.stdin.on('keypress', handleKeypress);
|
|
322
|
+
render();
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
244
326
|
/**
|
|
245
327
|
* Format a table for terminal output
|
|
246
328
|
* @param {string[]} headers
|
|
@@ -290,3 +372,29 @@ export function spinner(message) {
|
|
|
290
372
|
}
|
|
291
373
|
};
|
|
292
374
|
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Format date to local datetime string
|
|
378
|
+
* @param {Date} date - Date object
|
|
379
|
+
* @returns {string} Format: YYYY-MM-DD HH:mm
|
|
380
|
+
*/
|
|
381
|
+
export function formatLocalDateTime(date) {
|
|
382
|
+
const year = date.getFullYear();
|
|
383
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
384
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
385
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
386
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
387
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Format date to local date string
|
|
392
|
+
* @param {Date} date - Date object
|
|
393
|
+
* @returns {string} Format: YYYY-MM-DD
|
|
394
|
+
*/
|
|
395
|
+
export function formatLocalDate(date) {
|
|
396
|
+
const year = date.getFullYear();
|
|
397
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
398
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
399
|
+
return `${year}-${month}-${day}`;
|
|
400
|
+
}
|