shawnxixi-cli 0.3.0 → 1.1.0

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 (82) hide show
  1. package/.env.example +8 -2
  2. package/.github/workflows/ci.yml +9 -22
  3. package/.github/workflows/release.yml +30 -0
  4. package/CLAUDE.md +167 -63
  5. package/README.md +345 -65
  6. package/commitlint.config.js +10 -0
  7. package/dist/commands/biz/calendar.d.ts +17 -2
  8. package/dist/commands/biz/calendar.d.ts.map +1 -1
  9. package/dist/commands/biz/calendar.js +47 -9
  10. package/dist/commands/biz/calendar.js.map +1 -1
  11. package/dist/commands/biz/finance.d.ts +32 -1
  12. package/dist/commands/biz/finance.d.ts.map +1 -1
  13. package/dist/commands/biz/finance.js +99 -12
  14. package/dist/commands/biz/finance.js.map +1 -1
  15. package/dist/commands/biz/health.d.ts +32 -0
  16. package/dist/commands/biz/health.d.ts.map +1 -0
  17. package/dist/commands/biz/health.js +92 -0
  18. package/dist/commands/biz/health.js.map +1 -0
  19. package/dist/commands/biz/item.d.ts +41 -0
  20. package/dist/commands/biz/item.d.ts.map +1 -1
  21. package/dist/commands/biz/item.js +89 -5
  22. package/dist/commands/biz/item.js.map +1 -1
  23. package/dist/commands/biz/record.d.ts +0 -8
  24. package/dist/commands/biz/record.d.ts.map +1 -1
  25. package/dist/commands/biz/record.js +29 -8
  26. package/dist/commands/biz/record.js.map +1 -1
  27. package/dist/commands/biz/task.d.ts +38 -0
  28. package/dist/commands/biz/task.d.ts.map +1 -0
  29. package/dist/commands/biz/task.js +110 -0
  30. package/dist/commands/biz/task.js.map +1 -0
  31. package/dist/commands/biz/todo.d.ts +52 -8
  32. package/dist/commands/biz/todo.d.ts.map +1 -1
  33. package/dist/commands/biz/todo.js +167 -17
  34. package/dist/commands/biz/todo.js.map +1 -1
  35. package/dist/commands/sys/health.d.ts.map +1 -1
  36. package/dist/commands/sys/health.js +20 -3
  37. package/dist/commands/sys/health.js.map +1 -1
  38. package/dist/index.d.ts +5 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +281 -42
  41. package/dist/index.js.map +1 -1
  42. package/dist/services/api.d.ts +22 -8
  43. package/dist/services/api.d.ts.map +1 -1
  44. package/dist/services/api.js +108 -28
  45. package/dist/services/api.js.map +1 -1
  46. package/dist/utils/env.d.ts +16 -0
  47. package/dist/utils/env.d.ts.map +1 -0
  48. package/dist/utils/env.js +78 -0
  49. package/dist/utils/env.js.map +1 -0
  50. package/dist/utils/finance.model.d.ts +80 -0
  51. package/dist/utils/finance.model.d.ts.map +1 -0
  52. package/dist/utils/finance.model.js +150 -0
  53. package/dist/utils/finance.model.js.map +1 -0
  54. package/dist/utils/output.d.ts +42 -0
  55. package/dist/utils/output.d.ts.map +1 -0
  56. package/dist/utils/output.js +124 -0
  57. package/dist/utils/output.js.map +1 -0
  58. package/dist/utils/todo.model.d.ts +55 -0
  59. package/dist/utils/todo.model.d.ts.map +1 -0
  60. package/dist/utils/todo.model.js +135 -0
  61. package/dist/utils/todo.model.js.map +1 -0
  62. package/package.json +18 -2
  63. package/src/commands/biz/calendar.ts +61 -10
  64. package/src/commands/biz/finance.ts +112 -13
  65. package/src/commands/biz/health.ts +96 -0
  66. package/src/commands/biz/item.ts +113 -6
  67. package/src/commands/biz/record.ts +32 -8
  68. package/src/commands/biz/task.ts +115 -0
  69. package/src/commands/biz/todo.ts +193 -21
  70. package/src/commands/sys/health.ts +23 -3
  71. package/src/index.ts +311 -54
  72. package/src/services/api.ts +111 -30
  73. package/src/utils/env.ts +85 -0
  74. package/src/utils/finance.model.ts +182 -0
  75. package/src/utils/output.ts +126 -0
  76. package/src/utils/todo.model.ts +167 -0
  77. package/tests/commands/finance.test.ts +281 -0
  78. package/tests/commands/item.test.ts +215 -0
  79. package/tests/commands/todo.test.ts +292 -9
  80. package/tests/services/api.test.ts +292 -20
  81. package/tests/utils/finance.model.test.ts +319 -0
  82. package/tests/utils/todo.model.test.ts +315 -0
