@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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/bin/octus.js +5 -0
- package/package.json +68 -0
- package/src/commands/config.js +29 -0
- package/src/commands/doctor.js +254 -0
- package/src/commands/generate-profile.js +46 -0
- package/src/commands/init.js +311 -0
- package/src/commands/login.js +60 -0
- package/src/commands/logout.js +21 -0
- package/src/commands/ping.js +72 -0
- package/src/commands/setup.js +439 -0
- package/src/index.js +62 -0
- package/src/lib/api.js +210 -0
- package/src/lib/config.js +254 -0
- package/src/lib/octus-contract.js +231 -0
- package/src/lib/profile-generator.js +443 -0
- package/src/lib/ui.js +186 -0
- package/src/lib/version.js +1 -0
|
@@ -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';
|