@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/src/lib/api.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import got from 'got';
|
|
2
|
+
import { config } from './config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create configured HTTP client
|
|
6
|
+
*/
|
|
7
|
+
function createClient() {
|
|
8
|
+
const { apiKey, backendUrl } = config.get();
|
|
9
|
+
|
|
10
|
+
return got.extend({
|
|
11
|
+
prefixUrl: backendUrl,
|
|
12
|
+
timeout: { request: 30000 },
|
|
13
|
+
headers: {
|
|
14
|
+
'Content-Type': 'application/json',
|
|
15
|
+
...(apiKey && { 'X-API-Key': apiKey })
|
|
16
|
+
},
|
|
17
|
+
responseType: 'json',
|
|
18
|
+
throwHttpErrors: false
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Octus API Client
|
|
24
|
+
* Mirrors the Python CLI's OctusClient
|
|
25
|
+
*/
|
|
26
|
+
export const api = {
|
|
27
|
+
// ─────────────────────────────────────────────────────────────
|
|
28
|
+
// Health & Auth
|
|
29
|
+
// ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
async healthCheck() {
|
|
32
|
+
const client = createClient();
|
|
33
|
+
const response = await client.get('health');
|
|
34
|
+
return { ok: response.statusCode === 200, data: response.body };
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async getMe() {
|
|
38
|
+
const client = createClient();
|
|
39
|
+
const response = await client.get('me');
|
|
40
|
+
if (response.statusCode !== 200) {
|
|
41
|
+
throw new Error(response.body?.detail || 'Failed to get user info');
|
|
42
|
+
}
|
|
43
|
+
return response.body;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async getMyOrganization() {
|
|
47
|
+
const client = createClient();
|
|
48
|
+
const response = await client.get('me/organization');
|
|
49
|
+
if (response.statusCode !== 200) {
|
|
50
|
+
throw new Error(response.body?.detail || 'Failed to get organization');
|
|
51
|
+
}
|
|
52
|
+
return response.body;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// ─────────────────────────────────────────────────────────────
|
|
56
|
+
// Repository Lifecycle
|
|
57
|
+
// ─────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
async registerRepository(repoData) {
|
|
60
|
+
const client = createClient();
|
|
61
|
+
const response = await client.post('repos/register', { json: repoData });
|
|
62
|
+
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
|
63
|
+
throw new Error(response.body?.detail || 'Failed to register repository');
|
|
64
|
+
}
|
|
65
|
+
return response.body;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async resolveProfile(repoId, gitRemoteUrl) {
|
|
69
|
+
const client = createClient();
|
|
70
|
+
const response = await client.post(`repos/${repoId}/resolve-profile`, {
|
|
71
|
+
json: { git_remote_url: gitRemoteUrl }
|
|
72
|
+
});
|
|
73
|
+
if (response.statusCode !== 200) {
|
|
74
|
+
throw new Error(response.body?.detail || 'Failed to resolve profile');
|
|
75
|
+
}
|
|
76
|
+
return response.body;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async createInitFile(repoId, profileRef) {
|
|
80
|
+
const client = createClient();
|
|
81
|
+
const response = await client.post(`repos/${repoId}/init-file`, {
|
|
82
|
+
json: { profile_ref: profileRef }
|
|
83
|
+
});
|
|
84
|
+
if (response.statusCode !== 200) {
|
|
85
|
+
throw new Error(response.body?.detail || 'Failed to create init file');
|
|
86
|
+
}
|
|
87
|
+
return response.body;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// ─────────────────────────────────────────────────────────────
|
|
91
|
+
// Run Lifecycle
|
|
92
|
+
// ─────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
async verifyInitFile(payload) {
|
|
95
|
+
const client = createClient();
|
|
96
|
+
const response = await client.post('runs/verify-init-file', { json: payload });
|
|
97
|
+
if (response.statusCode !== 200) {
|
|
98
|
+
throw new Error(response.body?.detail || 'Failed to verify init file');
|
|
99
|
+
}
|
|
100
|
+
return response.body;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async createRun(runData) {
|
|
104
|
+
const client = createClient();
|
|
105
|
+
const response = await client.post('runs', { json: runData });
|
|
106
|
+
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
|
107
|
+
throw new Error(response.body?.detail || 'Failed to create run');
|
|
108
|
+
}
|
|
109
|
+
return response.body;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async getRun(runId) {
|
|
113
|
+
const client = createClient();
|
|
114
|
+
const response = await client.get(`runs/${runId}`);
|
|
115
|
+
if (response.statusCode !== 200) {
|
|
116
|
+
throw new Error(response.body?.detail || 'Failed to get run');
|
|
117
|
+
}
|
|
118
|
+
return response.body;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async appendEnvironmentSnapshot(runId, payload) {
|
|
122
|
+
const client = createClient();
|
|
123
|
+
const response = await client.post(`runs/${runId}/environment-snapshots`, {
|
|
124
|
+
json: payload
|
|
125
|
+
});
|
|
126
|
+
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
|
127
|
+
throw new Error(response.body?.detail || 'Failed to append environment snapshot');
|
|
128
|
+
}
|
|
129
|
+
return response.body;
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// ─────────────────────────────────────────────────────────────
|
|
133
|
+
// Step Lifecycle
|
|
134
|
+
// ─────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
async startStep(stepId, payload) {
|
|
137
|
+
const client = createClient();
|
|
138
|
+
const response = await client.post(`steps/${stepId}/start`, { json: payload });
|
|
139
|
+
if (response.statusCode !== 200) {
|
|
140
|
+
throw new Error(response.body?.detail || 'Failed to start step');
|
|
141
|
+
}
|
|
142
|
+
return response.body;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async finishStep(stepId, payload) {
|
|
146
|
+
const client = createClient();
|
|
147
|
+
const response = await client.post(`steps/${stepId}/finish`, { json: payload });
|
|
148
|
+
if (response.statusCode !== 200) {
|
|
149
|
+
throw new Error(response.body?.detail || 'Failed to finish step');
|
|
150
|
+
}
|
|
151
|
+
return response.body;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async appendStepLog(stepId, payload) {
|
|
155
|
+
const client = createClient();
|
|
156
|
+
const response = await client.post(`steps/${stepId}/logs`, { json: payload });
|
|
157
|
+
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
|
158
|
+
throw new Error(response.body?.detail || 'Failed to append step log');
|
|
159
|
+
}
|
|
160
|
+
return response.body;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async appendStepLogsBatch(stepId, payload) {
|
|
164
|
+
const client = createClient();
|
|
165
|
+
const response = await client.post(`steps/${stepId}/logs/batch`, { json: payload });
|
|
166
|
+
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
|
167
|
+
throw new Error(response.body?.detail || 'Failed to append step logs batch');
|
|
168
|
+
}
|
|
169
|
+
return response.body;
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// ─────────────────────────────────────────────────────────────
|
|
173
|
+
// Profiles
|
|
174
|
+
// ─────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
async listProfiles() {
|
|
177
|
+
const client = createClient();
|
|
178
|
+
const response = await client.get('profiles');
|
|
179
|
+
if (response.statusCode !== 200) {
|
|
180
|
+
throw new Error(response.body?.detail || 'Failed to list profiles');
|
|
181
|
+
}
|
|
182
|
+
return response.body;
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async getProfile(profileKey) {
|
|
186
|
+
const client = createClient();
|
|
187
|
+
const response = await client.get(`profiles/${profileKey}`);
|
|
188
|
+
if (response.statusCode !== 200) {
|
|
189
|
+
throw new Error(response.body?.detail || 'Failed to get profile');
|
|
190
|
+
}
|
|
191
|
+
return response.body;
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
// ─────────────────────────────────────────────────────────────
|
|
195
|
+
// Blockers
|
|
196
|
+
// ─────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
async getBlockerPatterns(params = {}) {
|
|
199
|
+
const client = createClient();
|
|
200
|
+
const searchParams = new URLSearchParams(params).toString();
|
|
201
|
+
const url = searchParams ? `blockers/patterns?${searchParams}` : 'blockers/patterns';
|
|
202
|
+
const response = await client.get(url);
|
|
203
|
+
if (response.statusCode !== 200) {
|
|
204
|
+
throw new Error(response.body?.detail || 'Failed to get blocker patterns');
|
|
205
|
+
}
|
|
206
|
+
return response.body;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export default api;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { homedir, platform } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
copyFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'fs';
|
|
14
|
+
|
|
15
|
+
const NBOARD_DIR = join(homedir(), '.nboard');
|
|
16
|
+
const STATE_DIR = join(NBOARD_DIR, 'state');
|
|
17
|
+
const CONFIG_FILE = join(NBOARD_DIR, 'config.json');
|
|
18
|
+
const RUN_STATE_FILE = join(STATE_DIR, 'run_state.json');
|
|
19
|
+
const RUN_LOCK_FILE = join(STATE_DIR, 'run.lock');
|
|
20
|
+
const APPROVED_PROFILES_FILE = join(NBOARD_DIR, 'approved_profiles.json');
|
|
21
|
+
|
|
22
|
+
const LEGACY_DIR = join(homedir(), '.octus');
|
|
23
|
+
const LEGACY_CONFIG_FILE = join(LEGACY_DIR, 'config.toml');
|
|
24
|
+
const LEGACY_STATE_FILE = join(LEGACY_DIR, 'run_state.json');
|
|
25
|
+
const LEGACY_LOCK_FILE = join(LEGACY_DIR, 'run.lock');
|
|
26
|
+
|
|
27
|
+
function ensureNboardDir() {
|
|
28
|
+
if (!existsSync(NBOARD_DIR)) {
|
|
29
|
+
mkdirSync(NBOARD_DIR, { recursive: true });
|
|
30
|
+
if (platform() !== 'win32') chmodSync(NBOARD_DIR, 0o700);
|
|
31
|
+
}
|
|
32
|
+
if (!existsSync(STATE_DIR)) {
|
|
33
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
34
|
+
if (platform() !== 'win32') chmodSync(STATE_DIR, 0o700);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeSecureFile(filePath, data) {
|
|
39
|
+
ensureNboardDir();
|
|
40
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
41
|
+
if (platform() !== 'win32') chmodSync(filePath, 0o600);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readJsonFile(filePath, defaultValue = null) {
|
|
45
|
+
try {
|
|
46
|
+
if (existsSync(filePath)) {
|
|
47
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// ignore corrupted files and fall back
|
|
51
|
+
}
|
|
52
|
+
return defaultValue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function migrateLegacyLayout() {
|
|
56
|
+
ensureNboardDir();
|
|
57
|
+
|
|
58
|
+
if (!existsSync(CONFIG_FILE) && existsSync(LEGACY_CONFIG_FILE)) {
|
|
59
|
+
try {
|
|
60
|
+
const content = readFileSync(LEGACY_CONFIG_FILE, 'utf-8');
|
|
61
|
+
const migrated = {};
|
|
62
|
+
for (const line of content.split(/\r?\n/)) {
|
|
63
|
+
const match = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/);
|
|
64
|
+
if (!match) continue;
|
|
65
|
+
const [, key, value] = match;
|
|
66
|
+
if (key === 'api_key' || key === 'backend_url') {
|
|
67
|
+
migrated[key] = value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
writeSecureFile(CONFIG_FILE, migrated);
|
|
71
|
+
unlinkSync(LEGACY_CONFIG_FILE);
|
|
72
|
+
} catch {
|
|
73
|
+
// best effort
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const [legacyPath, currentPath] of [
|
|
78
|
+
[LEGACY_STATE_FILE, RUN_STATE_FILE],
|
|
79
|
+
[LEGACY_LOCK_FILE, RUN_LOCK_FILE],
|
|
80
|
+
]) {
|
|
81
|
+
if (!existsSync(currentPath) && existsSync(legacyPath)) {
|
|
82
|
+
try {
|
|
83
|
+
copyFileSync(legacyPath, currentPath);
|
|
84
|
+
if (platform() !== 'win32') chmodSync(currentPath, 0o600);
|
|
85
|
+
unlinkSync(legacyPath);
|
|
86
|
+
} catch {
|
|
87
|
+
// ignore
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (existsSync(LEGACY_DIR) && readdirSync(LEGACY_DIR).length === 0) {
|
|
94
|
+
rmSync(LEGACY_DIR, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// ignore
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const config = {
|
|
102
|
+
get() {
|
|
103
|
+
migrateLegacyLayout();
|
|
104
|
+
const envApiKey = process.env.OCTUS_API_TOKEN;
|
|
105
|
+
const envApiUrl = process.env.OCTUS_API_URL;
|
|
106
|
+
const fileConfig = readJsonFile(CONFIG_FILE, {});
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
apiKey: envApiKey || fileConfig.api_key || null,
|
|
110
|
+
backendUrl: envApiUrl || fileConfig.backend_url || 'http://localhost:8000',
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
set({ apiKey, backendUrl }) {
|
|
115
|
+
const current = readJsonFile(CONFIG_FILE, {});
|
|
116
|
+
const updated = {
|
|
117
|
+
...current,
|
|
118
|
+
...(apiKey !== undefined && { api_key: apiKey }),
|
|
119
|
+
...(backendUrl !== undefined && { backend_url: backendUrl }),
|
|
120
|
+
};
|
|
121
|
+
writeSecureFile(CONFIG_FILE, updated);
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
clear() {
|
|
125
|
+
if (existsSync(NBOARD_DIR)) {
|
|
126
|
+
rmSync(NBOARD_DIR, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
if (existsSync(LEGACY_DIR)) {
|
|
129
|
+
rmSync(LEGACY_DIR, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
isLoggedIn() {
|
|
134
|
+
const { apiKey } = this.get();
|
|
135
|
+
return Boolean(apiKey);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
getConfigPath() {
|
|
139
|
+
return CONFIG_FILE;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
getNboardDir() {
|
|
143
|
+
return NBOARD_DIR;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const runState = {
|
|
148
|
+
get() {
|
|
149
|
+
migrateLegacyLayout();
|
|
150
|
+
return readJsonFile(RUN_STATE_FILE, {
|
|
151
|
+
run_id: null,
|
|
152
|
+
profile_hash: null,
|
|
153
|
+
repo_path: null,
|
|
154
|
+
steps: {},
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
set(state) {
|
|
159
|
+
writeSecureFile(RUN_STATE_FILE, state);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
clear() {
|
|
163
|
+
if (existsSync(RUN_STATE_FILE)) {
|
|
164
|
+
rmSync(RUN_STATE_FILE);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
getFilePath() {
|
|
169
|
+
return RUN_STATE_FILE;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const runLock = {
|
|
174
|
+
STALE_THRESHOLD_MS: 60 * 60 * 1000,
|
|
175
|
+
|
|
176
|
+
acquire(force = false) {
|
|
177
|
+
ensureNboardDir();
|
|
178
|
+
|
|
179
|
+
const existing = this.get();
|
|
180
|
+
if (existing && !force) {
|
|
181
|
+
const acquiredAt = new Date(existing.acquired_at).getTime();
|
|
182
|
+
const isStale = Number.isFinite(acquiredAt) && (Date.now() - acquiredAt > this.STALE_THRESHOLD_MS);
|
|
183
|
+
|
|
184
|
+
if (!isStale) {
|
|
185
|
+
if (platform() !== 'win32') {
|
|
186
|
+
try {
|
|
187
|
+
process.kill(existing.pid, 0);
|
|
188
|
+
return { success: false, reason: 'locked', lock: existing };
|
|
189
|
+
} catch {
|
|
190
|
+
// process dead; lock stale
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
return { success: false, reason: 'locked', lock: existing };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const lock = {
|
|
199
|
+
pid: process.pid,
|
|
200
|
+
acquired_at: new Date().toISOString(),
|
|
201
|
+
};
|
|
202
|
+
writeSecureFile(RUN_LOCK_FILE, lock);
|
|
203
|
+
return { success: true, lock };
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
release() {
|
|
207
|
+
if (existsSync(RUN_LOCK_FILE)) {
|
|
208
|
+
rmSync(RUN_LOCK_FILE);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
get() {
|
|
213
|
+
return readJsonFile(RUN_LOCK_FILE, null);
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
getFilePath() {
|
|
217
|
+
return RUN_LOCK_FILE;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export const approvedProfiles = {
|
|
222
|
+
get() {
|
|
223
|
+
return readJsonFile(APPROVED_PROFILES_FILE, {});
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
add(profilePath, sha256, profileName) {
|
|
227
|
+
const profiles = this.get();
|
|
228
|
+
profiles[profilePath] = {
|
|
229
|
+
sha256,
|
|
230
|
+
approved_at: new Date().toISOString(),
|
|
231
|
+
profile_name: profileName,
|
|
232
|
+
};
|
|
233
|
+
writeSecureFile(APPROVED_PROFILES_FILE, profiles);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
isApproved(profilePath, sha256) {
|
|
237
|
+
const profiles = this.get();
|
|
238
|
+
const record = profiles[profilePath];
|
|
239
|
+
return Boolean(record && record.sha256 === sha256);
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
getFilePath() {
|
|
243
|
+
return APPROVED_PROFILES_FILE;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export default {
|
|
248
|
+
config,
|
|
249
|
+
runState,
|
|
250
|
+
runLock,
|
|
251
|
+
approvedProfiles,
|
|
252
|
+
NBOARD_DIR,
|
|
253
|
+
STATE_DIR,
|
|
254
|
+
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { basename, join } from 'path';
|
|
4
|
+
import { arch, platform, release } from 'os';
|
|
5
|
+
|
|
6
|
+
function normalizeText(value) {
|
|
7
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function mapCategoryToStepType(category) {
|
|
11
|
+
switch ((category || '').toLowerCase()) {
|
|
12
|
+
case 'dependency':
|
|
13
|
+
return 'install';
|
|
14
|
+
case 'access':
|
|
15
|
+
return 'auth';
|
|
16
|
+
case 'verification':
|
|
17
|
+
return 'verify';
|
|
18
|
+
default:
|
|
19
|
+
return 'custom';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mapOwner(owner) {
|
|
24
|
+
const normalized = (owner || '').toLowerCase();
|
|
25
|
+
if (normalized === 'human' || normalized === 'it' || normalized === 'octo') {
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
return 'octo';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeStep(rawStep, index) {
|
|
32
|
+
const key = normalizeText(rawStep.step_key)
|
|
33
|
+
|| normalizeText(rawStep.key)
|
|
34
|
+
|| normalizeText(rawStep.title)
|
|
35
|
+
|| `step_${index}`;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
step_index: typeof rawStep.step_index === 'number' ? rawStep.step_index : index,
|
|
39
|
+
step_key: key.toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
|
|
40
|
+
step_type: normalizeText(rawStep.step_type) || mapCategoryToStepType(rawStep.category),
|
|
41
|
+
title: normalizeText(rawStep.title) || normalizeText(rawStep.name) || key,
|
|
42
|
+
description: normalizeText(rawStep.description),
|
|
43
|
+
owner: mapOwner(rawStep.owner),
|
|
44
|
+
command: normalizeText(rawStep.command),
|
|
45
|
+
retryable: rawStep.retryable !== false,
|
|
46
|
+
timeout_seconds: Number.isFinite(Number(rawStep.timeout_seconds))
|
|
47
|
+
? Math.max(1, Number(rawStep.timeout_seconds))
|
|
48
|
+
: 300,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeProfile(rawProfile, fallbackName = 'custom') {
|
|
53
|
+
const steps = Array.isArray(rawProfile?.steps)
|
|
54
|
+
? rawProfile.steps.map((step, index) => normalizeStep(step || {}, index))
|
|
55
|
+
: [];
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
key: normalizeText(rawProfile?.profile_key) || normalizeText(rawProfile?.key) || null,
|
|
59
|
+
name: normalizeText(rawProfile?.display_name)
|
|
60
|
+
|| normalizeText(rawProfile?.profile_name)
|
|
61
|
+
|| normalizeText(rawProfile?.name)
|
|
62
|
+
|| fallbackName,
|
|
63
|
+
version: normalizeText(rawProfile?.version) || 'v1',
|
|
64
|
+
run_command: normalizeText(rawProfile?.run_command),
|
|
65
|
+
run_url: normalizeText(rawProfile?.run_url),
|
|
66
|
+
steps,
|
|
67
|
+
env_vars_required: Array.isArray(rawProfile?.env_vars_required)
|
|
68
|
+
? rawProfile.env_vars_required.filter((item) => typeof item === 'string' && item.trim())
|
|
69
|
+
: [],
|
|
70
|
+
raw: rawProfile,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function computeProfileHash(profileName, steps) {
|
|
75
|
+
return createHash('sha256')
|
|
76
|
+
.update(JSON.stringify({ profileName, steps }))
|
|
77
|
+
.digest('hex');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeInitFilePayload(rawPayload) {
|
|
81
|
+
const orgId = normalizeText(rawPayload?.org_id);
|
|
82
|
+
const repoId = normalizeText(rawPayload?.repo_id);
|
|
83
|
+
const profileRef = normalizeText(rawPayload?.profile_ref);
|
|
84
|
+
const sig = normalizeText(rawPayload?.sig);
|
|
85
|
+
|
|
86
|
+
if (!orgId || !repoId || !profileRef || !sig) {
|
|
87
|
+
throw new Error('Invalid .octus.yaml — run `octus init` again.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
org_id: orgId,
|
|
92
|
+
repo_id: repoId,
|
|
93
|
+
profile_ref: profileRef,
|
|
94
|
+
sig,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function scanRepository(repoPath) {
|
|
99
|
+
const projectTypes = [];
|
|
100
|
+
if (existsSync(join(repoPath, 'package.json'))) projectTypes.push('node');
|
|
101
|
+
if (existsSync(join(repoPath, 'pyproject.toml')) || existsSync(join(repoPath, 'requirements.txt'))) {
|
|
102
|
+
projectTypes.push('python');
|
|
103
|
+
}
|
|
104
|
+
if (existsSync(join(repoPath, 'Cargo.toml'))) projectTypes.push('rust');
|
|
105
|
+
if (existsSync(join(repoPath, 'go.mod'))) projectTypes.push('go');
|
|
106
|
+
if (existsSync(join(repoPath, 'pom.xml'))) projectTypes.push('java');
|
|
107
|
+
if (existsSync(join(repoPath, 'Gemfile'))) projectTypes.push('ruby');
|
|
108
|
+
if (existsSync(join(repoPath, 'docker-compose.yml')) || existsSync(join(repoPath, 'docker-compose.yaml'))) {
|
|
109
|
+
projectTypes.push('docker_compose');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
name: basename(repoPath),
|
|
114
|
+
path: repoPath,
|
|
115
|
+
project_type: projectTypes[0] || 'unknown',
|
|
116
|
+
project_types: projectTypes,
|
|
117
|
+
has_readme: existsSync(join(repoPath, 'README.md')),
|
|
118
|
+
has_dockerfile: existsSync(join(repoPath, 'Dockerfile')),
|
|
119
|
+
has_docker_compose: existsSync(join(repoPath, 'docker-compose.yml')) || existsSync(join(repoPath, 'docker-compose.yaml')),
|
|
120
|
+
metadata: {
|
|
121
|
+
project_type: projectTypes[0] || 'unknown',
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildRunContext({
|
|
127
|
+
scanResult,
|
|
128
|
+
repoPath,
|
|
129
|
+
profileName,
|
|
130
|
+
profileVersion,
|
|
131
|
+
resume = false,
|
|
132
|
+
gitRemoteUrl = null,
|
|
133
|
+
}) {
|
|
134
|
+
return {
|
|
135
|
+
repo_name: scanResult.name,
|
|
136
|
+
repo_path: repoPath,
|
|
137
|
+
project_type: scanResult.project_type,
|
|
138
|
+
default_branch: 'main',
|
|
139
|
+
profile_name: profileName,
|
|
140
|
+
profile_version: profileVersion,
|
|
141
|
+
git_remote_url: gitRemoteUrl,
|
|
142
|
+
user_identifier: null,
|
|
143
|
+
resume,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildEnvironmentSnapshot({
|
|
148
|
+
scanResult,
|
|
149
|
+
profileName,
|
|
150
|
+
profileVersion,
|
|
151
|
+
captureSource = 'cli_setup_start',
|
|
152
|
+
}) {
|
|
153
|
+
return {
|
|
154
|
+
capture_source: captureSource,
|
|
155
|
+
os_name: platform(),
|
|
156
|
+
os_version: release(),
|
|
157
|
+
platform: `${platform()} ${release()}`,
|
|
158
|
+
architecture: arch(),
|
|
159
|
+
node_version: process.version,
|
|
160
|
+
shell: process.env.SHELL || process.env.ComSpec || null,
|
|
161
|
+
metadata: {
|
|
162
|
+
repo_name: scanResult.name,
|
|
163
|
+
project_type: scanResult.project_type,
|
|
164
|
+
profile_name: profileName,
|
|
165
|
+
profile_version: profileVersion,
|
|
166
|
+
},
|
|
167
|
+
captured_at: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function buildRunPayload({
|
|
172
|
+
repoResponse,
|
|
173
|
+
currentUser,
|
|
174
|
+
scanResult,
|
|
175
|
+
selectedProfile,
|
|
176
|
+
selectedProfileKey,
|
|
177
|
+
selectedProfileVersion,
|
|
178
|
+
profileHash,
|
|
179
|
+
stepDefs,
|
|
180
|
+
runContext,
|
|
181
|
+
environmentSnapshot,
|
|
182
|
+
}) {
|
|
183
|
+
return {
|
|
184
|
+
organization_id: repoResponse.organization_id,
|
|
185
|
+
project_id: repoResponse.id,
|
|
186
|
+
user_identifier: currentUser.email,
|
|
187
|
+
role: currentUser.role,
|
|
188
|
+
profile_version: selectedProfileVersion,
|
|
189
|
+
profile_hash: profileHash,
|
|
190
|
+
profile_key: selectedProfileKey || undefined,
|
|
191
|
+
profile_data: {
|
|
192
|
+
profile_key: selectedProfileKey || undefined,
|
|
193
|
+
profile_name: selectedProfile,
|
|
194
|
+
project_type: scanResult.project_type,
|
|
195
|
+
},
|
|
196
|
+
workflow_type: 'onboarding',
|
|
197
|
+
workflow_name: selectedProfile,
|
|
198
|
+
trigger_source: 'cli_setup',
|
|
199
|
+
trigger_actor: currentUser.email,
|
|
200
|
+
subject_type: 'user',
|
|
201
|
+
subject_identifier: currentUser.email,
|
|
202
|
+
run_context: {
|
|
203
|
+
...runContext,
|
|
204
|
+
user_identifier: currentUser.email,
|
|
205
|
+
},
|
|
206
|
+
environment_snapshot: environmentSnapshot,
|
|
207
|
+
steps: stepDefs,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildLogEntries(stdout, stderr, sequenceStart = 1) {
|
|
212
|
+
const entries = [];
|
|
213
|
+
let sequence = sequenceStart;
|
|
214
|
+
|
|
215
|
+
for (const [stream, content] of [['stdout', stdout], ['stderr', stderr]]) {
|
|
216
|
+
if (!content || !String(content).trim()) continue;
|
|
217
|
+
for (const line of String(content).split(/\r?\n/)) {
|
|
218
|
+
if (!line.trim()) continue;
|
|
219
|
+
entries.push({
|
|
220
|
+
stream,
|
|
221
|
+
message: line,
|
|
222
|
+
sequence,
|
|
223
|
+
ts: new Date().toISOString(),
|
|
224
|
+
event_id: randomUUID(),
|
|
225
|
+
});
|
|
226
|
+
sequence += 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { entries, nextSequence: sequence };
|
|
231
|
+
}
|