@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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +255 -0
  3. package/agents/README.md +93 -0
  4. package/agents/api-expert/context.md +416 -0
  5. package/agents/architecture-expert/context.md +454 -0
  6. package/agents/backend-expert/context.md +483 -0
  7. package/agents/code-review-expert/context.md +365 -0
  8. package/agents/database-expert/context.md +250 -0
  9. package/agents/devops-expert/context.md +446 -0
  10. package/agents/frontend-expert/context.md +364 -0
  11. package/agents/index.js +140 -0
  12. package/agents/performance-expert/context.md +377 -0
  13. package/agents/security-expert/context.md +343 -0
  14. package/agents/testing-expert/context.md +414 -0
  15. package/agents/ui-ux-expert/context.md +448 -0
  16. package/agents/vercel-expert/context.md +426 -0
  17. package/bin/bootspring.js +310 -0
  18. package/cli/agent.js +337 -0
  19. package/cli/context.js +194 -0
  20. package/cli/dashboard.js +150 -0
  21. package/cli/generate.js +294 -0
  22. package/cli/init.js +410 -0
  23. package/cli/loop.js +421 -0
  24. package/cli/mcp.js +241 -0
  25. package/cli/memory.js +303 -0
  26. package/cli/orchestrator.js +400 -0
  27. package/cli/plugin.js +451 -0
  28. package/cli/quality.js +332 -0
  29. package/cli/skill.js +369 -0
  30. package/cli/task.js +628 -0
  31. package/cli/telemetry.js +114 -0
  32. package/cli/todo.js +614 -0
  33. package/cli/update.js +312 -0
  34. package/core/config.js +245 -0
  35. package/core/context.js +329 -0
  36. package/core/entitlements.js +209 -0
  37. package/core/index.js +43 -0
  38. package/core/policies.js +68 -0
  39. package/core/telemetry.js +247 -0
  40. package/core/utils.js +380 -0
  41. package/dashboard/server.js +818 -0
  42. package/docs/integrations/claude-code.md +42 -0
  43. package/docs/integrations/codex.md +42 -0
  44. package/docs/mcp-api-platform.md +102 -0
  45. package/generators/generate.js +598 -0
  46. package/generators/index.js +18 -0
  47. package/hooks/context-detector.js +177 -0
  48. package/hooks/index.js +35 -0
  49. package/hooks/prompt-enhancer.js +289 -0
  50. package/intelligence/git-memory.js +551 -0
  51. package/intelligence/index.js +59 -0
  52. package/intelligence/orchestrator.js +964 -0
  53. package/intelligence/prd.js +447 -0
  54. package/intelligence/recommendation-weights.json +18 -0
  55. package/intelligence/recommendations.js +234 -0
  56. package/mcp/capabilities.js +71 -0
  57. package/mcp/contracts/mcp-contract.v1.json +497 -0
  58. package/mcp/registry.js +213 -0
  59. package/mcp/response-formatter.js +462 -0
  60. package/mcp/server.js +99 -0
  61. package/mcp/tools/agent-tool.js +137 -0
  62. package/mcp/tools/capabilities-tool.js +54 -0
  63. package/mcp/tools/context-tool.js +49 -0
  64. package/mcp/tools/dashboard-tool.js +58 -0
  65. package/mcp/tools/generate-tool.js +46 -0
  66. package/mcp/tools/loop-tool.js +134 -0
  67. package/mcp/tools/memory-tool.js +180 -0
  68. package/mcp/tools/orchestrator-tool.js +232 -0
  69. package/mcp/tools/plugin-tool.js +76 -0
  70. package/mcp/tools/quality-tool.js +47 -0
  71. package/mcp/tools/skill-tool.js +233 -0
  72. package/mcp/tools/telemetry-tool.js +95 -0
  73. package/mcp/tools/todo-tool.js +133 -0
  74. package/package.json +98 -0
  75. package/plugins/index.js +141 -0
  76. package/quality/index.js +380 -0
  77. package/quality/lint-budgets.json +19 -0
  78. package/skills/index.js +787 -0
  79. package/skills/patterns/README.md +163 -0
  80. package/skills/patterns/api/route-handler.md +217 -0
  81. package/skills/patterns/api/server-action.md +249 -0
  82. package/skills/patterns/auth/clerk.md +132 -0
  83. package/skills/patterns/database/prisma.md +180 -0
  84. package/skills/patterns/payments/stripe.md +272 -0
  85. package/skills/patterns/security/validation.md +268 -0
  86. package/skills/patterns/testing/vitest.md +307 -0
  87. package/templates/bootspring.config.js +83 -0
  88. 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
+ };