@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,439 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join, resolve } from 'path';
4
+ import { randomUUID } from 'crypto';
5
+ import { execa } from 'execa';
6
+ import chalk from 'chalk';
7
+ import YAML from 'yaml';
8
+ import inquirer from 'inquirer';
9
+ import { config, runLock, runState } from '../lib/config.js';
10
+ import { api } from '../lib/api.js';
11
+ import ui from '../lib/ui.js';
12
+ import {
13
+ buildEnvironmentSnapshot,
14
+ buildLogEntries,
15
+ buildRunContext,
16
+ buildRunPayload,
17
+ computeProfileHash,
18
+ normalizeInitFilePayload,
19
+ normalizeProfile,
20
+ scanRepository,
21
+ } from '../lib/octus-contract.js';
22
+
23
+ export const setupCommand = new Command('setup')
24
+ .description('Execute onboarding setup run')
25
+ .argument('[path]', 'Repository path', '.')
26
+ .option('--profile <key>', 'Profile key (auto|demo-node|demo-python|...)', 'auto')
27
+ .option('--ignore-repo-profile', 'Ignore repo-local .octus/profile.yaml')
28
+ .option('--timeout <seconds>', 'Per-step timeout in seconds', '300')
29
+ .option('--force-lock', 'Break stale run lock')
30
+ .option('--resume', 'Resume last local run')
31
+ .option('--force-resume', 'Allow resume from different repo path')
32
+ .option('--step-delay-ms <ms>', 'Delay before each step (testing)', '0')
33
+ .option('--turbo', 'Skip confirmations and run fast')
34
+ .option('--verbose', 'Show detailed output')
35
+ .action(async (pathArg, options) => {
36
+ await runSetup(pathArg, options);
37
+ });
38
+
39
+ function normalizeRunSteps(steps = []) {
40
+ return [...steps].sort((a, b) => {
41
+ const aIndex = a.step_index ?? a.stepIndex ?? 0;
42
+ const bIndex = b.step_index ?? b.stepIndex ?? 0;
43
+ return aIndex - bIndex;
44
+ });
45
+ }
46
+
47
+ async function ensureLoggedIn(turbo) {
48
+ if (config.isLoggedIn()) return;
49
+ if (turbo) {
50
+ ui.error('Not logged in. Run `octus login` first.');
51
+ process.exit(1);
52
+ }
53
+
54
+ ui.warning('Not logged in');
55
+ const { doLogin } = await inquirer.prompt([
56
+ { type: 'confirm', name: 'doLogin', message: 'Login now?', default: true },
57
+ ]);
58
+
59
+ if (!doLogin) {
60
+ process.exit(1);
61
+ }
62
+
63
+ const { loginCommand } = await import('./login.js');
64
+ await loginCommand.parseAsync(['login'], { from: 'user' });
65
+ }
66
+
67
+ export async function runSetup(pathArg, options = {}) {
68
+ const repoPath = resolve(pathArg || '.');
69
+ const timeoutMs = parseInt(options.timeout || '300', 10) * 1000;
70
+ const stepDelayMs = parseInt(options.stepDelayMs || '0', 10);
71
+ const turbo = Boolean(options.turbo);
72
+ const verbose = Boolean(options.verbose);
73
+
74
+ await ensureLoggedIn(turbo);
75
+
76
+ const octusYamlPath = join(repoPath, '.octus.yaml');
77
+ if (!existsSync(octusYamlPath)) {
78
+ ui.error('.octus.yaml not found. Run `octus init` first.');
79
+ process.exit(1);
80
+ }
81
+
82
+ const lockResult = runLock.acquire(options.forceLock);
83
+ if (!lockResult.success) {
84
+ ui.error(`Another setup is running (PID: ${lockResult.lock.pid})`);
85
+ ui.info('Use --force-lock to override if the other process is dead');
86
+ process.exit(1);
87
+ }
88
+
89
+ const cleanup = () => runLock.release();
90
+ process.on('SIGINT', () => { cleanup(); process.exit(130); });
91
+ process.on('SIGTERM', () => { cleanup(); process.exit(143); });
92
+
93
+ try {
94
+ ui.header('Octus Setup');
95
+ ui.info(`Repository: ${repoPath}`);
96
+
97
+ let initPayload;
98
+ try {
99
+ initPayload = normalizeInitFilePayload(
100
+ YAML.parse(readFileSync(octusYamlPath, 'utf-8'))
101
+ );
102
+ } catch (error) {
103
+ ui.error(error.message);
104
+ process.exit(1);
105
+ }
106
+
107
+ const verifySpin = ui.spinner('Verifying init file...').start();
108
+ let verification;
109
+ try {
110
+ verification = await api.verifyInitFile(initPayload);
111
+ verifySpin.succeed('Init file verified');
112
+ } catch (error) {
113
+ verifySpin.fail(`Verification failed: ${error.message}`);
114
+ ui.info('Run `octus init` to regenerate');
115
+ process.exit(1);
116
+ }
117
+
118
+ const scanResult = scanRepository(repoPath);
119
+ const registerSpin = ui.spinner('Registering repository...').start();
120
+ let repoResponse;
121
+ let currentUser;
122
+ try {
123
+ repoResponse = await api.registerRepository({
124
+ name: scanResult.name,
125
+ local_path: repoPath,
126
+ git_url: null,
127
+ default_branch: 'main',
128
+ project_type: scanResult.project_type,
129
+ metadata: scanResult.metadata,
130
+ has_readme: scanResult.has_readme,
131
+ has_dockerfile: scanResult.has_dockerfile,
132
+ has_docker_compose: scanResult.has_docker_compose,
133
+ });
134
+ currentUser = await api.getMe();
135
+ registerSpin.succeed('Repository registered');
136
+ } catch (error) {
137
+ registerSpin.fail(`Failed to register repository: ${error.message}`);
138
+ process.exit(1);
139
+ }
140
+
141
+ if (String(repoResponse.organization_id) !== initPayload.org_id) {
142
+ ui.error('Invalid .octus.yaml — organization mismatch. Run `octus init` again.');
143
+ process.exit(1);
144
+ }
145
+
146
+ if (String(repoResponse.id) !== initPayload.repo_id) {
147
+ ui.error('Invalid .octus.yaml — repository mismatch. Run `octus init` again.');
148
+ process.exit(1);
149
+ }
150
+
151
+ const localProfilePath = join(repoPath, '.octus', 'profile.json');
152
+ let profile;
153
+ let selectedProfileKey = null;
154
+
155
+ if (!options.ignoreRepoProfile && existsSync(localProfilePath)) {
156
+ profile = normalizeProfile(
157
+ JSON.parse(readFileSync(localProfilePath, 'utf-8')),
158
+ scanResult.name
159
+ );
160
+ ui.info(`Using local profile: ${profile.name}`);
161
+ } else {
162
+ const backendProfileKey = options.profile && options.profile !== 'auto'
163
+ ? options.profile
164
+ : verification?.profile_key || initPayload.profile_ref;
165
+ const profileSpin = ui.spinner('Fetching profile from backend...').start();
166
+ try {
167
+ profile = normalizeProfile(
168
+ await api.getProfile(backendProfileKey),
169
+ backendProfileKey
170
+ );
171
+ selectedProfileKey = profile.key || backendProfileKey;
172
+ profileSpin.succeed(`Using profile: ${profile.name}`);
173
+ } catch (error) {
174
+ profileSpin.fail(`Failed to fetch profile: ${error.message}`);
175
+ process.exit(1);
176
+ }
177
+ }
178
+
179
+ if (!Array.isArray(profile.steps) || profile.steps.length === 0) {
180
+ ui.warning('No steps in profile');
181
+ return;
182
+ }
183
+
184
+ const profileHash = computeProfileHash(profile.name, profile.steps);
185
+
186
+ let runResponse;
187
+ const existingState = runState.get();
188
+ let localState = {
189
+ run_id: null,
190
+ profile_hash: profileHash,
191
+ repo_path: repoPath,
192
+ steps: {},
193
+ };
194
+
195
+ if (options.resume && existingState.run_id) {
196
+ if (existingState.repo_path && existingState.repo_path !== repoPath && !options.forceResume) {
197
+ ui.error('Saved run is for a different repo path');
198
+ ui.info(`Saved: ${existingState.repo_path}`);
199
+ ui.info(`Current: ${repoPath}`);
200
+ ui.info('Use --force-resume to override');
201
+ process.exit(1);
202
+ }
203
+ const runSpin = ui.spinner(`Loading run ${existingState.run_id.slice(0, 8)}...`).start();
204
+ try {
205
+ runResponse = await api.getRun(existingState.run_id);
206
+ localState = {
207
+ ...existingState,
208
+ profile_hash: existingState.profile_hash || profileHash,
209
+ repo_path: repoPath,
210
+ steps: existingState.steps || {},
211
+ };
212
+ runSpin.succeed('Resume state loaded');
213
+ } catch (error) {
214
+ runSpin.fail(`Failed to load saved run: ${error.message}`);
215
+ process.exit(1);
216
+ }
217
+ } else {
218
+ const runSpin = ui.spinner('Creating onboarding run...').start();
219
+ try {
220
+ runResponse = await api.createRun(
221
+ buildRunPayload({
222
+ repoResponse,
223
+ currentUser,
224
+ scanResult,
225
+ selectedProfile: profile.name,
226
+ selectedProfileKey,
227
+ selectedProfileVersion: profile.version,
228
+ profileHash,
229
+ stepDefs: profile.steps,
230
+ runContext: buildRunContext({
231
+ scanResult,
232
+ repoPath,
233
+ profileName: profile.name,
234
+ profileVersion: profile.version,
235
+ resume: false,
236
+ gitRemoteUrl: null,
237
+ }),
238
+ environmentSnapshot: buildEnvironmentSnapshot({
239
+ scanResult,
240
+ profileName: profile.name,
241
+ profileVersion: profile.version,
242
+ captureSource: 'cli_setup_start',
243
+ }),
244
+ })
245
+ );
246
+ runSpin.succeed(`Run created: ${runResponse.id.slice(0, 8)}...`);
247
+ localState.run_id = runResponse.id;
248
+ } catch (error) {
249
+ runSpin.fail(`Failed to create run: ${error.message}`);
250
+ process.exit(1);
251
+ }
252
+ }
253
+
254
+ runState.set(localState);
255
+
256
+ ui.divider();
257
+ ui.header('Running Onboarding Steps');
258
+
259
+ const runSteps = normalizeRunSteps(runResponse?.steps || []);
260
+ let allPassed = true;
261
+ let failedStep = null;
262
+
263
+ for (let index = 0; index < runSteps.length; index += 1) {
264
+ const backendStep = runSteps[index];
265
+ const localStep = profile.steps[index] || backendStep;
266
+ const stepId = backendStep.id;
267
+ const stepTitle = localStep.title || backendStep.label || backendStep.title || localStep.step_key;
268
+ const existingStateForStep = localState.steps[stepId];
269
+ const knownStatus = existingStateForStep?.status || backendStep.status;
270
+
271
+ if (knownStatus === 'success' || knownStatus === 'skipped') {
272
+ ui.step(index + 1, runSteps.length, stepTitle, 'success');
273
+ console.log(chalk.dim(' (already completed)'));
274
+ continue;
275
+ }
276
+
277
+ if (stepDelayMs > 0) {
278
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, stepDelayMs));
279
+ }
280
+
281
+ ui.step(index + 1, runSteps.length, stepTitle, 'running');
282
+
283
+ let exitCode = 0;
284
+ let stdout = '';
285
+ let stderr = '';
286
+ const startedAt = new Date().toISOString();
287
+ const startedMs = Date.now();
288
+
289
+ try {
290
+ if (backendStep.status !== 'running') {
291
+ await api.startStep(stepId, {
292
+ event_id: randomUUID(),
293
+ sequence: 1,
294
+ started_at: startedAt,
295
+ });
296
+ }
297
+
298
+ if (localStep.command) {
299
+ if (verbose) {
300
+ console.log(chalk.dim(` $ ${localStep.command}`));
301
+ }
302
+
303
+ const result = await execa(localStep.command, {
304
+ cwd: repoPath,
305
+ timeout: timeoutMs,
306
+ shell: true,
307
+ env: { ...process.env, FORCE_COLOR: '1' },
308
+ reject: false,
309
+ });
310
+
311
+ exitCode = result.exitCode;
312
+ stdout = result.stdout || '';
313
+ stderr = result.stderr || '';
314
+
315
+ if (verbose && stdout) {
316
+ stdout.split('\n').slice(0, 10).forEach((line) => {
317
+ if (line.trim()) console.log(chalk.dim(` ${line}`));
318
+ });
319
+ }
320
+ }
321
+
322
+ const durationMs = Date.now() - startedMs;
323
+ localState.steps[stepId] = {
324
+ status: exitCode === 0 ? 'success' : 'failed',
325
+ metadata: {
326
+ step_key: localStep.step_key,
327
+ command: localStep.command,
328
+ step_index: index,
329
+ exit_code: exitCode,
330
+ duration_ms: durationMs,
331
+ },
332
+ };
333
+ runState.set(localState);
334
+
335
+ await api.finishStep(stepId, {
336
+ event_id: randomUUID(),
337
+ sequence: 2,
338
+ status: exitCode === 0 ? 'success' : 'failed',
339
+ exit_code: exitCode,
340
+ duration_ms: durationMs,
341
+ stdout_tail: stdout.slice(-2000),
342
+ stderr_tail: stderr.slice(-2000),
343
+ finished_at: new Date().toISOString(),
344
+ });
345
+
346
+ if (stdout || stderr) {
347
+ const { entries } = buildLogEntries(stdout, stderr, 1);
348
+ if (entries.length > 0) {
349
+ await api.appendStepLogsBatch(stepId, { entries }).catch(() => {});
350
+ }
351
+ }
352
+
353
+ process.stdout.write('\x1B[1A\x1B[2K');
354
+ if (exitCode === 0) {
355
+ ui.step(index + 1, runSteps.length, stepTitle, 'success');
356
+ console.log(chalk.dim(` (${durationMs}ms)`));
357
+ } else {
358
+ ui.step(index + 1, runSteps.length, stepTitle, 'failed');
359
+ console.log(chalk.red(` Exit code: ${exitCode}`));
360
+ if (stderr) {
361
+ stderr.split('\n').slice(0, 3).forEach((line) => {
362
+ if (line.trim()) console.log(chalk.red(` ${line}`));
363
+ });
364
+ }
365
+ allPassed = false;
366
+ failedStep = localStep;
367
+ break;
368
+ }
369
+ } catch (error) {
370
+ process.stdout.write('\x1B[1A\x1B[2K');
371
+ ui.step(index + 1, runSteps.length, stepTitle, 'failed');
372
+ console.log(chalk.red(` Error: ${error.message}`));
373
+
374
+ localState.steps[stepId] = {
375
+ status: 'failed',
376
+ metadata: {
377
+ step_key: localStep.step_key,
378
+ command: localStep.command,
379
+ step_index: index,
380
+ error: error.message,
381
+ },
382
+ };
383
+ runState.set(localState);
384
+
385
+ allPassed = false;
386
+ failedStep = localStep;
387
+ break;
388
+ }
389
+ }
390
+
391
+ ui.divider();
392
+
393
+ if (allPassed) {
394
+ runState.clear();
395
+ ui.successPanel('Setup Complete', `
396
+ All ${runSteps.length} steps completed successfully!
397
+
398
+ ${chalk.bold('Next steps:')}
399
+ • Start coding
400
+ • Check ${chalk.cyan('octus doctor')} for environment status
401
+ • View your dashboard at ${chalk.cyan('https://demo.nboard.tech')}
402
+ `);
403
+ return;
404
+ }
405
+
406
+ ui.failurePanel('Setup Failed', `
407
+ Step "${failedStep?.title || failedStep?.step_key || 'unknown'}" failed.
408
+
409
+ ${chalk.bold('To retry:')}
410
+ ${chalk.cyan('octus setup --resume')}
411
+
412
+ ${chalk.bold('To debug:')}
413
+ ${chalk.cyan('octus doctor')}
414
+
415
+ ${chalk.bold('To start fresh:')}
416
+ ${chalk.cyan('octus setup --force-lock')}
417
+ `);
418
+
419
+ try {
420
+ const patterns = await api.getBlockerPatterns({
421
+ step_key: failedStep?.step_key,
422
+ });
423
+ if (Array.isArray(patterns) && patterns.length > 0) {
424
+ ui.info('Similar issues resolved before:');
425
+ patterns.slice(0, 3).forEach((pattern) => {
426
+ console.log(chalk.dim(` • ${pattern.resolution_summary || pattern.reason}`));
427
+ });
428
+ }
429
+ } catch {
430
+ // ignore blocker hint failures
431
+ }
432
+
433
+ process.exit(1);
434
+ } finally {
435
+ cleanup();
436
+ }
437
+ }
438
+
439
+ export default setupCommand;
package/src/index.js ADDED
@@ -0,0 +1,62 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { version } from './lib/version.js';
4
+
5
+ // Import commands
6
+ import { loginCommand } from './commands/login.js';
7
+ import { logoutCommand } from './commands/logout.js';
8
+ import { pingCommand } from './commands/ping.js';
9
+ import { configCommand } from './commands/config.js';
10
+ import { doctorCommand } from './commands/doctor.js';
11
+ import { initCommand } from './commands/init.js';
12
+ import { setupCommand } from './commands/setup.js';
13
+ import { generateProfileCommand } from './commands/generate-profile.js';
14
+
15
+ export const program = new Command();
16
+
17
+ // ASCII art banner
18
+ const banner = chalk.cyan(`
19
+ ____ __
20
+ / __ \\____/ /___ _______
21
+ / / / / __/ __/ / / / ___/
22
+ / /_/ / /_/ /_/ /_/ (__ )
23
+ \\____/\\__/\\__/\\__,_/____/
24
+ `);
25
+
26
+ program
27
+ .name('octus')
28
+ .description(`${banner}\n ${chalk.dim('AI-powered onboarding for engineering teams')}`)
29
+ .version(version, '-V, --version', 'Show version')
30
+ .addHelpText('after', `
31
+ ${chalk.bold('Examples:')}
32
+ ${chalk.dim('# First time setup')}
33
+ $ octus login
34
+ $ octus init .
35
+ $ octus setup
36
+
37
+ ${chalk.dim('# Check everything is working')}
38
+ $ octus doctor
39
+
40
+ ${chalk.dim('# Quick start (login + init + setup)')}
41
+ $ octus setup --turbo
42
+
43
+ ${chalk.bold('Documentation:')}
44
+ https://docs.nboard.tech/cli
45
+ `);
46
+
47
+ // Register all commands
48
+ program.addCommand(loginCommand);
49
+ program.addCommand(logoutCommand);
50
+ program.addCommand(pingCommand);
51
+ program.addCommand(configCommand);
52
+ program.addCommand(doctorCommand);
53
+ program.addCommand(initCommand);
54
+ program.addCommand(setupCommand);
55
+ program.addCommand(generateProfileCommand);
56
+
57
+ // Default action when no command provided
58
+ program.action(() => {
59
+ program.outputHelp();
60
+ });
61
+
62
+ export default program;