@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 ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## v0.3.0
4
+
5
+ Initial Node.js release of `@nboard/octus`.
6
+
7
+ Highlights:
8
+ - replaces the standard Python/Homebrew installation path for the Octus CLI
9
+ - preserves the core command surface:
10
+ - `octus login`
11
+ - `octus logout`
12
+ - `octus ping`
13
+ - `octus config`
14
+ - `octus init`
15
+ - `octus setup`
16
+ - `octus doctor`
17
+ - `octus generate-profile`
18
+ - stores config and runtime state under `~/.nboard/`
19
+ - supports Anthropic-powered onboarding profile generation during `octus init`
20
+ - includes repo-local Octus files:
21
+ - `.octus.yaml`
22
+ - `.octus/profile.json`
23
+ - `.octusignore`
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nBoard Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # @nboard-dev/octus
2
+
3
+ [![npm version](https://img.shields.io/npm/v/%40nboard%2Foctus.svg)](https://www.npmjs.com/package/@nboard-dev/octus)
4
+ [![license](https://img.shields.io/npm/l/%40nboard%2Foctus.svg)](./LICENSE)
5
+
6
+ > AI-powered onboarding for engineering teams
7
+
8
+ ```text
9
+ ____ __
10
+ / __ \____/ /___ _______
11
+ / / / / __/ __/ / / / ___/
12
+ / /_/ / /_/ /_/ /_/ (__ )
13
+ \____/\__/\__/\__,_/____/
14
+ ```
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g @nboard-dev/octus
20
+ ```
21
+
22
+ Works on **macOS**, **Linux**, and **Windows**.
23
+
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ # 1. Login with your API key
28
+ octus login
29
+
30
+ # 2. Initialize your repo
31
+ octus init .
32
+
33
+ # 3. Run onboarding
34
+ octus setup
35
+
36
+ # 4. Check status
37
+ octus doctor
38
+ ```
39
+
40
+ Or do it all in one command:
41
+
42
+ ```bash
43
+ octus init . --run-setup
44
+ ```
45
+
46
+ ## Commands
47
+
48
+ | Command | Description |
49
+ |---------|-------------|
50
+ | `octus login` | Authenticate with your API key |
51
+ | `octus logout` | Clear stored credentials |
52
+ | `octus ping` | Check connection & auth status |
53
+ | `octus config` | Show current configuration |
54
+ | `octus init [path]` | Initialize a repository |
55
+ | `octus setup [path]` | Run onboarding steps |
56
+ | `octus doctor [path]` | Health check your setup |
57
+ | `octus generate-profile` | Generate a draft profile |
58
+
59
+ ## Migration from Python CLI
60
+
61
+ `@nboard-dev/octus` is the Node.js replacement for the original Python/Homebrew CLI.
62
+
63
+ What stays the same:
64
+ - command names (`octus login`, `octus init`, `octus setup`, `octus doctor`)
65
+ - local config directory: `~/.nboard/`
66
+ - repo-local files: `.octus.yaml`, `.octus/profile.json`, `.octusignore`
67
+
68
+ What changes:
69
+ - install with npm instead of Poetry/Homebrew
70
+ - Node.js 18+ is now the primary runtime
71
+ - AI profile generation uses `@anthropic-ai/sdk` when `ANTHROPIC_API_KEY` is set
72
+
73
+ ## Setup Options
74
+
75
+ ```bash
76
+ # Use a specific profile
77
+ octus setup --profile demo-node
78
+
79
+ # Skip confirmations (CI mode)
80
+ octus setup --turbo
81
+
82
+ # Resume a failed run
83
+ octus setup --resume
84
+
85
+ # Show detailed output
86
+ octus setup --verbose
87
+
88
+ # Custom timeout per step (seconds)
89
+ octus setup --timeout 600
90
+ ```
91
+
92
+ ## Init Options
93
+
94
+ ```bash
95
+ # Clone a repo and init
96
+ octus init https://github.com/org/repo.git
97
+
98
+ # Clone to specific directory
99
+ octus init https://github.com/org/repo.git -d my-folder
100
+
101
+ # Init and immediately run setup
102
+ octus init . --run-setup
103
+ ```
104
+
105
+ ## Configuration
106
+
107
+ Octus stores config in `~/.nboard/`:
108
+
109
+ ```text
110
+ ~/.nboard/
111
+ ├── config.json # API key & backend URL
112
+ ├── approved_profiles.json # Profile approval records
113
+ └── state/
114
+ ├── run_state.json # Current run progress
115
+ └── run.lock # Prevents concurrent runs
116
+ ```
117
+
118
+ ## Environment Variables
119
+
120
+ | Variable | Description |
121
+ |----------|-------------|
122
+ | `OCTUS_API_TOKEN` | API key (overrides config file) |
123
+ | `OCTUS_API_URL` | Backend URL (overrides config file) |
124
+ | `ANTHROPIC_API_KEY` | Enables AI-generated onboarding profiles during `octus init` |
125
+
126
+ ## Repository Files
127
+
128
+ Octus creates these files in your repo:
129
+
130
+ | File | Purpose |
131
+ |------|---------|
132
+ | `.octus.yaml` | Signed init file from backend |
133
+ | `.octus/profile.json` | Local onboarding profile |
134
+ | `.octusignore` | Files to exclude from AI context |
135
+
136
+ ## Requirements
137
+
138
+ - Node.js 18+
139
+ - Git
140
+
141
+ ## Troubleshooting
142
+
143
+ ### `Not logged in`
144
+
145
+ ```bash
146
+ octus login
147
+ ```
148
+
149
+ ### `.octus.yaml not found`
150
+
151
+ ```bash
152
+ octus init .
153
+ ```
154
+
155
+ ### AI profile generation fell back to rule-based mode
156
+
157
+ ```bash
158
+ export ANTHROPIC_API_KEY=...
159
+ octus init .
160
+ ```
161
+
162
+ ### Backend/auth problems
163
+
164
+ ```bash
165
+ octus config
166
+ octus ping
167
+ ```
168
+
169
+ ### Resume a blocked/interrupted onboarding run
170
+
171
+ ```bash
172
+ octus setup --resume
173
+ ```
174
+
175
+ ## Links
176
+
177
+ - [Documentation](https://docs.nboard.tech/cli)
178
+ - [Dashboard](https://demo.nboard.tech)
179
+ - [Support](mailto:support@nboard.tech)
180
+
181
+ ## License
182
+
183
+ MIT © nBoard Team
package/bin/octus.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from '../src/index.js';
4
+
5
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@nboard-dev/octus",
3
+ "version": "0.3.0",
4
+ "description": "Octus CLI - AI-powered onboarding for engineering teams",
5
+ "type": "module",
6
+ "bin": {
7
+ "octus": "./bin/octus.js"
8
+ },
9
+ "main": "./src/index.js",
10
+ "scripts": {
11
+ "start": "node bin/octus.js",
12
+ "test": "vitest",
13
+ "lint": "eslint src/ bin/ test/",
14
+ "build": "echo 'No build step needed for pure JS'"
15
+ },
16
+ "keywords": [
17
+ "octus",
18
+ "nboard",
19
+ "onboarding",
20
+ "cli",
21
+ "developer-tools",
22
+ "ai"
23
+ ],
24
+ "author": "nBoard Team",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/nboard-team/octus-cli"
29
+ },
30
+ "homepage": "https://github.com/nboard-team/octus-cli#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/nboard-team/octus-cli/issues"
33
+ },
34
+ "funding": {
35
+ "type": "github",
36
+ "url": "https://github.com/sponsors/nboard-team"
37
+ },
38
+ "files": [
39
+ "bin",
40
+ "src",
41
+ "README.md",
42
+ "CHANGELOG.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18.0.0"
47
+ },
48
+ "dependencies": {
49
+ "@anthropic-ai/sdk": "^0.24.0",
50
+ "boxen": "^7.1.1",
51
+ "chalk": "^5.3.0",
52
+ "cli-table3": "^0.6.5",
53
+ "commander": "^12.1.0",
54
+ "conf": "^12.0.0",
55
+ "execa": "^9.3.0",
56
+ "figures": "^6.1.0",
57
+ "got": "^14.4.1",
58
+ "inquirer": "^9.2.23",
59
+ "log-symbols": "^6.0.0",
60
+ "ora": "^8.0.1",
61
+ "proper-lockfile": "^4.1.2",
62
+ "yaml": "^2.4.5"
63
+ },
64
+ "devDependencies": {
65
+ "eslint": "^9.5.0",
66
+ "vitest": "^1.6.0"
67
+ }
68
+ }
@@ -0,0 +1,29 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { config } from '../lib/config.js';
4
+ import ui from '../lib/ui.js';
5
+
6
+ export const configCommand = new Command('config')
7
+ .description('Show current configuration')
8
+ .action(async () => {
9
+ ui.header('Octus Configuration');
10
+
11
+ const { apiKey, backendUrl } = config.get();
12
+ const isLoggedIn = config.isLoggedIn();
13
+
14
+ ui.divider();
15
+ ui.keyValue([
16
+ ['Config file', config.getConfigPath()],
17
+ ['Data directory', config.getNboardDir()],
18
+ ['Backend URL', backendUrl || chalk.dim('Not set')],
19
+ ['API Key', apiKey ? ui.maskSecret(apiKey) : chalk.dim('Not set')],
20
+ ['Status', isLoggedIn ? chalk.green('Logged in') : chalk.yellow('Not logged in')]
21
+ ]);
22
+ ui.divider();
23
+
24
+ if (!isLoggedIn) {
25
+ ui.info('Run `octus login` to authenticate');
26
+ }
27
+ });
28
+
29
+ export default configCommand;
@@ -0,0 +1,254 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join, resolve } from 'path';
4
+ import { execa } from 'execa';
5
+ import { config, runState } from '../lib/config.js';
6
+ import { api } from '../lib/api.js';
7
+ import ui from '../lib/ui.js';
8
+
9
+ const DOCTOR_TIMEOUT_MS = 10_000;
10
+ const LOCAL_ENV_FILE_NAME = '.env.local';
11
+ const DISALLOWED_SHELL_TOKENS = new Set([';', '|', '||', '&', '&&', '>', '>>', '<', '<<']);
12
+ const DISALLOWED_SHELL_SUBSTRINGS = ['`', '$(', '${', '\n', '\r'];
13
+
14
+ function parseEnvLocalKeys(repoPath) {
15
+ const envPath = join(repoPath, LOCAL_ENV_FILE_NAME);
16
+ if (!existsSync(envPath)) return new Set();
17
+
18
+ return new Set(
19
+ readFileSync(envPath, 'utf-8')
20
+ .split(/\r?\n/)
21
+ .map((line) => line.trim())
22
+ .filter((line) => line && !line.startsWith('#'))
23
+ .map((line) => (line.startsWith('export ') ? line.slice('export '.length).trim() : line))
24
+ .filter((line) => line.includes('='))
25
+ .map((line) => line.split('=', 1)[0].trim())
26
+ .filter(Boolean)
27
+ );
28
+ }
29
+
30
+ function expectedText(step) {
31
+ return (typeof step.description === 'string' && step.description.trim())
32
+ ? step.description.trim()
33
+ : (step.name || step.title || step.key || step.step_key || '').trim() || null;
34
+ }
35
+
36
+ function validateCheckCommand(checkCommand) {
37
+ const stripped = checkCommand.trim();
38
+ if (!stripped) return { valid: false, message: 'Command is empty.' };
39
+
40
+ for (const pattern of DISALLOWED_SHELL_SUBSTRINGS) {
41
+ if (stripped.includes(pattern)) {
42
+ return { valid: false, message: `Command contains disallowed shell syntax: ${pattern}` };
43
+ }
44
+ }
45
+
46
+ const tokens = stripped.split(/\s+/);
47
+ for (const token of tokens) {
48
+ if (DISALLOWED_SHELL_TOKENS.has(token)) {
49
+ return { valid: false, message: `Command contains disallowed shell operator: ${token}` };
50
+ }
51
+ }
52
+
53
+ return { valid: true, tokens };
54
+ }
55
+
56
+ async function runStepCheck(repoPath, step) {
57
+ const key = step.key || step.step_key || 'unknown_step';
58
+ const name = step.name || step.title || key;
59
+ const safe = validateCheckCommand(step.check_command);
60
+
61
+ if (!safe.valid) {
62
+ return {
63
+ key,
64
+ name,
65
+ expected_text: expectedText(step),
66
+ passed: false,
67
+ timed_out: false,
68
+ actual_text: `Unsafe command rejected: ${safe.message}`,
69
+ };
70
+ }
71
+
72
+ try {
73
+ const result = await execa(safe.tokens[0], safe.tokens.slice(1), {
74
+ cwd: repoPath,
75
+ timeout: DOCTOR_TIMEOUT_MS,
76
+ reject: false,
77
+ });
78
+ const combined = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
79
+ return {
80
+ key,
81
+ name,
82
+ expected_text: expectedText(step),
83
+ passed: result.exitCode === 0,
84
+ timed_out: false,
85
+ actual_text: combined ? combined.replace(/\s+/g, ' ') : (result.exitCode === 0 ? 'passed' : 'failed'),
86
+ };
87
+ } catch (error) {
88
+ if (error.timedOut) {
89
+ return {
90
+ key,
91
+ name,
92
+ expected_text: expectedText(step),
93
+ passed: false,
94
+ timed_out: true,
95
+ actual_text: null,
96
+ };
97
+ }
98
+
99
+ return {
100
+ key,
101
+ name,
102
+ expected_text: expectedText(step),
103
+ passed: false,
104
+ timed_out: false,
105
+ actual_text: `Command not found: ${safe.tokens[0]}`,
106
+ };
107
+ }
108
+ }
109
+
110
+ function summarize(checks, envVars) {
111
+ return {
112
+ passed: checks.filter((item) => item.passed).length,
113
+ failed: checks.filter((item) => !item.passed && !item.timed_out).length,
114
+ timed_out: checks.filter((item) => item.timed_out).length,
115
+ env_vars_missing: envVars.filter((item) => !item.present).length,
116
+ };
117
+ }
118
+
119
+ function renderCheckLine(item) {
120
+ if (item.timed_out) return `⚠ ${item.key.padEnd(24)} timed out (10s)`;
121
+ const symbol = item.passed ? '✓' : '✗';
122
+ const base = item.actual_text || (item.passed ? 'passed' : 'failed');
123
+ const detail = item.expected_text ? `${base} (expected ${item.expected_text})` : base;
124
+ return `${symbol} ${item.key.padEnd(24)} ${detail}`;
125
+ }
126
+
127
+ function renderEnvVarLine(item) {
128
+ return `${item.present ? '✓' : '✗'} ${item.name.padEnd(24)} ${item.present ? 'set' : 'missing'}`;
129
+ }
130
+
131
+ function doctorExitCode(report) {
132
+ if (report.summary.failed) return 1;
133
+ if (report.summary.timed_out) return 1;
134
+ if (report.summary.env_vars_missing) return 1;
135
+ return 0;
136
+ }
137
+
138
+ async function syncDoctorReport(repoPath, report) {
139
+ const localState = runState.get();
140
+ if (!localState.run_id) {
141
+ ui.warning('No saved Octus run id found; skipping doctor sync.');
142
+ return;
143
+ }
144
+ if (localState.repo_path && localState.repo_path !== repoPath) {
145
+ ui.warning('Saved run state belongs to a different repository; skipping doctor sync.');
146
+ return;
147
+ }
148
+
149
+ try {
150
+ await api.appendEnvironmentSnapshot(localState.run_id, {
151
+ capture_source: 'cli_doctor',
152
+ node_version: process.version,
153
+ shell: process.env.SHELL || process.env.ComSpec || null,
154
+ captured_at: report.checked_at,
155
+ metadata: {
156
+ doctor_report: report,
157
+ },
158
+ });
159
+ } catch (error) {
160
+ ui.warning(`Doctor sync failed: ${error.message}`);
161
+ }
162
+ }
163
+
164
+ export const doctorCommand = new Command('doctor')
165
+ .description('Run a read-only health check against the saved Octus profile')
166
+ .argument('[path]', 'Path to the repository to check', '.')
167
+ .option('--sync', 'Best-effort sync of the doctor report to the backend using the saved local run id')
168
+ .option('--json', 'Print the full doctor report as JSON')
169
+ .action(async (repoPathArg, options) => {
170
+ const repoPath = resolve(repoPathArg);
171
+ if (!existsSync(repoPath)) {
172
+ ui.error(`Path does not exist: ${repoPath}`);
173
+ process.exit(1);
174
+ }
175
+
176
+ const profilePath = join(repoPath, '.octus', 'profile.json');
177
+ if (!existsSync(profilePath)) {
178
+ ui.error('No Octus profile found. Run octus init . first.');
179
+ process.exit(1);
180
+ }
181
+
182
+ let profile;
183
+ try {
184
+ profile = JSON.parse(readFileSync(profilePath, 'utf-8'));
185
+ } catch (error) {
186
+ ui.error(`Invalid Octus profile: ${error.message}`);
187
+ process.exit(1);
188
+ }
189
+
190
+ const steps = Array.isArray(profile.steps) ? profile.steps : [];
191
+ const envVarNames = Array.isArray(profile.env_vars_required) ? profile.env_vars_required : [];
192
+ const envLocalKeys = parseEnvLocalKeys(repoPath);
193
+ const checks = [];
194
+
195
+ for (const step of steps) {
196
+ if (!step.check_command) continue;
197
+ checks.push(await runStepCheck(repoPath, step));
198
+ }
199
+
200
+ const envVars = envVarNames.map((name) => ({
201
+ name,
202
+ present: Boolean(process.env[name]) || envLocalKeys.has(name),
203
+ }));
204
+
205
+ const report = {
206
+ repo: repoPath.split('/').pop(),
207
+ profile_name: profile.profile_name || profile.name || 'unknown',
208
+ checked_at: new Date().toISOString(),
209
+ checks,
210
+ env_vars: envVars,
211
+ summary: summarize(checks, envVars),
212
+ };
213
+
214
+ if (options.sync && config.isLoggedIn()) {
215
+ await syncDoctorReport(repoPath, report);
216
+ }
217
+
218
+ if (options.json) {
219
+ console.log(JSON.stringify(report, null, 2));
220
+ process.exit(doctorExitCode(report));
221
+ }
222
+
223
+ console.log(`octus doctor — ${report.repo}`);
224
+ console.log(`Profile: ${report.profile_name}`);
225
+ console.log(`Checked: ${new Date(report.checked_at).toLocaleString()}`);
226
+ console.log('ENVIRONMENT HEALTH');
227
+ console.log('──────────────────────────────────────────');
228
+ if (report.checks.length === 0) {
229
+ console.log('No check_command entries found in the saved profile.');
230
+ } else {
231
+ report.checks.forEach((item) => console.log(renderCheckLine(item)));
232
+ }
233
+ console.log('ENV VARS');
234
+ console.log('──────────────────────────────────────────');
235
+ if (report.env_vars.length === 0) {
236
+ console.log('No required environment variables declared in the saved profile.');
237
+ } else {
238
+ report.env_vars.forEach((item) => console.log(renderEnvVarLine(item)));
239
+ }
240
+ console.log('SUMMARY');
241
+ console.log('──────────────────────────────────────────');
242
+ console.log(
243
+ `${report.summary.passed} passed `
244
+ + `${report.summary.failed} failed `
245
+ + `${report.summary.timed_out} timed out `
246
+ + `${report.summary.env_vars_missing} env var missing`
247
+ );
248
+ console.log('Run octus setup . to apply the latest profile.');
249
+ console.log('Run octus setup . to fix failing steps.');
250
+
251
+ process.exit(doctorExitCode(report));
252
+ });
253
+
254
+ export default doctorCommand;
@@ -0,0 +1,46 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, writeFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import chalk from 'chalk';
5
+ import ui from '../lib/ui.js';
6
+ import { generateRuleBasedProfile, toYamlDraft } from '../lib/profile-generator.js';
7
+
8
+ export const generateProfileCommand = new Command('generate-profile')
9
+ .description('Analyze a repository and generate a draft onboarding profile')
10
+ .argument('[path]', 'Repository path to analyze', '.')
11
+ .option('-o, --output <file>', 'Output file path (otherwise prints to stdout)')
12
+ .action(async (pathArg, options) => {
13
+ const repoPath = resolve(pathArg);
14
+
15
+ if (!existsSync(repoPath)) {
16
+ ui.error(`Path does not exist: ${repoPath}`);
17
+ process.exit(1);
18
+ }
19
+
20
+ ui.header('Generate Onboarding Profile');
21
+ ui.info(`Analyzing: ${repoPath}`);
22
+
23
+ const spin = ui.spinner('Scanning repository...').start();
24
+ const { analysis, profile } = generateRuleBasedProfile(repoPath);
25
+ spin.succeed('Profile generated');
26
+
27
+ const yamlOutput = toYamlDraft(profile);
28
+
29
+ if (options.output) {
30
+ writeFileSync(options.output, yamlOutput);
31
+ ui.success(`Profile saved to: ${options.output}`);
32
+ } else {
33
+ ui.divider();
34
+ console.log(chalk.cyan('─'.repeat(50)));
35
+ console.log(yamlOutput);
36
+ console.log(chalk.cyan('─'.repeat(50)));
37
+ }
38
+
39
+ if (analysis.matchesKnownProfile) {
40
+ ui.divider();
41
+ ui.warning(`This repo matches the "${analysis.matchesKnownProfile}" profile pattern`);
42
+ ui.info('Consider using the built-in profile instead of a custom one');
43
+ }
44
+ });
45
+
46
+ export default generateProfileCommand;