@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
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
|
+
[](https://www.npmjs.com/package/@nboard-dev/octus)
|
|
4
|
+
[](./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
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;
|