@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,311 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
3
+ import { join, resolve, basename } from 'path';
4
+ import { execa } from 'execa';
5
+ import inquirer from 'inquirer';
6
+ import chalk from 'chalk';
7
+ import YAML from 'yaml';
8
+ import { config } from '../lib/config.js';
9
+ import { api } from '../lib/api.js';
10
+ import { approvedProfiles } from '../lib/config.js';
11
+ import ui from '../lib/ui.js';
12
+ import { createHash } from 'crypto';
13
+ import { scanRepository } from '../lib/octus-contract.js';
14
+ import { generateProfileWithAi } from '../lib/profile-generator.js';
15
+
16
+ export const initCommand = new Command('init')
17
+ .description('Initialize and register a repository with Octus')
18
+ .argument('[path]', 'Local repo path or Git URL to clone', '.')
19
+ .option('-d, --clone-dir <dir>', 'Target directory for cloning Git URLs')
20
+ .option('--run-setup', 'Immediately run setup after init')
21
+ .option('--setup-profile <profile>', 'Profile to use for setup', 'auto')
22
+ .option('--setup-ignore-repo-profile', 'Ignore repo-local profile during setup')
23
+ .option('--setup-timeout <seconds>', 'Per-step timeout for setup', '300')
24
+ .action(async (pathArg, options) => {
25
+ // Check login
26
+ if (!config.isLoggedIn()) {
27
+ ui.error('Not logged in. Run `octus login` first.');
28
+ process.exit(1);
29
+ }
30
+
31
+ let repoPath;
32
+ const isGitUrl = pathArg.startsWith('http') || pathArg.startsWith('git@');
33
+
34
+ ui.header('Octus Init');
35
+
36
+ // ─────────────────────────────────────────────────────────────
37
+ // Handle Git URL cloning
38
+ // ─────────────────────────────────────────────────────────────
39
+
40
+ if (isGitUrl) {
41
+ const cloneDir = options.cloneDir || basename(pathArg.replace(/\.git$/, ''));
42
+ repoPath = resolve(cloneDir);
43
+
44
+ if (existsSync(repoPath)) {
45
+ ui.error(`Directory ${cloneDir} already exists`);
46
+ process.exit(1);
47
+ }
48
+
49
+ const spin = ui.spinner(`Cloning ${pathArg}...`).start();
50
+ try {
51
+ await execa('git', ['clone', pathArg, cloneDir]);
52
+ spin.succeed(`Cloned to ${cloneDir}`);
53
+ } catch (err) {
54
+ spin.fail(`Clone failed: ${err.message}`);
55
+ process.exit(1);
56
+ }
57
+ } else {
58
+ repoPath = resolve(pathArg);
59
+ }
60
+
61
+ // Verify it's a git repo
62
+ if (!existsSync(join(repoPath, '.git'))) {
63
+ ui.error(`${repoPath} is not a git repository`);
64
+ process.exit(1);
65
+ }
66
+
67
+ ui.info(`Repository: ${repoPath}`);
68
+
69
+ // ─────────────────────────────────────────────────────────────
70
+ // Analyze repository
71
+ // ─────────────────────────────────────────────────────────────
72
+
73
+ const spin = ui.spinner('Analyzing repository...').start();
74
+
75
+ // Get git remote
76
+ let gitRemoteUrl = null;
77
+ try {
78
+ const { stdout } = await execa('git', ['remote', 'get-url', 'origin'], { cwd: repoPath });
79
+ gitRemoteUrl = stdout.trim();
80
+ } catch {
81
+ // No remote, that's ok
82
+ }
83
+
84
+ // Detect project type
85
+ const projectType = detectProjectType(repoPath);
86
+ spin.text = `Detected: ${projectType}`;
87
+
88
+ // Scan repo structure
89
+ const scanResult = scanRepository(repoPath);
90
+ const repoData = {
91
+ name: basename(repoPath),
92
+ local_path: repoPath,
93
+ git_url: gitRemoteUrl,
94
+ default_branch: 'main',
95
+ project_type: projectType,
96
+ metadata: scanResult.metadata,
97
+ has_readme: scanResult.has_readme,
98
+ has_dockerfile: scanResult.has_dockerfile,
99
+ has_docker_compose: scanResult.has_docker_compose
100
+ };
101
+
102
+ spin.succeed('Repository analyzed');
103
+
104
+ // ─────────────────────────────────────────────────────────────
105
+ // Ensure .octusignore
106
+ // ─────────────────────────────────────────────────────────────
107
+
108
+ const octusignorePath = join(repoPath, '.octusignore');
109
+ if (!existsSync(octusignorePath)) {
110
+ writeFileSync(octusignorePath, getDefaultOctusignore());
111
+ ui.info('Created .octusignore');
112
+ }
113
+
114
+ // ─────────────────────────────────────────────────────────────
115
+ // Generate onboarding profile
116
+ // ─────────────────────────────────────────────────────────────
117
+
118
+ const profileDir = join(repoPath, '.octus');
119
+ const profilePath = join(profileDir, 'profile.json');
120
+
121
+ if (!existsSync(profileDir)) {
122
+ mkdirSync(profileDir, { recursive: true });
123
+ }
124
+
125
+ let profile;
126
+ if (existsSync(profilePath)) {
127
+ ui.info('Using existing profile');
128
+ profile = JSON.parse(readFileSync(profilePath, 'utf-8'));
129
+ } else {
130
+ const genSpin = ui.spinner('Generating onboarding profile...').start();
131
+ const generation = await generateProfileWithAi(repoPath);
132
+ profile = generation.profile;
133
+ if (generation.usedAi) {
134
+ genSpin.succeed('AI onboarding profile generated');
135
+ } else {
136
+ genSpin.succeed('Rule-based onboarding profile generated');
137
+ if (generation.fallbackReason) {
138
+ ui.warning(generation.fallbackReason);
139
+ }
140
+ }
141
+
142
+ // Show profile for review
143
+ ui.divider();
144
+ ui.header('Generated Onboarding Steps');
145
+ profile.steps.forEach((step, i) => {
146
+ console.log(` ${chalk.cyan(i + 1)}. ${step.name || step.title}`);
147
+ console.log(` ${chalk.dim(step.command || step.description || '')}`);
148
+ });
149
+ ui.divider();
150
+
151
+ // Ask for approval
152
+ const { approved } = await inquirer.prompt([
153
+ {
154
+ type: 'confirm',
155
+ name: 'approved',
156
+ message: 'Approve this onboarding profile?',
157
+ default: true
158
+ }
159
+ ]);
160
+
161
+ if (!approved) {
162
+ ui.warning('Profile not approved. Edit .octus/profile.json and run init again.');
163
+ writeFileSync(profilePath, JSON.stringify(profile, null, 2));
164
+ process.exit(0);
165
+ }
166
+
167
+ // Save profile
168
+ writeFileSync(profilePath, JSON.stringify(profile, null, 2));
169
+
170
+ // Record approval
171
+ const profileHash = createHash('sha256')
172
+ .update(JSON.stringify(profile))
173
+ .digest('hex');
174
+ approvedProfiles.add(profilePath, profileHash, profile.profile_name || profile.name || 'generated');
175
+
176
+ ui.success('Profile approved and saved');
177
+ }
178
+
179
+ // ─────────────────────────────────────────────────────────────
180
+ // Register with backend
181
+ // ─────────────────────────────────────────────────────────────
182
+
183
+ const regSpin = ui.spinner('Registering repository...').start();
184
+
185
+ try {
186
+ const repo = await api.registerRepository(repoData);
187
+ regSpin.text = 'Resolving profile...';
188
+
189
+ const resolved = await api.resolveProfile(repo.id, gitRemoteUrl);
190
+ regSpin.text = 'Getting signed init file...';
191
+
192
+ const initFile = await api.createInitFile(repo.id, resolved.profile_key || 'auto');
193
+
194
+ // Write .octus.yaml
195
+ const octusYamlPath = join(repoPath, '.octus.yaml');
196
+ writeFileSync(
197
+ octusYamlPath,
198
+ typeof initFile.content === 'string' ? initFile.content : YAML.stringify(initFile)
199
+ );
200
+
201
+ regSpin.succeed('Repository registered');
202
+
203
+ // Update .gitignore
204
+ updateGitignore(repoPath);
205
+
206
+ } catch (err) {
207
+ regSpin.fail(`Registration failed: ${err.message}`);
208
+ process.exit(1);
209
+ }
210
+
211
+ // ─────────────────────────────────────────────────────────────
212
+ // Success
213
+ // ─────────────────────────────────────────────────────────────
214
+
215
+ ui.divider();
216
+ ui.successPanel('Repository Initialized', `
217
+ ${chalk.bold('Created files:')}
218
+ • .octus/profile.json
219
+ • .octus.yaml
220
+ • .octusignore
221
+
222
+ ${chalk.bold('Next steps:')}
223
+ ${chalk.cyan('octus setup')} Run onboarding
224
+ ${chalk.cyan('octus doctor')} Check status
225
+ `);
226
+
227
+ // Run setup if requested
228
+ if (options.runSetup) {
229
+ ui.divider();
230
+ ui.info('Running setup...');
231
+ // Import and run setup command
232
+ const { runSetup } = await import('./setup.js');
233
+ await runSetup(repoPath, {
234
+ profile: options.setupProfile,
235
+ ignoreRepoProfile: options.setupIgnoreRepoProfile,
236
+ timeout: parseInt(options.setupTimeout, 10)
237
+ });
238
+ }
239
+ });
240
+
241
+ /**
242
+ * Detect project type based on files
243
+ */
244
+ function detectProjectType(repoPath) {
245
+ if (existsSync(join(repoPath, 'package.json'))) return 'node';
246
+ if (existsSync(join(repoPath, 'pyproject.toml'))) return 'python';
247
+ if (existsSync(join(repoPath, 'requirements.txt'))) return 'python';
248
+ if (existsSync(join(repoPath, 'Cargo.toml'))) return 'rust';
249
+ if (existsSync(join(repoPath, 'go.mod'))) return 'go';
250
+ if (existsSync(join(repoPath, 'pom.xml'))) return 'java';
251
+ if (existsSync(join(repoPath, 'Gemfile'))) return 'ruby';
252
+ return 'generic';
253
+ }
254
+
255
+ /**
256
+ * Default .octusignore content
257
+ */
258
+ function getDefaultOctusignore() {
259
+ return `# Octus ignore file - files to exclude from AI context
260
+ # Syntax is similar to .gitignore
261
+
262
+ # Secrets and credentials
263
+ .env
264
+ .env.*
265
+ *.pem
266
+ *.key
267
+ secrets/
268
+ credentials/
269
+
270
+ # Dependencies
271
+ node_modules/
272
+ .venv/
273
+ venv/
274
+ __pycache__/
275
+
276
+ # Build outputs
277
+ dist/
278
+ build/
279
+ *.pyc
280
+ *.o
281
+ *.so
282
+
283
+ # IDE
284
+ .idea/
285
+ .vscode/
286
+ *.swp
287
+
288
+ # OS
289
+ .DS_Store
290
+ Thumbs.db
291
+ `;
292
+ }
293
+
294
+ /**
295
+ * Update .gitignore to include Octus files
296
+ */
297
+ function updateGitignore(repoPath) {
298
+ const gitignorePath = join(repoPath, '.gitignore');
299
+ const octusEntries = '\n# Octus\n.octus/profile.json\n.octus.yaml\n';
300
+
301
+ if (existsSync(gitignorePath)) {
302
+ const content = readFileSync(gitignorePath, 'utf-8');
303
+ if (!content.includes('.octus/profile.json') || !content.includes('.octus.yaml')) {
304
+ writeFileSync(gitignorePath, content + octusEntries);
305
+ }
306
+ } else {
307
+ writeFileSync(gitignorePath, octusEntries);
308
+ }
309
+ }
310
+
311
+ export default initCommand;
@@ -0,0 +1,60 @@
1
+ import { Command } from 'commander';
2
+ import inquirer from 'inquirer';
3
+ import { config } from '../lib/config.js';
4
+ import { api } from '../lib/api.js';
5
+ import ui from '../lib/ui.js';
6
+
7
+ export const loginCommand = new Command('login')
8
+ .description('Authenticate with Octus Backend and save API key')
9
+ .option('-u, --backend-url <url>', 'Backend API URL', 'http://localhost:8000')
10
+ .option('-k, --api-key <key>', 'API key (will prompt if not provided)')
11
+ .action(async (options) => {
12
+ let { backendUrl, apiKey } = options;
13
+ const previousConfig = config.get();
14
+
15
+ // Prompt for API key if not provided
16
+ if (!apiKey) {
17
+ const answers = await inquirer.prompt([
18
+ {
19
+ type: 'password',
20
+ name: 'apiKey',
21
+ message: 'Enter your Octus API key:',
22
+ mask: '•',
23
+ validate: (input) => input.length > 0 || 'API key is required'
24
+ }
25
+ ]);
26
+ apiKey = answers.apiKey;
27
+ }
28
+
29
+ const spin = ui.spinner('Authenticating...').start();
30
+
31
+ try {
32
+ // Temporarily set config to test the credentials
33
+ config.set({ apiKey, backendUrl });
34
+
35
+ // Validate credentials
36
+ const user = await api.getMe();
37
+ spin.text = 'Fetching organization...';
38
+
39
+ const org = await api.getMyOrganization();
40
+
41
+ spin.succeed('Authenticated successfully!');
42
+
43
+ ui.divider();
44
+ ui.success(`Logged in as ${user.email || user.name || 'user'}`);
45
+ ui.keyValue([
46
+ ['Organization', org.name || 'N/A'],
47
+ ['Backend', backendUrl],
48
+ ['Config saved', config.getConfigPath()]
49
+ ]);
50
+ ui.divider();
51
+
52
+ } catch (err) {
53
+ spin.fail('Authentication failed');
54
+ config.set({ apiKey: previousConfig.apiKey, backendUrl: previousConfig.backendUrl });
55
+ ui.error(err.message);
56
+ process.exit(1);
57
+ }
58
+ });
59
+
60
+ export default loginCommand;
@@ -0,0 +1,21 @@
1
+ import { Command } from 'commander';
2
+ import { config } from '../lib/config.js';
3
+ import ui from '../lib/ui.js';
4
+
5
+ export const logoutCommand = new Command('logout')
6
+ .description('Clear stored credentials')
7
+ .action(async () => {
8
+ const spin = ui.spinner('Clearing credentials...').start();
9
+
10
+ try {
11
+ config.clear();
12
+ spin.succeed('Logged out successfully');
13
+ ui.info('All local Octus data has been cleared');
14
+ } catch (err) {
15
+ spin.fail('Failed to clear credentials');
16
+ ui.error(err.message);
17
+ process.exit(1);
18
+ }
19
+ });
20
+
21
+ export default logoutCommand;
@@ -0,0 +1,72 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { config } from '../lib/config.js';
4
+ import { api } from '../lib/api.js';
5
+ import ui from '../lib/ui.js';
6
+
7
+ export const pingCommand = new Command('ping')
8
+ .description('Check connectivity and authentication with Octus Backend')
9
+ .action(async () => {
10
+ ui.header('Octus Connection Check');
11
+
12
+ const { backendUrl, apiKey } = config.get();
13
+ const results = [];
14
+
15
+ // Check 1: Backend URL configured
16
+ results.push(['Backend URL', backendUrl || 'Not configured', !!backendUrl]);
17
+
18
+ // Check 2: API key present
19
+ results.push(['API Key', apiKey ? ui.maskSecret(apiKey) : 'Not set', !!apiKey]);
20
+
21
+ // Check 3: Health endpoint
22
+ let healthOk = false;
23
+ try {
24
+ const health = await api.healthCheck();
25
+ healthOk = health.ok;
26
+ results.push(['Backend Health', healthOk ? 'Healthy' : 'Unhealthy', healthOk]);
27
+ } catch (err) {
28
+ results.push(['Backend Health', `Error: ${err.message}`, false]);
29
+ }
30
+
31
+ // Check 4: Authentication (only if health passed)
32
+ if (healthOk && apiKey) {
33
+ try {
34
+ const user = await api.getMe();
35
+ results.push(['Authentication', `✓ ${user.email || user.name || 'Authenticated'}`, true]);
36
+
37
+ try {
38
+ const org = await api.getMyOrganization();
39
+ results.push(['Organization', org.name || 'N/A', true]);
40
+ } catch {
41
+ results.push(['Organization', 'Could not fetch', false]);
42
+ }
43
+ } catch (err) {
44
+ results.push(['Authentication', `Failed: ${err.message}`, false]);
45
+ }
46
+ } else if (!apiKey) {
47
+ results.push(['Authentication', 'Skipped (no API key)', false]);
48
+ }
49
+
50
+ // Display results
51
+ ui.divider();
52
+ ui.table(
53
+ ['Check', 'Status', 'Result'],
54
+ results.map(([check, status, ok]) => [
55
+ check,
56
+ status,
57
+ ok ? chalk.green('✓ Pass') : chalk.red('✗ Fail')
58
+ ])
59
+ );
60
+
61
+ // Summary
62
+ ui.divider();
63
+ const allPassed = results.every(([, , ok]) => ok);
64
+ if (allPassed) {
65
+ ui.success('All checks passed! Octus is ready to use.');
66
+ } else {
67
+ ui.warning('Some checks failed. Run `octus login` to configure.');
68
+ process.exit(1);
69
+ }
70
+ });
71
+
72
+ export default pingCommand;