@@ -4,8 +4,9 @@
4
4
  */
5
5
 
6
6
  import axios, { AxiosInstance } from 'axios';
7
+ import { envConfig } from '../utils/env';
7
8
 
8
- const API_BASE = process.env.SHAWNXIXI_API_URL || 'http://server.shawnxixi.icu';
9
+ const API_BASE = envConfig.API_URL;
9
10
 
10
11
  /** Normalize date/datetime strings to LocalDateTime format (ISO 8601 without timezone) */
11
12
  function toLocalDateTime(value?: string): string | undefined {
@@ -62,8 +63,7 @@ class ApiService {
62
63
  }
63
64
 
64
65
  private async getToken(): Promise<string | null> {
65
- // TODO: Keychain 读取 token
66
- return process.env.SHAWNXIXI_API_TOKEN || null;
66
+ return envConfig.API_TOKEN || process.env.SHAWNXIXI_API_TOKEN || null;
67
67
  }
68
68
 
69
69
  private async refreshToken(): Promise<void> {
@@ -71,23 +71,30 @@ class ApiService {
71
71
  }
72
72
 
73
73
  // ============ Todo API ============
74
- async createTodo(title: string, dueDate?: string, priority?: string) {
74
+ async createTodo(title: string, dueDate?: string, priority?: string, tags?: string, repeatRule?: string, repeatEndDate?: string, parentId?: string) {
75
75
  const { data } = await this.client.post('/api/v1/todos', {
76
76
  title,
77
77
  dueDate: toLocalDateTime(dueDate),
78
78
  priority: priority || 'medium',
79
79
  status: 'pending',
80
+ tags,
81
+ repeatRule,
82
+ repeatEndDate: toLocalDateTime(repeatEndDate),
83
+ parentId,
80
84
  });
81
85
  return data;
82
86
  }
83
87
 
84
- async listTodos(status?: string) {
85
- const params = status ? { status } : {};
88
+ async listTodos(status?: string, priority?: string, tags?: string) {
89
+ const params: Record<string, string> = {};
90
+ if (status) params.status = status;
91
+ if (priority) params.priority = priority;
92
+ if (tags) params.tags = tags;
86
93
  const { data } = await this.client.get('/api/v1/todos', { params });
87
94
  return data;
88
95
  }
89
96
 
90
- async updateTodo(id: number, updates: Partial<{title: string; status: string; priority: string; dueDate: string}>) {
97
+ async updateTodo(id: number, updates: Partial<{title: string; status: string; priority: string; dueDate: string; tags: string}>) {
91
98
  const { data } = await this.client.patch(`/api/v1/todos/${id}`, updates);
92
99
  return data;
93
100
  }
@@ -97,6 +104,18 @@ class ApiService {
97
104
  return data;
98
105
  }
99
106
 
107
+ async repeatTodo(id: number) {
108
+ const { data } = await this.client.post(`/api/v1/todos/${id}/repeat`);
109
+ return data;
110
+ }
111
+
112
+ async listSubtasks(parentId: number) {
113
+ const params: Record<string, string> = {};
114
+ if (parentId) params.parentId = String(parentId);
115
+ const { data } = await this.client.get('/api/v1/todos', { params });
116
+ return data;
117
+ }
118
+
100
119
  // ============ Record API ============
101
120
  async createRecord(title: string, content: string, type: string = 'diary', tags?: string) {
102
121
  const { data } = await this.client.post('/api/v1/records', {
@@ -117,25 +136,58 @@ class ApiService {
117
136
  }
118
137
 
119
138
  // ============ Finance API ============
120
- async createFinance(amount: number, type: string, category: string, description?: string) {
139
+ async createFinance(amount: number, type: string, category: string, description?: string, account?: string, transactionDate?: string, tags?: string) {
121
140
  const { data } = await this.client.post('/api/v1/finances', {
122
141
  amount,
123
142
  type,
124
143
  category,
125
144
  description,
145
+ account,
146
+ transactionDate: toLocalDateTime(transactionDate),
147
+ tags,
126
148
  });
127
149
  return data;
128
150
  }
129
151
 
130
- async listFinances(type?: string, category?: string) {
152
+ async listFinances(type?: string, category?: string, month?: string) {
131
153
  const params: Record<string, string> = {};
132
154
  if (type) params.type = type;
133
155
  if (category) params.category = category;
156
+ if (month) params.month = month;
134
157
  const { data } = await this.client.get('/api/v1/finances', { params });
135
158
  return data;
136
159
  }
137
160
 
161
+ async getFinanceStats(month: string) {
162
+ const { data } = await this.client.get('/api/v1/finances/stats', { params: { month } });
163
+ return data;
164
+ }
165
+
166
+ async getFinanceReport(month: string) {
167
+ const { data } = await this.client.get('/api/v1/finances/report', { params: { month } });
168
+ return data;
169
+ }
170
+
138
171
  // ============ Health API ============
172
+ async logHealthData(dataType: string, value: string, unit?: string, date?: string) {
173
+ const { data } = await this.client.post('/api/v1/health-data', {
174
+ dataType,
175
+ value,
176
+ unit,
177
+ date: toLocalDateTime(date),
178
+ });
179
+ return data;
180
+ }
181
+
182
+ async listHealthData(dataType?: string, fromDate?: string, toDate?: string) {
183
+ const params: Record<string, string> = {};
184
+ if (dataType) params.dataType = dataType;
185
+ if (fromDate) params.fromDate = fromDate;
186
+ if (toDate) params.toDate = toDate;
187
+ const { data } = await this.client.get('/api/v1/health-data', { params });
188
+ return data;
189
+ }
190
+
139
191
  async syncHealthData() {
140
192
  const { data } = await this.client.post('/api/v1/health-data/sync');
141
193
  return data;
@@ -151,12 +203,19 @@ class ApiService {
151
203
  }
152
204
 
153
205
  // ============ Item API ============
154
- async createItem(name: string, category?: string, location?: string, expireDate?: string) {
206
+ async createItem(name: string, category?: string, location?: string, expireDate?: string, quantity?: number, unit?: string, brand?: string, barcode?: string, price?: number, supplier?: string, purchaseDate?: string) {
155
207
  const { data } = await this.client.post('/api/v1/items', {
156
208
  name,
157
209
  category,
158
210
  location,
159
211
  expireDate: toLocalDateTime(expireDate),
212
+ quantity,
213
+ unit,
214
+ brand,
215
+ barcode,
216
+ price,
217
+ supplier,
218
+ purchaseDate: toLocalDateTime(purchaseDate),
160
219
  });
161
220
  return data;
162
221
  }
@@ -169,14 +228,25 @@ class ApiService {
169
228
  return data;
170
229
  }
171
230
 
231
+ async getExpiringItems(days: number) {
232
+ const { data } = await this.client.get('/api/v1/items/expiring', { params: { days } });
233
+ return data;
234
+ }
235
+
236
+ async getItemStats() {
237
+ const { data } = await this.client.get('/api/v1/items/stats');
238
+ return data;
239
+ }
240
+
172
241
  // ============ Calendar API ============
173
- async createCalendarEvent(title: string, startTime: string, endTime: string, description?: string, location?: string) {
242
+ async createCalendarEvent(title: string, startTime: string, endTime: string, description?: string, location?: string, color?: string) {
174
243
  const { data } = await this.client.post('/api/v1/calendar-events', {
175
244
  title,
176
245
  startTime: toLocalDateTime(startTime),
177
246
  endTime: toLocalDateTime(endTime),
178
247
  description,
179
248
  location,
249
+ color,
180
250
  });
181
251
  return data;
182
252
  }
@@ -189,6 +259,36 @@ class ApiService {
189
259
  return data;
190
260
  }
191
261
 
262
+ // ============ Task API ============
263
+ async createTask(title: string, source?: string, openclawId?: string, priority?: string) {
264
+ const { data } = await this.client.post('/api/v1/tasks', {
265
+ title,
266
+ source: source || 'cli',
267
+ openclawId,
268
+ priority: priority || 'medium',
269
+ status: 'pending',
270
+ });
271
+ return data;
272
+ }
273
+
274
+ async listTasks(status?: string, source?: string) {
275
+ const params: Record<string, string> = {};
276
+ if (status) params.status = status;
277
+ if (source) params.source = source;
278
+ const { data } = await this.client.get('/api/v1/tasks', { params });
279
+ return data;
280
+ }
281
+
282
+ async updateTask(id: number, updates: Partial<{status: string; priority: string}>) {
283
+ const { data } = await this.client.patch(`/api/v1/tasks/${id}`, updates);
284
+ return data;
285
+ }
286
+
287
+ async deleteTask(id: number) {
288
+ const { data } = await this.client.delete(`/api/v1/tasks/${id}`);
289
+ return data;
290
+ }
291
+
192
292
  // ============ Reminder API ============
193
293
  async createReminder(title: string, triggerTime: string, type: string = 'once') {
194
294
  const { data } = await this.client.post('/api/v1/reminders', {
@@ -217,25 +317,6 @@ class ApiService {
217
317
  const { data } = await this.client.get('/api/v1/audit-logs', { params });
218
318
  return data;
219
319
  }
220
-
221
- // ============ Task API ============
222
- async createTask(title: string, description?: string, priority?: string, source?: string) {
223
- const { data } = await this.client.post('/api/v1/tasks', {
224
- title,
225
- description,
226
- priority: priority || 'medium',
227
- source: source || 'cli',
228
- status: 'pending',
229
- });
230
- return data;
231
- }
232
-
233
- async listTasks(status?: string) {
234
- const params: Record<string, string> = {};
235
- if (status) params.status = status;
236
- const { data } = await this.client.get('/api/v1/tasks', { params });
237
- return data;
238
- }
239
320
  }
240
321
 
241
322
  export const apiService = new ApiService();
@@ -0,0 +1,85 @@
1
+ /**
2
+ * .env 配置文件加载器
3
+ * 支持 SHAWNXIXI_API_URL 等环境变量
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+
9
+ interface EnvConfig {
10
+ API_URL: string;
11
+ API_TOKEN?: string;
12
+ }
13
+
14
+ let cachedConfig: EnvConfig | null = null;
15
+
16
+ /**
17
+ * 加载 .env 文件并返回配置
18
+ * 优先级:process.env > .env 文件 > 默认值
19
+ */
20
+ export function loadEnvConfig(): EnvConfig {
21
+ if (cachedConfig) return cachedConfig;
22
+
23
+ const defaultConfig: EnvConfig = {
24
+ API_URL: process.env.SHAWNXIXI_API_URL || 'http://localhost:8080/api/v1',
25
+ API_TOKEN: process.env.SHAWNXIXI_API_TOKEN,
26
+ };
27
+
28
+ // 尝试从当前目录和项目根目录加载 .env
29
+ const possiblePaths = [
30
+ path.join(process.cwd(), '.env'),
31
+ path.join(__dirname, '../../.env'),
32
+ path.join(__dirname, '../../../.env'),
33
+ ];
34
+
35
+ for (const envPath of possiblePaths) {
36
+ try {
37
+ if (fs.existsSync(envPath)) {
38
+ const content = fs.readFileSync(envPath, 'utf-8');
39
+ const envVars = parseEnvFile(content);
40
+
41
+ // 合并配置,process.env 优先级最高
42
+ cachedConfig = {
43
+ API_URL: process.env.SHAWNXIXI_API_URL || envVars.SHAWNXIXI_API_URL || defaultConfig.API_URL,
44
+ API_TOKEN: process.env.SHAWNXIXI_API_TOKEN || envVars.SHAWNXIXI_API_TOKEN || defaultConfig.API_TOKEN,
45
+ };
46
+ return cachedConfig;
47
+ }
48
+ } catch (e) {
49
+ // 忽略读取错误,继续尝试下一个路径
50
+ }
51
+ }
52
+
53
+ cachedConfig = defaultConfig;
54
+ return cachedConfig;
55
+ }
56
+
57
+ /**
58
+ * 简单 .env 文件解析器
59
+ */
60
+ function parseEnvFile(content: string): Record<string, string> {
61
+ const result: Record<string, string> = {};
62
+ const lines = content.split('\n');
63
+
64
+ for (const line of lines) {
65
+ const trimmed = line.trim();
66
+ // 跳过空行和注释
67
+ if (!trimmed || trimmed.startsWith('#')) continue;
68
+
69
+ const eqIndex = trimmed.indexOf('=');
70
+ if (eqIndex > 0) {
71
+ const key = trimmed.substring(0, eqIndex).trim();
72
+ let value = trimmed.substring(eqIndex + 1).trim();
73
+ // 去掉引号
74
+ if ((value.startsWith('"') && value.endsWith('"')) ||
75
+ (value.startsWith("'") && value.endsWith("'"))) {
76
+ value = value.slice(1, -1);
77
+ }
78
+ result[key] = value;
79
+ }
80
+ }
81
+
82
+ return result;
83
+ }
84
+
85
+ export const envConfig = loadEnvConfig();
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Finance Model Utilities
3
+ * 包含收支分类、账户余额计算、金额格式化等功能
4
+ */
5
+
6
+ export type FinanceType = 'income' | 'expense';
7
+
8
+ export interface FinanceItem {
9
+ id: string;
10
+ amount: number;
11
+ type: FinanceType;
12
+ category: string;
13
+ description?: string;
14
+ createdAt: string;
15
+ }
16
+
17
+ export interface Account {
18
+ id: string;
19
+ name: string;
20
+ balance: number;
21
+ currency: string;
22
+ }
23
+
24
+ export interface FinanceStats {
25
+ totalIncome: number;
26
+ totalExpense: number;
27
+ netBalance: number;
28
+ byCategory: Record<string, { income: number; expense: number }>;
29
+ }
30
+
31
+ /**
32
+ * 收入分类
33
+ */
34
+ export const INCOME_CATEGORIES = [
35
+ 'salary',
36
+ 'bonus',
37
+ 'investment',
38
+ 'gift',
39
+ 'refund',
40
+ 'other_income',
41
+ ] as const;
42
+
43
+ /**
44
+ * 支出分类
45
+ */
46
+ export const EXPENSE_CATEGORIES = [
47
+ 'food',
48
+ 'transport',
49
+ 'shopping',
50
+ 'entertainment',
51
+ 'housing',
52
+ 'healthcare',
53
+ 'education',
54
+ 'travel',
55
+ 'other',
56
+ ] as const;
57
+
58
+ /**
59
+ * 判断账单类型
60
+ */
61
+ export function categorizeFinance(amount: number, type?: string): FinanceType {
62
+ if (type === 'income' || type === 'expense') {
63
+ return type;
64
+ }
65
+ // 根据金额正负判断
66
+ return amount >= 0 ? 'income' : 'expense';
67
+ }
68
+
69
+ /**
70
+ * 获取默认分类
71
+ */
72
+ export function getDefaultCategory(type: FinanceType): string {
73
+ return type === 'income' ? 'other_income' : 'other';
74
+ }
75
+
76
+ /**
77
+ * 验证分类是否有效
78
+ */
79
+ export function isValidCategory(type: FinanceType, category: string): boolean {
80
+ const categories = type === 'income' ? INCOME_CATEGORIES : EXPENSE_CATEGORIES;
81
+ return (categories as readonly string[]).some(c => c === category);
82
+ }
83
+
84
+ /**
85
+ * 计算账户余额
86
+ */
87
+ export function calculateBalance(items: FinanceItem[]): number {
88
+ return items.reduce((balance, item) => {
89
+ if (item.type === 'income') {
90
+ return balance + item.amount;
91
+ } else {
92
+ return balance - item.amount;
93
+ }
94
+ }, 0);
95
+ }
96
+
97
+ /**
98
+ * 计算账单统计
99
+ */
100
+ export function calculateFinanceStats(items: FinanceItem[]): FinanceStats {
101
+ const stats: FinanceStats = {
102
+ totalIncome: 0,
103
+ totalExpense: 0,
104
+ netBalance: 0,
105
+ byCategory: {},
106
+ };
107
+
108
+ for (const item of items) {
109
+ if (!stats.byCategory[item.category]) {
110
+ stats.byCategory[item.category] = { income: 0, expense: 0 };
111
+ }
112
+
113
+ if (item.type === 'income') {
114
+ stats.totalIncome += item.amount;
115
+ stats.byCategory[item.category].income += item.amount;
116
+ } else {
117
+ stats.totalExpense += item.amount;
118
+ stats.byCategory[item.category].expense += item.amount;
119
+ }
120
+ }
121
+
122
+ stats.netBalance = stats.totalIncome - stats.totalExpense;
123
+ return stats;
124
+ }
125
+
126
+ /**
127
+ * 格式化金额
128
+ * @param amount 金额
129
+ * @param currency 货币符号,默认 ¥
130
+ * @param showSign 是否显示正负号,默认 false
131
+ */
132
+ export function formatAmount(
133
+ amount: number,
134
+ currency: string = '¥',
135
+ showSign: boolean = false
136
+ ): string {
137
+ const formatted = Math.abs(amount).toFixed(2);
138
+ const sign = showSign ? (amount >= 0 ? '+' : '-') : '';
139
+
140
+ // 格式化千分位
141
+ const parts = formatted.split('.');
142
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
143
+
144
+ return `${sign}${currency}${parts.join('.')}`;
145
+ }
146
+
147
+ /**
148
+ * 解析金额字符串
149
+ */
150
+ export function parseAmount(amountStr: string): number {
151
+ // 移除货币符号和千分位逗号
152
+ const cleaned = amountStr
153
+ .replace(/[¥$,]/g, '')
154
+ .trim();
155
+ const num = parseFloat(cleaned);
156
+ return isNaN(num) ? 0 : num;
157
+ }
158
+
159
+ /**
160
+ * 判断是否为有效金额
161
+ */
162
+ export function isValidAmount(amount: number): boolean {
163
+ return !isNaN(amount) && isFinite(amount) && amount !== 0;
164
+ }
165
+
166
+ /**
167
+ * 计算日均支出
168
+ */
169
+ export function calculateDailyAverage(expense: number, days: number): number {
170
+ if (days <= 0) return 0;
171
+ return expense / days;
172
+ }
173
+
174
+ /**
175
+ * 计算月度预算剩余
176
+ */
177
+ export function calculateBudgetRemaining(
178
+ totalBudget: number,
179
+ spent: number
180
+ ): number {
181
+ return totalBudget - spent;
182
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 全局输出格式化工具
3
+ * 支持 --json / --quiet / --dry-run 全局参数
4
+ */
5
+
6
+ // 全局标志(由 index.ts 在解析全局参数后设置)
7
+ export let globalJsonFlag = false;
8
+ export let globalQuietFlag = false;
9
+ export let globalDryRunFlag = false;
10
+
11
+ export function setGlobalFlags(args: { json?: boolean; quiet?: boolean; dryRun?: boolean }) {
12
+ if (args.json !== undefined) globalJsonFlag = args.json;
13
+ if (args.quiet !== undefined) globalQuietFlag = args.quiet;
14
+ if (args.dryRun !== undefined) globalDryRunFlag = args.dryRun;
15
+ }
16
+
17
+ /**
18
+ * 统一输出函数
19
+ */
20
+ export function output(data: any, options: { quiet?: boolean } = {}): void {
21
+ if (globalQuietFlag || options.quiet) return;
22
+
23
+ if (globalJsonFlag) {
24
+ console.log(JSON.stringify(data, null, 2));
25
+ } else {
26
+ console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
27
+ }
28
+ }
29
+
30
+ /**
31
+ * 错误输出
32
+ */
33
+ export function outputError(message: string, ...args: any[]): void {
34
+ if (!globalQuietFlag) {
35
+ console.error(`\x1b[31m✖ ${message}\x1b[0m`, ...args);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * 成功输出
41
+ */
42
+ export function outputSuccess(message: string, ...args: any[]): void {
43
+ if (!globalQuietFlag) {
44
+ console.log(`\x1b[32m✔ ${message}\x1b[0m`, ...args);
45
+ }
46
+ }
47
+
48
+ // Simple loading indicator (ora ESM issue workaround)
49
+ const spinners = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
50
+ let spinnerInterval: NodeJS.Timeout | null = null;
51
+
52
+ export interface LoadingInstance {
53
+ start: () => void;
54
+ succeed: (text?: string) => void;
55
+ fail: (text?: string) => void;
56
+ stop: () => void;
57
+ }
58
+
59
+ /**
60
+ * 创建 Loading 实例(简单实现,兼容 CJS)
61
+ */
62
+ export function createLoading(text: string): LoadingInstance {
63
+ let currentText = text;
64
+ let index = 0;
65
+ let started = false;
66
+
67
+ const instance: LoadingInstance = {
68
+ start: () => {
69
+ if (started || globalQuietFlag) return;
70
+ started = true;
71
+ process.stdout.write('\x1b[?25l'); // hide cursor
72
+ spinnerInterval = setInterval(() => {
73
+ process.stdout.write(`\r${spinners[index % spinners.length]} ${currentText}`);
74
+ index++;
75
+ }, 80);
76
+ },
77
+ succeed: (text?: string) => {
78
+ if (spinnerInterval) {
79
+ clearInterval(spinnerInterval);
80
+ spinnerInterval = null;
81
+ }
82
+ process.stdout.write('\x1b[?25h'); // show cursor
83
+ process.stdout.write('\r' + ' '.repeat(currentText.length + 3) + '\r');
84
+ if (!globalQuietFlag) {
85
+ console.log(`\x1b[32m✔ ${text || currentText}\x1b[0m`);
86
+ }
87
+ },
88
+ fail: (text?: string) => {
89
+ if (spinnerInterval) {
90
+ clearInterval(spinnerInterval);
91
+ spinnerInterval = null;
92
+ }
93
+ process.stdout.write('\x1b[?25h'); // show cursor
94
+ process.stdout.write('\r' + ' '.repeat(currentText.length + 3) + '\r');
95
+ if (!globalQuietFlag) {
96
+ console.error(`\x1b[31m✖ ${text || currentText}\x1b[0m`);
97
+ }
98
+ },
99
+ stop: () => {
100
+ if (spinnerInterval) {
101
+ clearInterval(spinnerInterval);
102
+ spinnerInterval = null;
103
+ }
104
+ process.stdout.write('\x1b[?25h'); // show cursor
105
+ process.stdout.write('\r' + ' '.repeat(currentText.length + 3) + '\r');
106
+ },
107
+ };
108
+
109
+ return instance;
110
+ }
111
+
112
+ /**
113
+ * Dry-run 检查,如果处于预览模式则输出并返回 true
114
+ */
115
+ export function isDryRun(): boolean {
116
+ return globalDryRunFlag;
117
+ }
118
+
119
+ export function dryRunLog(action: string, details?: any): void {
120
+ if (globalDryRunFlag) {
121
+ console.log(`\x1b[33m[DRY-RUN]\x1b[0m ${action}`);
122
+ if (details && !globalQuietFlag) {
123
+ console.log(JSON.stringify(details, null, 2));
124
+ }
125
+ }
126
+ }