@girardmedia/bootspring 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.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/agents/README.md +93 -0
- package/agents/api-expert/context.md +416 -0
- package/agents/architecture-expert/context.md +454 -0
- package/agents/backend-expert/context.md +483 -0
- package/agents/code-review-expert/context.md +365 -0
- package/agents/database-expert/context.md +250 -0
- package/agents/devops-expert/context.md +446 -0
- package/agents/frontend-expert/context.md +364 -0
- package/agents/index.js +140 -0
- package/agents/performance-expert/context.md +377 -0
- package/agents/security-expert/context.md +343 -0
- package/agents/testing-expert/context.md +414 -0
- package/agents/ui-ux-expert/context.md +448 -0
- package/agents/vercel-expert/context.md +426 -0
- package/bin/bootspring.js +310 -0
- package/cli/agent.js +337 -0
- package/cli/context.js +194 -0
- package/cli/dashboard.js +150 -0
- package/cli/generate.js +294 -0
- package/cli/init.js +410 -0
- package/cli/loop.js +421 -0
- package/cli/mcp.js +241 -0
- package/cli/memory.js +303 -0
- package/cli/orchestrator.js +400 -0
- package/cli/plugin.js +451 -0
- package/cli/quality.js +332 -0
- package/cli/skill.js +369 -0
- package/cli/task.js +628 -0
- package/cli/telemetry.js +114 -0
- package/cli/todo.js +614 -0
- package/cli/update.js +312 -0
- package/core/config.js +245 -0
- package/core/context.js +329 -0
- package/core/entitlements.js +209 -0
- package/core/index.js +43 -0
- package/core/policies.js +68 -0
- package/core/telemetry.js +247 -0
- package/core/utils.js +380 -0
- package/dashboard/server.js +818 -0
- package/docs/integrations/claude-code.md +42 -0
- package/docs/integrations/codex.md +42 -0
- package/docs/mcp-api-platform.md +102 -0
- package/generators/generate.js +598 -0
- package/generators/index.js +18 -0
- package/hooks/context-detector.js +177 -0
- package/hooks/index.js +35 -0
- package/hooks/prompt-enhancer.js +289 -0
- package/intelligence/git-memory.js +551 -0
- package/intelligence/index.js +59 -0
- package/intelligence/orchestrator.js +964 -0
- package/intelligence/prd.js +447 -0
- package/intelligence/recommendation-weights.json +18 -0
- package/intelligence/recommendations.js +234 -0
- package/mcp/capabilities.js +71 -0
- package/mcp/contracts/mcp-contract.v1.json +497 -0
- package/mcp/registry.js +213 -0
- package/mcp/response-formatter.js +462 -0
- package/mcp/server.js +99 -0
- package/mcp/tools/agent-tool.js +137 -0
- package/mcp/tools/capabilities-tool.js +54 -0
- package/mcp/tools/context-tool.js +49 -0
- package/mcp/tools/dashboard-tool.js +58 -0
- package/mcp/tools/generate-tool.js +46 -0
- package/mcp/tools/loop-tool.js +134 -0
- package/mcp/tools/memory-tool.js +180 -0
- package/mcp/tools/orchestrator-tool.js +232 -0
- package/mcp/tools/plugin-tool.js +76 -0
- package/mcp/tools/quality-tool.js +47 -0
- package/mcp/tools/skill-tool.js +233 -0
- package/mcp/tools/telemetry-tool.js +95 -0
- package/mcp/tools/todo-tool.js +133 -0
- package/package.json +98 -0
- package/plugins/index.js +141 -0
- package/quality/index.js +380 -0
- package/quality/lint-budgets.json +19 -0
- package/skills/index.js +787 -0
- package/skills/patterns/README.md +163 -0
- package/skills/patterns/api/route-handler.md +217 -0
- package/skills/patterns/api/server-action.md +249 -0
- package/skills/patterns/auth/clerk.md +132 -0
- package/skills/patterns/database/prisma.md +180 -0
- package/skills/patterns/payments/stripe.md +272 -0
- package/skills/patterns/security/validation.md +268 -0
- package/skills/patterns/testing/vitest.md +307 -0
- package/templates/bootspring.config.js +83 -0
- package/templates/mcp.json +9 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootspring Telemetry
|
|
3
|
+
* Lightweight JSONL event emitter for product instrumentation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const MAX_EVENTS_LIMIT = 10000;
|
|
11
|
+
|
|
12
|
+
function getTelemetryDir(projectRoot = process.cwd()) {
|
|
13
|
+
return path.join(projectRoot, '.bootspring', 'telemetry');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getTelemetryFile(projectRoot = process.cwd()) {
|
|
17
|
+
return path.join(getTelemetryDir(projectRoot), 'events.jsonl');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ensureTelemetryDir(projectRoot = process.cwd()) {
|
|
21
|
+
const dir = getTelemetryDir(projectRoot);
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function emitEvent(event, payload = {}, options = {}) {
|
|
27
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
28
|
+
ensureTelemetryDir(projectRoot);
|
|
29
|
+
const file = getTelemetryFile(projectRoot);
|
|
30
|
+
const record = {
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
event: String(event || '').trim(),
|
|
33
|
+
payload: payload || {}
|
|
34
|
+
};
|
|
35
|
+
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf-8');
|
|
36
|
+
return record;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseEventLine(line) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(line);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function listEvents(options = {}) {
|
|
48
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
49
|
+
const file = getTelemetryFile(projectRoot);
|
|
50
|
+
if (!fs.existsSync(file)) return [];
|
|
51
|
+
|
|
52
|
+
const eventFilter = String(options.event || '').trim();
|
|
53
|
+
const from = options.from ? new Date(options.from).getTime() : null;
|
|
54
|
+
const to = options.to ? new Date(options.to).getTime() : null;
|
|
55
|
+
const limit = Number(options.limit);
|
|
56
|
+
|
|
57
|
+
const lines = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean);
|
|
58
|
+
let records = lines
|
|
59
|
+
.map(parseEventLine)
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.filter(record => {
|
|
62
|
+
if (eventFilter && record.event !== eventFilter) return false;
|
|
63
|
+
const ts = new Date(record.timestamp).getTime();
|
|
64
|
+
if (Number.isFinite(from) && ts < from) return false;
|
|
65
|
+
if (Number.isFinite(to) && ts > to) return false;
|
|
66
|
+
return true;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const effectiveLimit = Number.isFinite(limit) && limit > 0
|
|
70
|
+
? Math.min(limit, MAX_EVENTS_LIMIT)
|
|
71
|
+
: MAX_EVENTS_LIMIT;
|
|
72
|
+
records = records.slice(-effectiveLimit);
|
|
73
|
+
return records;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clearEvents(options = {}) {
|
|
77
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
78
|
+
const file = getTelemetryFile(projectRoot);
|
|
79
|
+
const records = listEvents({ projectRoot });
|
|
80
|
+
if (fs.existsSync(file)) {
|
|
81
|
+
fs.writeFileSync(file, '', 'utf-8');
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
cleared: records.length,
|
|
85
|
+
file
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getStatus(options = {}) {
|
|
90
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
91
|
+
const file = getTelemetryFile(projectRoot);
|
|
92
|
+
const records = listEvents({ projectRoot });
|
|
93
|
+
return {
|
|
94
|
+
file,
|
|
95
|
+
exists: fs.existsSync(file),
|
|
96
|
+
count: records.length,
|
|
97
|
+
lastEventAt: records.length > 0 ? records[records.length - 1].timestamp : null
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sleep(ms) {
|
|
102
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function chunkArray(items, size) {
|
|
106
|
+
const chunks = [];
|
|
107
|
+
for (let i = 0; i < items.length; i += size) {
|
|
108
|
+
chunks.push(items.slice(i, i + size));
|
|
109
|
+
}
|
|
110
|
+
return chunks;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function postBatchWithRetry(endpoint, body, headers, options = {}) {
|
|
114
|
+
const maxRetries = Number(options.maxRetries);
|
|
115
|
+
const retryDelayMs = Number(options.retryDelayMs);
|
|
116
|
+
const retries = Number.isFinite(maxRetries) && maxRetries >= 0 ? maxRetries : 2;
|
|
117
|
+
const delayBase = Number.isFinite(retryDelayMs) && retryDelayMs > 0 ? retryDelayMs : 300;
|
|
118
|
+
|
|
119
|
+
let attempt = 0;
|
|
120
|
+
while (true) {
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(endpoint, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers,
|
|
125
|
+
body: JSON.stringify(body)
|
|
126
|
+
});
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
throw new Error(`HTTP ${response.status}`);
|
|
129
|
+
}
|
|
130
|
+
return { success: true, attempts: attempt + 1 };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (attempt >= retries) {
|
|
133
|
+
return {
|
|
134
|
+
success: false,
|
|
135
|
+
attempts: attempt + 1,
|
|
136
|
+
error: error.message || String(error)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
attempt += 1;
|
|
140
|
+
const delay = delayBase * (2 ** (attempt - 1));
|
|
141
|
+
await sleep(delay);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function uploadEvents(options = {}) {
|
|
147
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
148
|
+
const endpoint = options.endpoint || process.env.BOOTSPRING_TELEMETRY_ENDPOINT;
|
|
149
|
+
if (!endpoint) {
|
|
150
|
+
throw new Error('Missing telemetry endpoint. Set BOOTSPRING_TELEMETRY_ENDPOINT or pass endpoint.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const token = options.token || process.env.BOOTSPRING_TELEMETRY_TOKEN;
|
|
154
|
+
const event = options.event;
|
|
155
|
+
const limit = Number(options.limit) || undefined;
|
|
156
|
+
const batchSizeOption = Number(options.batchSize || process.env.BOOTSPRING_TELEMETRY_BATCH_SIZE);
|
|
157
|
+
const batchSize = Number.isFinite(batchSizeOption) && batchSizeOption > 0 ? batchSizeOption : 100;
|
|
158
|
+
const clearOnSuccess = options.clearOnSuccess === true;
|
|
159
|
+
const records = listEvents({ projectRoot, event, limit });
|
|
160
|
+
|
|
161
|
+
if (records.length === 0) {
|
|
162
|
+
return {
|
|
163
|
+
uploaded: 0,
|
|
164
|
+
remaining: 0,
|
|
165
|
+
endpoint
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const headers = {
|
|
170
|
+
'content-type': 'application/json',
|
|
171
|
+
accept: 'application/json'
|
|
172
|
+
};
|
|
173
|
+
if (token) {
|
|
174
|
+
headers.authorization = `Bearer ${token}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const batches = chunkArray(records, batchSize);
|
|
178
|
+
let uploaded = 0;
|
|
179
|
+
let totalAttempts = 0;
|
|
180
|
+
const failedBatches = [];
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < batches.length; i++) {
|
|
183
|
+
const events = batches[i];
|
|
184
|
+
const batchId = crypto.createHash('sha1')
|
|
185
|
+
.update(JSON.stringify(events))
|
|
186
|
+
.digest('hex');
|
|
187
|
+
const result = await postBatchWithRetry(
|
|
188
|
+
endpoint,
|
|
189
|
+
{
|
|
190
|
+
source: 'bootspring',
|
|
191
|
+
batch: {
|
|
192
|
+
index: i,
|
|
193
|
+
total: batches.length,
|
|
194
|
+
id: batchId
|
|
195
|
+
},
|
|
196
|
+
events
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
...headers,
|
|
200
|
+
'x-bootspring-batch-id': batchId
|
|
201
|
+
},
|
|
202
|
+
options
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
totalAttempts += result.attempts || 1;
|
|
206
|
+
if (!result.success) {
|
|
207
|
+
failedBatches.push({
|
|
208
|
+
index: i,
|
|
209
|
+
count: events.length,
|
|
210
|
+
error: result.error || 'upload_failed'
|
|
211
|
+
});
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
uploaded += events.length;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (failedBatches.length > 0) {
|
|
218
|
+
throw new Error(`Telemetry upload failed for ${failedBatches.length}/${batches.length} batches`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (clearOnSuccess) {
|
|
222
|
+
clearEvents({ projectRoot });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const remaining = clearOnSuccess ? 0 : listEvents({ projectRoot }).length;
|
|
226
|
+
return {
|
|
227
|
+
uploaded,
|
|
228
|
+
attempted: records.length,
|
|
229
|
+
batches: batches.length,
|
|
230
|
+
attempts: totalAttempts,
|
|
231
|
+
remaining,
|
|
232
|
+
endpoint,
|
|
233
|
+
failedBatches
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = {
|
|
238
|
+
MAX_EVENTS_LIMIT,
|
|
239
|
+
getTelemetryDir,
|
|
240
|
+
getTelemetryFile,
|
|
241
|
+
ensureTelemetryDir,
|
|
242
|
+
emitEvent,
|
|
243
|
+
listEvents,
|
|
244
|
+
clearEvents,
|
|
245
|
+
getStatus,
|
|
246
|
+
uploadEvents
|
|
247
|
+
};
|
package/core/utils.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootspring Utilities
|
|
3
|
+
* Shared utility functions
|
|
4
|
+
*
|
|
5
|
+
* @package bootspring
|
|
6
|
+
* @module core/utils
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
// ANSI color codes
|
|
13
|
+
const COLORS = {
|
|
14
|
+
reset: '\x1b[0m',
|
|
15
|
+
bold: '\x1b[1m',
|
|
16
|
+
dim: '\x1b[2m',
|
|
17
|
+
italic: '\x1b[3m',
|
|
18
|
+
underline: '\x1b[4m',
|
|
19
|
+
|
|
20
|
+
// Foreground
|
|
21
|
+
black: '\x1b[30m',
|
|
22
|
+
red: '\x1b[31m',
|
|
23
|
+
green: '\x1b[32m',
|
|
24
|
+
yellow: '\x1b[33m',
|
|
25
|
+
blue: '\x1b[34m',
|
|
26
|
+
magenta: '\x1b[35m',
|
|
27
|
+
cyan: '\x1b[36m',
|
|
28
|
+
white: '\x1b[37m',
|
|
29
|
+
|
|
30
|
+
// Background
|
|
31
|
+
bgBlack: '\x1b[40m',
|
|
32
|
+
bgRed: '\x1b[41m',
|
|
33
|
+
bgGreen: '\x1b[42m',
|
|
34
|
+
bgYellow: '\x1b[43m',
|
|
35
|
+
bgBlue: '\x1b[44m',
|
|
36
|
+
bgMagenta: '\x1b[45m',
|
|
37
|
+
bgCyan: '\x1b[46m',
|
|
38
|
+
bgWhite: '\x1b[47m'
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Styled console output
|
|
43
|
+
*/
|
|
44
|
+
const print = {
|
|
45
|
+
info: (msg) => console.log(`${COLORS.cyan}ℹ${COLORS.reset} ${msg}`),
|
|
46
|
+
success: (msg) => console.log(`${COLORS.green}✓${COLORS.reset} ${msg}`),
|
|
47
|
+
warning: (msg) => console.log(`${COLORS.yellow}⚠${COLORS.reset} ${msg}`),
|
|
48
|
+
error: (msg) => console.log(`${COLORS.red}✗${COLORS.reset} ${msg}`),
|
|
49
|
+
debug: (msg) => process.env.DEBUG && console.log(`${COLORS.dim}⋯ ${msg}${COLORS.reset}`),
|
|
50
|
+
header: (msg) => console.log(`\n${COLORS.bold}${COLORS.cyan}${msg}${COLORS.reset}\n`),
|
|
51
|
+
dim: (msg) => console.log(`${COLORS.dim}${msg}${COLORS.reset}`),
|
|
52
|
+
brand: (msg) => console.log(`${COLORS.cyan}⚡${COLORS.reset} ${msg}`)
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create a spinner for async operations
|
|
57
|
+
* @param {string} message - Spinner message
|
|
58
|
+
* @returns {object} Spinner controller
|
|
59
|
+
*/
|
|
60
|
+
function createSpinner(message) {
|
|
61
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
62
|
+
let frameIndex = 0;
|
|
63
|
+
let interval = null;
|
|
64
|
+
const isTTY = process.stdout.isTTY;
|
|
65
|
+
|
|
66
|
+
// Helper to clear line safely
|
|
67
|
+
const clearLine = () => {
|
|
68
|
+
if (isTTY && process.stdout.clearLine) {
|
|
69
|
+
process.stdout.clearLine(0);
|
|
70
|
+
process.stdout.cursorTo(0);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
start() {
|
|
76
|
+
if (isTTY) {
|
|
77
|
+
process.stdout.write(`${COLORS.cyan}${frames[0]}${COLORS.reset} ${message}`);
|
|
78
|
+
interval = setInterval(() => {
|
|
79
|
+
frameIndex = (frameIndex + 1) % frames.length;
|
|
80
|
+
clearLine();
|
|
81
|
+
process.stdout.write(`${COLORS.cyan}${frames[frameIndex]}${COLORS.reset} ${message}`);
|
|
82
|
+
}, 80);
|
|
83
|
+
}
|
|
84
|
+
return this;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
succeed(text = message) {
|
|
88
|
+
if (interval) clearInterval(interval);
|
|
89
|
+
clearLine();
|
|
90
|
+
console.log(`${COLORS.green}✓${COLORS.reset} ${text}`);
|
|
91
|
+
return this;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
fail(text = message) {
|
|
95
|
+
if (interval) clearInterval(interval);
|
|
96
|
+
clearLine();
|
|
97
|
+
console.log(`${COLORS.red}✗${COLORS.reset} ${text}`);
|
|
98
|
+
return this;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
warn(text = message) {
|
|
102
|
+
if (interval) clearInterval(interval);
|
|
103
|
+
clearLine();
|
|
104
|
+
console.log(`${COLORS.yellow}⚠${COLORS.reset} ${text}`);
|
|
105
|
+
return this;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
info(text = message) {
|
|
109
|
+
if (interval) clearInterval(interval);
|
|
110
|
+
clearLine();
|
|
111
|
+
console.log(`${COLORS.cyan}ℹ${COLORS.reset} ${text}`);
|
|
112
|
+
return this;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
stop() {
|
|
116
|
+
if (interval) clearInterval(interval);
|
|
117
|
+
clearLine();
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Ensure directory exists
|
|
125
|
+
* @param {string} dirPath - Directory path
|
|
126
|
+
* @returns {boolean} Success status
|
|
127
|
+
*/
|
|
128
|
+
function ensureDir(dirPath) {
|
|
129
|
+
try {
|
|
130
|
+
if (!fs.existsSync(dirPath)) {
|
|
131
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Read file safely
|
|
141
|
+
* @param {string} filepath - File path
|
|
142
|
+
* @param {string} [defaultValue=null] - Default value if file doesn't exist
|
|
143
|
+
* @returns {string|null} File contents or default value (null if not found)
|
|
144
|
+
*/
|
|
145
|
+
function readFile(filepath, defaultValue = null) {
|
|
146
|
+
try {
|
|
147
|
+
return fs.readFileSync(filepath, 'utf-8');
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return defaultValue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Write file safely
|
|
155
|
+
* @param {string} filepath - File path
|
|
156
|
+
* @param {string} content - File content
|
|
157
|
+
* @returns {boolean} Success status
|
|
158
|
+
*/
|
|
159
|
+
function writeFile(filepath, content) {
|
|
160
|
+
try {
|
|
161
|
+
const dir = path.dirname(filepath);
|
|
162
|
+
ensureDir(dir);
|
|
163
|
+
fs.writeFileSync(filepath, content, 'utf-8');
|
|
164
|
+
return true;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check if file exists
|
|
172
|
+
* @param {string} filepath - File path
|
|
173
|
+
* @returns {boolean} Exists status
|
|
174
|
+
*/
|
|
175
|
+
function fileExists(filepath) {
|
|
176
|
+
try {
|
|
177
|
+
return fs.existsSync(filepath);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get file modification time
|
|
185
|
+
* @param {string} filepath - File path
|
|
186
|
+
* @returns {Date|null} Modification date or null
|
|
187
|
+
*/
|
|
188
|
+
function getFileTime(filepath) {
|
|
189
|
+
try {
|
|
190
|
+
const stats = fs.statSync(filepath);
|
|
191
|
+
return stats.mtime;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Format date as ISO string without time
|
|
199
|
+
* @param {Date} date - Date object
|
|
200
|
+
* @returns {string} Formatted date
|
|
201
|
+
*/
|
|
202
|
+
function formatDate(date = new Date()) {
|
|
203
|
+
return date.toISOString().split('T')[0];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Format date as relative time
|
|
208
|
+
* @param {Date} date - Date object
|
|
209
|
+
* @returns {string} Relative time string
|
|
210
|
+
*/
|
|
211
|
+
function formatRelativeTime(date) {
|
|
212
|
+
const now = new Date();
|
|
213
|
+
const diff = now - date;
|
|
214
|
+
const seconds = Math.floor(diff / 1000);
|
|
215
|
+
const minutes = Math.floor(seconds / 60);
|
|
216
|
+
const hours = Math.floor(minutes / 60);
|
|
217
|
+
const days = Math.floor(hours / 24);
|
|
218
|
+
|
|
219
|
+
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
|
|
220
|
+
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
221
|
+
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
|
222
|
+
return 'just now';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Slugify a string
|
|
227
|
+
* @param {string} str - Input string
|
|
228
|
+
* @returns {string} Slugified string
|
|
229
|
+
*/
|
|
230
|
+
function slugify(str) {
|
|
231
|
+
return str
|
|
232
|
+
.toLowerCase()
|
|
233
|
+
.trim()
|
|
234
|
+
.replace(/[^\w\s-]/g, '')
|
|
235
|
+
.replace(/[\s_-]+/g, '-')
|
|
236
|
+
.replace(/^-+|-+$/g, '');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Truncate string with ellipsis
|
|
241
|
+
* @param {string} str - Input string
|
|
242
|
+
* @param {number} length - Max length
|
|
243
|
+
* @returns {string} Truncated string
|
|
244
|
+
*/
|
|
245
|
+
function truncate(str, length = 50) {
|
|
246
|
+
if (str.length <= length) return str;
|
|
247
|
+
return str.slice(0, length - 3) + '...';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Parse command line arguments
|
|
252
|
+
* @param {string[]} args - Argument array
|
|
253
|
+
* @returns {object} Parsed arguments { _: positional, ...flags }
|
|
254
|
+
*/
|
|
255
|
+
function parseArgs(args) {
|
|
256
|
+
const result = { _: [] };
|
|
257
|
+
|
|
258
|
+
for (let i = 0; i < args.length; i++) {
|
|
259
|
+
const arg = args[i];
|
|
260
|
+
|
|
261
|
+
if (arg.startsWith('--')) {
|
|
262
|
+
const key = arg.slice(2);
|
|
263
|
+
const nextArg = args[i + 1];
|
|
264
|
+
|
|
265
|
+
if (key.includes('=')) {
|
|
266
|
+
const [k, v] = key.split('=');
|
|
267
|
+
result[k] = v;
|
|
268
|
+
} else if (nextArg && !nextArg.startsWith('-')) {
|
|
269
|
+
result[key] = nextArg;
|
|
270
|
+
i++;
|
|
271
|
+
} else {
|
|
272
|
+
result[key] = true;
|
|
273
|
+
}
|
|
274
|
+
} else if (arg.startsWith('-') && arg.length === 2) {
|
|
275
|
+
// Short flag (e.g., -v, -n)
|
|
276
|
+
const key = arg.slice(1);
|
|
277
|
+
const nextArg = args[i + 1];
|
|
278
|
+
|
|
279
|
+
// Check if next argument is a value (not another flag)
|
|
280
|
+
if (nextArg && !nextArg.startsWith('-')) {
|
|
281
|
+
result[key] = nextArg;
|
|
282
|
+
i++;
|
|
283
|
+
} else {
|
|
284
|
+
result[key] = true;
|
|
285
|
+
}
|
|
286
|
+
} else if (arg.startsWith('-')) {
|
|
287
|
+
// Combined short flags (e.g., -abc) - treat each as boolean
|
|
288
|
+
const keys = arg.slice(1).split('');
|
|
289
|
+
for (const key of keys) {
|
|
290
|
+
result[key] = true;
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
result._.push(arg);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Create a simple table for console output
|
|
302
|
+
* @param {string[]} headers - Table headers
|
|
303
|
+
* @param {string[][]} rows - Table rows
|
|
304
|
+
* @returns {string} Formatted table
|
|
305
|
+
*/
|
|
306
|
+
function createTable(headers, rows) {
|
|
307
|
+
const widths = headers.map((h, i) => {
|
|
308
|
+
const colValues = [h, ...rows.map(r => String(r[i] || ''))];
|
|
309
|
+
return Math.max(...colValues.map(v => v.length));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const separator = widths.map(w => '─'.repeat(w + 2)).join('┼');
|
|
313
|
+
const formatRow = (row) => row.map((cell, i) => ` ${String(cell).padEnd(widths[i])} `).join('│');
|
|
314
|
+
|
|
315
|
+
const lines = [
|
|
316
|
+
formatRow(headers),
|
|
317
|
+
separator,
|
|
318
|
+
...rows.map(formatRow)
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
return lines.join('\n');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Deep clone an object
|
|
326
|
+
* @param {object} obj - Object to clone
|
|
327
|
+
* @returns {object} Cloned object
|
|
328
|
+
*/
|
|
329
|
+
function deepClone(obj) {
|
|
330
|
+
return JSON.parse(JSON.stringify(obj));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Check if running in CI environment
|
|
335
|
+
* @returns {boolean} CI status
|
|
336
|
+
*/
|
|
337
|
+
function isCI() {
|
|
338
|
+
return !!(
|
|
339
|
+
process.env.CI ||
|
|
340
|
+
process.env.CONTINUOUS_INTEGRATION ||
|
|
341
|
+
process.env.GITHUB_ACTIONS ||
|
|
342
|
+
process.env.GITLAB_CI ||
|
|
343
|
+
process.env.CIRCLECI ||
|
|
344
|
+
process.env.TRAVIS
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Get package.json from project
|
|
350
|
+
* @param {string} projectRoot - Project root path
|
|
351
|
+
* @returns {object|null} Package.json contents or null
|
|
352
|
+
*/
|
|
353
|
+
function getPackageJson(projectRoot) {
|
|
354
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
355
|
+
try {
|
|
356
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
module.exports = {
|
|
363
|
+
COLORS,
|
|
364
|
+
print,
|
|
365
|
+
createSpinner,
|
|
366
|
+
ensureDir,
|
|
367
|
+
readFile,
|
|
368
|
+
writeFile,
|
|
369
|
+
fileExists,
|
|
370
|
+
getFileTime,
|
|
371
|
+
formatDate,
|
|
372
|
+
formatRelativeTime,
|
|
373
|
+
slugify,
|
|
374
|
+
truncate,
|
|
375
|
+
parseArgs,
|
|
376
|
+
createTable,
|
|
377
|
+
deepClone,
|
|
378
|
+
isCI,
|
|
379
|
+
getPackageJson
|
|
380
|
+
};
|