@nboard-dev/octus 0.3.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.
@@ -0,0 +1,443 @@
1
+ import { Anthropic } from '@anthropic-ai/sdk';
2
+ import { existsSync, readdirSync, readFileSync } from 'fs';
3
+ import { extname, join, relative, resolve } from 'path';
4
+ import { execa } from 'execa';
5
+ import YAML from 'yaml';
6
+
7
+ const BLOCKED_FILES = [
8
+ '.env', '.env.*', '*.env', '.env.local', '.env.production', '.env.staging',
9
+ 'config/secrets.yml', 'config/secrets.yaml', 'config/credentials.yml', 'config/master.key',
10
+ '.aws/credentials', '.aws/config', '.ssh/id_rsa', '.ssh/id_ed25519', '.ssh/id_ecdsa',
11
+ '.ssh/known_hosts', '.ssh/authorized_keys', '*.pem', '*.key', '*.p12', '*.pfx', '*.jks',
12
+ '.bashrc', '.zshrc', '.bash_history', '.zsh_history', '.psql_history', '.mysql_history',
13
+ '.irb_history', '.netrc', '.pgpass', 'kubeconfig', '*.kubeconfig', '.kube/config',
14
+ 'serviceAccount*.json', '*service-account*.json', 'terraform.tfvars', '*.tfvars', '.npmrc',
15
+ '.pypirc', '.dockerconfigjson', 'docker-compose.override.yml', 'Thumbs.db', '.DS_Store',
16
+ '.vault-token', '.terraformrc', '*.tfstate', '.sops.yaml', '.config/gcloud/*', '.azure/*',
17
+ 'application_default_credentials.json',
18
+ ];
19
+
20
+ const SECRET_PATTERNS = [
21
+ /(?:api[_-]?key|apikey)\s*[:=]\s*["']?[\w-]{20,}/giu,
22
+ /(?:secret[_-]?key|secret)\s*[:=]\s*["']?[\w-]{20,}/giu,
23
+ /(?:password|passwd|pwd)\s*[:=]\s*["']?.{8,}/giu,
24
+ /token\s*[:=]\s*["']?[\w-]{20,}/giu,
25
+ /AKIA[0-9A-Z]{16}/g,
26
+ /sk-[a-zA-Z0-9]{32,}/g,
27
+ /AIza[0-9A-Za-z\-_]{35}/g,
28
+ /ghp_[0-9a-zA-Z]{36}/g,
29
+ /-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----/g,
30
+ ];
31
+
32
+ const CONTEXT_EXCLUDED_DIRS = new Set([
33
+ '.git', 'node_modules', '__pycache__', 'dist', 'build', '.next', '.venv', 'venv', 'coverage',
34
+ ]);
35
+
36
+ const CONTEXT_ROOT_FILES = [
37
+ 'README.md', 'package.json', 'requirements.txt', 'pyproject.toml', 'Dockerfile',
38
+ 'docker-compose.yml', 'docker-compose.yaml', 'Makefile', '.env.example', '.env.sample',
39
+ '.nvmrc', '.python-version',
40
+ ];
41
+
42
+ const LANGUAGE_SUFFIX_MAP = {
43
+ '.py': 'Python', '.js': 'JavaScript', '.jsx': 'JavaScript', '.ts': 'TypeScript',
44
+ '.tsx': 'TypeScript', '.go': 'Go', '.rs': 'Rust', '.java': 'Java', '.kt': 'Kotlin',
45
+ '.rb': 'Ruby', '.php': 'PHP', '.cs': 'C#', '.sh': 'Shell',
46
+ };
47
+
48
+ const LANGUAGE_MANIFEST_MAP = {
49
+ 'package.json': 'JavaScript',
50
+ 'pyproject.toml': 'Python',
51
+ 'requirements.txt': 'Python',
52
+ 'go.mod': 'Go',
53
+ 'Cargo.toml': 'Rust',
54
+ 'pom.xml': 'Java',
55
+ 'Gemfile': 'Ruby',
56
+ };
57
+
58
+ const PROFILE_GENERATION_SYSTEM_PROMPT = [
59
+ 'You are an expert DevOps engineer generating a deterministic engineering environment setup profile for a software repository.',
60
+ 'Your output will be executed automatically on a new engineer machine.',
61
+ 'Every step must be safe, idempotent, and specific to this exact repository.',
62
+ 'Output ONLY valid JSON matching this schema exactly.',
63
+ '{',
64
+ ' "profile_name": "string",',
65
+ ' "tech_stack": ["string"],',
66
+ ' "steps": [{"key": "snake_case", "name": "string", "description": "string", "command": "string or null", "check_command": "string or null", "requires_human": false, "category": "dependency|environment|service|access|verification"}],',
67
+ ' "env_vars_required": ["string"],',
68
+ ' "estimated_setup_minutes": 5,',
69
+ ' "notes": "string"',
70
+ '}',
71
+ ].join('\n');
72
+
73
+ function normalizePath(pathValue) {
74
+ return pathValue.replace(/\\/g, '/').replace(/^\.\//, '');
75
+ }
76
+
77
+ function globToRegExp(pattern) {
78
+ const escaped = pattern
79
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
80
+ .replace(/\*/g, '.*')
81
+ .replace(/\?/g, '.');
82
+ return new RegExp(`^${escaped}$`);
83
+ }
84
+
85
+ function loadOctusignorePatterns(repoPath) {
86
+ const octusignorePath = join(repoPath, '.octusignore');
87
+ if (!existsSync(octusignorePath)) return [];
88
+ return readFileSync(octusignorePath, 'utf-8')
89
+ .split(/\r?\n/)
90
+ .map((line) => line.trim())
91
+ .filter((line) => line && !line.startsWith('#'));
92
+ }
93
+
94
+ function matchesBlockedPattern(filePath, extraPatterns = []) {
95
+ const normalized = normalizePath(filePath);
96
+ const fileName = normalized.split('/').pop() || normalized;
97
+ return [...BLOCKED_FILES, ...extraPatterns].some((pattern) => {
98
+ const regex = globToRegExp(normalizePath(pattern));
99
+ return regex.test(normalized) || regex.test(fileName);
100
+ });
101
+ }
102
+
103
+ async function isGitIgnored(repoPath, relativePath) {
104
+ try {
105
+ await execa('git', ['check-ignore', '--quiet', '--', relativePath], { cwd: repoPath });
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function scrubContent(content) {
113
+ let scrubbed = content;
114
+ for (const pattern of SECRET_PATTERNS) {
115
+ scrubbed = scrubbed.replace(pattern, '[REDACTED]');
116
+ }
117
+ return scrubbed;
118
+ }
119
+
120
+ export function analyzeRepo(repoPath) {
121
+ const analysis = {
122
+ projectType: 'generic',
123
+ hasPackageJson: existsSync(join(repoPath, 'package.json')),
124
+ hasPyproject: existsSync(join(repoPath, 'pyproject.toml')),
125
+ hasRequirements: existsSync(join(repoPath, 'requirements.txt')),
126
+ hasDockerfile: existsSync(join(repoPath, 'Dockerfile')),
127
+ hasDockerCompose: existsSync(join(repoPath, 'docker-compose.yml')) || existsSync(join(repoPath, 'docker-compose.yaml')),
128
+ hasReadme: existsSync(join(repoPath, 'README.md')),
129
+ hasMakefile: existsSync(join(repoPath, 'Makefile')),
130
+ hasEnvExample: existsSync(join(repoPath, '.env.example')) || existsSync(join(repoPath, '.env.sample')),
131
+ frameworks: [],
132
+ matchesKnownProfile: null,
133
+ };
134
+
135
+ if (analysis.hasPackageJson) {
136
+ analysis.projectType = 'node';
137
+ try {
138
+ const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf-8'));
139
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
140
+ if (deps.next) analysis.frameworks.push('nextjs');
141
+ if (deps.react) analysis.frameworks.push('react');
142
+ if (deps.vue) analysis.frameworks.push('vue');
143
+ if (deps.express) analysis.frameworks.push('express');
144
+ if (deps.fastify) analysis.frameworks.push('fastify');
145
+ if (deps.typescript) analysis.frameworks.push('typescript');
146
+ } catch {}
147
+ analysis.matchesKnownProfile = 'demo-node';
148
+ } else if (analysis.hasPyproject || analysis.hasRequirements) {
149
+ analysis.projectType = 'python';
150
+ if (existsSync(join(repoPath, 'manage.py'))) analysis.frameworks.push('django');
151
+ try {
152
+ const pyprojectPath = join(repoPath, 'pyproject.toml');
153
+ const pyproject = existsSync(pyprojectPath) ? readFileSync(pyprojectPath, 'utf-8') : '';
154
+ if (pyproject.includes('fastapi')) analysis.frameworks.push('fastapi');
155
+ if (pyproject.includes('flask')) analysis.frameworks.push('flask');
156
+ if (pyproject.includes('pytest')) analysis.frameworks.push('pytest');
157
+ } catch {}
158
+ analysis.matchesKnownProfile = 'demo-python';
159
+ } else if (existsSync(join(repoPath, 'Cargo.toml'))) {
160
+ analysis.projectType = 'rust';
161
+ } else if (existsSync(join(repoPath, 'go.mod'))) {
162
+ analysis.projectType = 'go';
163
+ }
164
+
165
+ return analysis;
166
+ }
167
+
168
+ export function generateRuleBasedProfile(repoPath) {
169
+ const analysis = analyzeRepo(repoPath);
170
+ const profile = {
171
+ profile_name: `${analysis.projectType.charAt(0).toUpperCase() + analysis.projectType.slice(1)} Project`,
172
+ tech_stack: [analysis.projectType, ...analysis.frameworks].filter(Boolean),
173
+ steps: [],
174
+ env_vars_required: [],
175
+ estimated_setup_minutes: 5,
176
+ notes: 'Rule-based profile generated from current Octus repo analysis.',
177
+ };
178
+
179
+ if (analysis.hasReadme) {
180
+ profile.steps.push({
181
+ key: 'read_readme',
182
+ name: 'Review README',
183
+ description: 'Familiarize yourself with the project documentation.',
184
+ command: 'cat README.md | head -100',
185
+ check_command: 'test -f README.md',
186
+ requires_human: false,
187
+ category: 'verification',
188
+ });
189
+ }
190
+
191
+ if (analysis.projectType === 'node') {
192
+ profile.steps.push({
193
+ key: 'install_deps',
194
+ name: 'Install dependencies',
195
+ description: 'Install project dependencies.',
196
+ command: 'npm install',
197
+ check_command: 'test -d node_modules',
198
+ requires_human: false,
199
+ category: 'dependency',
200
+ });
201
+ if (analysis.hasEnvExample) {
202
+ profile.steps.push({
203
+ key: 'setup_env',
204
+ name: 'Setup environment variables',
205
+ description: 'Copy the example env file for local development.',
206
+ command: 'cp .env.example .env.local 2>/dev/null || cp .env.sample .env.local 2>/dev/null || true',
207
+ check_command: 'test -f .env.local',
208
+ requires_human: false,
209
+ category: 'environment',
210
+ });
211
+ }
212
+ profile.steps.push({
213
+ key: 'run_tests',
214
+ name: 'Run test suite',
215
+ description: 'Run automated tests.',
216
+ command: 'npm test',
217
+ check_command: 'npm test -- --passWithNoTests 2>/dev/null || true',
218
+ requires_human: false,
219
+ category: 'verification',
220
+ });
221
+ } else if (analysis.projectType === 'python') {
222
+ profile.steps.push({
223
+ key: 'create_venv',
224
+ name: 'Create virtual environment',
225
+ description: 'Create a local virtual environment.',
226
+ command: 'python3 -m venv .venv',
227
+ check_command: 'test -d .venv',
228
+ requires_human: false,
229
+ category: 'dependency',
230
+ });
231
+ profile.steps.push({
232
+ key: 'install_deps',
233
+ name: 'Install dependencies',
234
+ description: 'Install Python dependencies.',
235
+ command: analysis.hasPyproject ? '.venv/bin/pip install -e ".[dev]"' : '.venv/bin/pip install -r requirements.txt',
236
+ check_command: '.venv/bin/python --version',
237
+ requires_human: false,
238
+ category: 'dependency',
239
+ });
240
+ }
241
+
242
+ if (analysis.hasDockerCompose) {
243
+ profile.steps.push({
244
+ key: 'docker_up',
245
+ name: 'Start Docker services',
246
+ description: 'Start required Docker services.',
247
+ command: 'docker compose up -d',
248
+ check_command: 'docker compose ps',
249
+ requires_human: false,
250
+ category: 'service',
251
+ });
252
+ }
253
+
254
+ if (analysis.hasMakefile) {
255
+ profile.steps.push({
256
+ key: 'make_setup',
257
+ name: 'Run make setup',
258
+ description: 'Run the repository make setup/install target if available.',
259
+ command: 'make setup 2>/dev/null || make install 2>/dev/null || true',
260
+ check_command: 'test -f Makefile',
261
+ requires_human: false,
262
+ category: 'verification',
263
+ });
264
+ }
265
+
266
+ profile.estimated_setup_minutes = Math.max(5, profile.steps.length * 3 || 5);
267
+ if (analysis.matchesKnownProfile) {
268
+ profile.notes += ` Known matcher detected: ${analysis.matchesKnownProfile}.`;
269
+ }
270
+ return { profile, analysis };
271
+ }
272
+
273
+ async function buildDirectoryTree(repoPath, extraPatterns, currentPath = repoPath, depth = 0, maxDepth = 3) {
274
+ if (depth > maxDepth) return [];
275
+ const entries = readdirSync(currentPath, { withFileTypes: true })
276
+ .filter((entry) => !CONTEXT_EXCLUDED_DIRS.has(entry.name))
277
+ .sort((a, b) => a.name.localeCompare(b.name));
278
+ const lines = [];
279
+ for (const entry of entries) {
280
+ const fullPath = join(currentPath, entry.name);
281
+ const relPath = normalizePath(relative(repoPath, fullPath));
282
+ if (matchesBlockedPattern(relPath, extraPatterns)) continue;
283
+ if (await isGitIgnored(repoPath, relPath)) continue;
284
+ lines.push(`${' '.repeat(depth)}${entry.name}${entry.isDirectory() ? '/' : ''}`);
285
+ if (entry.isDirectory()) {
286
+ lines.push(...await buildDirectoryTree(repoPath, extraPatterns, fullPath, depth + 1, maxDepth));
287
+ }
288
+ }
289
+ return lines;
290
+ }
291
+
292
+ async function collectRelevantFiles(repoPath, extraPatterns) {
293
+ const selected = [];
294
+ for (const fileName of CONTEXT_ROOT_FILES) {
295
+ const fullPath = join(repoPath, fileName);
296
+ if (!existsSync(fullPath)) continue;
297
+ const relPath = normalizePath(relative(repoPath, fullPath));
298
+ if (matchesBlockedPattern(relPath, extraPatterns)) continue;
299
+ if (await isGitIgnored(repoPath, relPath)) continue;
300
+ selected.push(fullPath);
301
+ }
302
+
303
+ const stack = [repoPath];
304
+ while (stack.length > 0 && selected.length < 20) {
305
+ const dir = stack.pop();
306
+ const entries = readdirSync(dir, { withFileTypes: true });
307
+ for (const entry of entries) {
308
+ if (CONTEXT_EXCLUDED_DIRS.has(entry.name)) continue;
309
+ const fullPath = join(dir, entry.name);
310
+ const relPath = normalizePath(relative(repoPath, fullPath));
311
+ if (matchesBlockedPattern(relPath, extraPatterns)) continue;
312
+ if (await isGitIgnored(repoPath, relPath)) continue;
313
+ if (entry.isDirectory()) {
314
+ stack.push(fullPath);
315
+ continue;
316
+ }
317
+ const ext = extname(entry.name).toLowerCase();
318
+ if (!LANGUAGE_SUFFIX_MAP[ext]) continue;
319
+ if (!selected.includes(fullPath)) selected.push(fullPath);
320
+ if (selected.length >= 20) break;
321
+ }
322
+ }
323
+ return selected;
324
+ }
325
+
326
+ async function collectRepoContext(repoPath) {
327
+ const extraPatterns = loadOctusignorePatterns(repoPath);
328
+ const directoryTree = (await buildDirectoryTree(repoPath, extraPatterns)).join('\n');
329
+ const relevantFiles = await collectRelevantFiles(repoPath, extraPatterns);
330
+ const fileExcerpts = relevantFiles.map((filePath) => ({
331
+ relative_path: normalizePath(relative(repoPath, filePath)),
332
+ content: scrubContent(
333
+ readFileSync(filePath, 'utf-8').split(/\r?\n/).slice(0, 200).join('\n').slice(0, 12000)
334
+ ),
335
+ }));
336
+
337
+ const languageSignals = new Set();
338
+ const packageManagers = new Set();
339
+ for (const filePath of relevantFiles) {
340
+ const relPath = normalizePath(relative(repoPath, filePath));
341
+ const fileName = relPath.split('/').pop() || relPath;
342
+ const ext = extname(fileName).toLowerCase();
343
+ if (LANGUAGE_SUFFIX_MAP[ext]) languageSignals.add(LANGUAGE_SUFFIX_MAP[ext]);
344
+ if (LANGUAGE_MANIFEST_MAP[fileName]) languageSignals.add(LANGUAGE_MANIFEST_MAP[fileName]);
345
+ if (fileName === 'package.json') packageManagers.add('npm');
346
+ if (fileName === 'pyproject.toml') packageManagers.add('poetry or pip');
347
+ }
348
+
349
+ return {
350
+ repo_name: resolve(repoPath).split('/').pop(),
351
+ directory_tree: directoryTree,
352
+ file_excerpts: fileExcerpts,
353
+ tech_stack_signals: {
354
+ languages: [...languageSignals],
355
+ frameworks: [],
356
+ package_managers: [...packageManagers],
357
+ },
358
+ };
359
+ }
360
+
361
+ function contextPrompt(context, repoPath) {
362
+ const sections = [
363
+ 'Return raw JSON only. Do not include markdown fences, comments, or explanatory text.',
364
+ `Repository name: ${context.repo_name}`,
365
+ `Repository path: ${repoPath}`,
366
+ 'Directory tree:',
367
+ context.directory_tree || '(empty)',
368
+ 'Tech stack signals:',
369
+ `Languages: ${context.tech_stack_signals.languages.join(', ') || 'unknown'}`,
370
+ `Frameworks: ${context.tech_stack_signals.frameworks.join(', ') || 'unknown'}`,
371
+ `Package managers: ${context.tech_stack_signals.package_managers.join(', ') || 'unknown'}`,
372
+ ];
373
+ for (const excerpt of context.file_excerpts) {
374
+ sections.push(`File: ${excerpt.relative_path}`);
375
+ sections.push(excerpt.content);
376
+ }
377
+ return sections.join('\n\n');
378
+ }
379
+
380
+ function stripMarkdownFences(text) {
381
+ const trimmed = text.trim();
382
+ if (!trimmed.startsWith('```')) return trimmed;
383
+ return trimmed.replace(/^```[a-zA-Z]*\n?/, '').replace(/\n?```$/, '').trim();
384
+ }
385
+
386
+ export async function generateProfileWithAi(repoPath) {
387
+ const apiKey = process.env.ANTHROPIC_API_KEY?.trim();
388
+ if (!apiKey) {
389
+ return { ...generateRuleBasedProfile(repoPath), usedAi: false, fallbackReason: 'ANTHROPIC_API_KEY not set — using rule-based profile generation.' };
390
+ }
391
+
392
+ try {
393
+ const context = await collectRepoContext(repoPath);
394
+ const anthropic = new Anthropic({ apiKey });
395
+ const completion = await anthropic.messages.create({
396
+ model: 'claude-sonnet-4-5',
397
+ max_tokens: 2500,
398
+ temperature: 0,
399
+ system: PROFILE_GENERATION_SYSTEM_PROMPT,
400
+ messages: [{ role: 'user', content: contextPrompt(context, repoPath) }],
401
+ });
402
+ const rawText = completion.content.map((block) => (typeof block.text === 'string' ? block.text : '')).join('\n').trim();
403
+ const parsed = JSON.parse(stripMarkdownFences(rawText));
404
+ return {
405
+ profile: {
406
+ profile_name: parsed.profile_name || 'Generated Profile',
407
+ tech_stack: Array.isArray(parsed.tech_stack) ? parsed.tech_stack : [],
408
+ steps: Array.isArray(parsed.steps) ? parsed.steps : [],
409
+ env_vars_required: Array.isArray(parsed.env_vars_required) ? parsed.env_vars_required : [],
410
+ estimated_setup_minutes: Number.isFinite(parsed.estimated_setup_minutes) ? parsed.estimated_setup_minutes : 5,
411
+ notes: typeof parsed.notes === 'string' ? parsed.notes : '',
412
+ },
413
+ analysis: analyzeRepo(repoPath),
414
+ usedAi: true,
415
+ fallbackReason: null,
416
+ };
417
+ } catch (error) {
418
+ return {
419
+ ...generateRuleBasedProfile(repoPath),
420
+ usedAi: false,
421
+ fallbackReason: `AI profile generation failed, using rule-based fallback: ${error.message}`,
422
+ };
423
+ }
424
+ }
425
+
426
+ export function toYamlDraft(profile) {
427
+ return YAML.stringify({
428
+ name: profile.profile_name,
429
+ description: profile.notes || 'Auto-generated onboarding profile',
430
+ generated_at: new Date().toISOString(),
431
+ steps: profile.steps.map((step) => ({
432
+ step_key: step.key,
433
+ title: step.name,
434
+ description: step.description,
435
+ command: step.command,
436
+ check_command: step.check_command,
437
+ requires_human: step.requires_human,
438
+ category: step.category,
439
+ })),
440
+ }, { indent: 2 });
441
+ }
442
+
443
+ export default { analyzeRepo, generateRuleBasedProfile, generateProfileWithAi, toYamlDraft };
package/src/lib/ui.js ADDED
@@ -0,0 +1,186 @@
1
+ import chalk from 'chalk';
2
+ import boxen from 'boxen';
3
+ import ora from 'ora';
4
+ import Table from 'cli-table3';
5
+ import logSymbols from 'log-symbols';
6
+ import figures from 'figures';
7
+
8
+ /**
9
+ * Create a spinner
10
+ */
11
+ export function spinner(text) {
12
+ return ora({
13
+ text,
14
+ spinner: 'dots',
15
+ color: 'cyan'
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Success message
21
+ */
22
+ export function success(message) {
23
+ console.log(`${logSymbols.success} ${chalk.green(message)}`);
24
+ }
25
+
26
+ /**
27
+ * Error message
28
+ */
29
+ export function error(message) {
30
+ console.log(`${logSymbols.error} ${chalk.red(message)}`);
31
+ }
32
+
33
+ /**
34
+ * Warning message
35
+ */
36
+ export function warning(message) {
37
+ console.log(`${logSymbols.warning} ${chalk.yellow(message)}`);
38
+ }
39
+
40
+ /**
41
+ * Info message
42
+ */
43
+ export function info(message) {
44
+ console.log(`${logSymbols.info} ${chalk.blue(message)}`);
45
+ }
46
+
47
+ /**
48
+ * Print a boxed message
49
+ */
50
+ export function box(content, options = {}) {
51
+ console.log(boxen(content, {
52
+ padding: 1,
53
+ margin: 1,
54
+ borderStyle: 'round',
55
+ borderColor: 'cyan',
56
+ ...options
57
+ }));
58
+ }
59
+
60
+ /**
61
+ * Print a table
62
+ */
63
+ export function table(headers, rows, options = {}) {
64
+ const t = new Table({
65
+ head: headers.map(h => chalk.cyan.bold(h)),
66
+ style: { head: [], border: [] },
67
+ ...options
68
+ });
69
+ rows.forEach(row => t.push(row));
70
+ console.log(t.toString());
71
+ }
72
+
73
+ /**
74
+ * Print key-value pairs
75
+ */
76
+ export function keyValue(pairs) {
77
+ const maxKeyLen = Math.max(...pairs.map(([k]) => k.length));
78
+ pairs.forEach(([key, value]) => {
79
+ const paddedKey = key.padEnd(maxKeyLen);
80
+ console.log(` ${chalk.dim(paddedKey)} ${value}`);
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Print a step in a process
86
+ */
87
+ export function step(number, total, text, status = 'pending') {
88
+ const statusIcon = {
89
+ pending: chalk.dim(figures.circle),
90
+ running: chalk.cyan(figures.pointer),
91
+ success: chalk.green(figures.tick),
92
+ failed: chalk.red(figures.cross),
93
+ skipped: chalk.yellow(figures.line)
94
+ }[status] || figures.circle;
95
+
96
+ console.log(` ${statusIcon} ${chalk.dim(`[${number}/${total}]`)} ${text}`);
97
+ }
98
+
99
+ /**
100
+ * Print a header/title
101
+ */
102
+ export function header(text) {
103
+ console.log();
104
+ console.log(chalk.bold.cyan(text));
105
+ console.log(chalk.dim('─'.repeat(text.length + 4)));
106
+ }
107
+
108
+ /**
109
+ * Print a section divider
110
+ */
111
+ export function divider() {
112
+ console.log();
113
+ }
114
+
115
+ /**
116
+ * Mask a secret (show first 4 and last 4 chars)
117
+ */
118
+ export function maskSecret(secret) {
119
+ if (!secret || secret.length < 12) {
120
+ return '••••••••';
121
+ }
122
+ return `${secret.slice(0, 4)}${'•'.repeat(8)}${secret.slice(-4)}`;
123
+ }
124
+
125
+ /**
126
+ * Format a status badge
127
+ */
128
+ export function statusBadge(status) {
129
+ const badges = {
130
+ success: chalk.bgGreen.black(' SUCCESS '),
131
+ failed: chalk.bgRed.white(' FAILED '),
132
+ running: chalk.bgBlue.white(' RUNNING '),
133
+ pending: chalk.bgGray.white(' PENDING '),
134
+ blocked: chalk.bgYellow.black(' BLOCKED '),
135
+ skipped: chalk.bgGray.white(' SKIPPED ')
136
+ };
137
+ return badges[status] || chalk.bgGray.white(` ${status.toUpperCase()} `);
138
+ }
139
+
140
+ /**
141
+ * Print failure panel (like Rich panel)
142
+ */
143
+ export function failurePanel(title, content) {
144
+ console.log();
145
+ console.log(boxen(content, {
146
+ title: chalk.red.bold(title),
147
+ titleAlignment: 'left',
148
+ padding: 1,
149
+ margin: { top: 0, bottom: 0, left: 2, right: 2 },
150
+ borderStyle: 'double',
151
+ borderColor: 'red'
152
+ }));
153
+ }
154
+
155
+ /**
156
+ * Print success panel
157
+ */
158
+ export function successPanel(title, content) {
159
+ console.log();
160
+ console.log(boxen(content, {
161
+ title: chalk.green.bold(title),
162
+ titleAlignment: 'left',
163
+ padding: 1,
164
+ margin: { top: 0, bottom: 0, left: 2, right: 2 },
165
+ borderStyle: 'round',
166
+ borderColor: 'green'
167
+ }));
168
+ }
169
+
170
+ export default {
171
+ spinner,
172
+ success,
173
+ error,
174
+ warning,
175
+ info,
176
+ box,
177
+ table,
178
+ keyValue,
179
+ step,
180
+ header,
181
+ divider,
182
+ maskSecret,
183
+ statusBadge,
184
+ failurePanel,
185
+ successPanel
186
+ };
@@ -0,0 +1 @@
1
+ export const version = '0.3.0';