@bli-cockpit/cli 0.1.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/README.md +26 -0
- package/dist/adapters/car-state.js +103 -0
- package/dist/adapters/common.js +35 -0
- package/dist/adapters/git-state.js +133 -0
- package/dist/adapters/local-sources.js +59 -0
- package/dist/adapters/risk-flags.js +67 -0
- package/dist/adapters/ticket-binding.js +65 -0
- package/dist/cli.js +5 -0
- package/dist/commands/local.js +699 -0
- package/dist/commands/public-root.js +32 -0
- package/dist/local-state.js +471 -0
- package/dist/server.js +99 -0
- package/dist/spool/local-spool.js +145 -0
- package/dist/upload.js +321 -0
- package/package.json +30 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const SPOOL_STATE_FILENAME = "upload-state.json";
|
|
5
|
+
const MAX_PENDING_UPLOADS = 20;
|
|
6
|
+
export function emptyUploadSpoolState() {
|
|
7
|
+
return {
|
|
8
|
+
schema_version: "cockpit-upload-spool.v1",
|
|
9
|
+
updated_at: null,
|
|
10
|
+
last_upload_attempt_at: null,
|
|
11
|
+
last_upload_success_at: null,
|
|
12
|
+
last_upload_failure_reason: null,
|
|
13
|
+
pending_uploads: [],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export async function readLocalUploadSpoolState(paths) {
|
|
17
|
+
try {
|
|
18
|
+
const raw = JSON.parse(await fs.readFile(uploadSpoolStatePath(paths), "utf8"));
|
|
19
|
+
return parseUploadSpoolState(raw);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return emptyUploadSpoolState();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function summarizeLocalUploadSpool(paths) {
|
|
26
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
27
|
+
return {
|
|
28
|
+
last_upload_attempt_at: state.last_upload_attempt_at,
|
|
29
|
+
last_upload_success_at: state.last_upload_success_at,
|
|
30
|
+
last_upload_failure_reason: state.last_upload_failure_reason,
|
|
31
|
+
pending_upload_count: state.pending_uploads.length,
|
|
32
|
+
retry_command: state.pending_uploads[0]?.retry_command ?? null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export async function recordUploadBlocked(paths, options) {
|
|
36
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
37
|
+
const next = {
|
|
38
|
+
...state,
|
|
39
|
+
updated_at: options.attemptedAt,
|
|
40
|
+
last_upload_attempt_at: options.attemptedAt,
|
|
41
|
+
last_upload_failure_reason: options.reason,
|
|
42
|
+
};
|
|
43
|
+
await writeUploadSpoolState(paths, next);
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
export async function recordUploadSuccess(paths, options) {
|
|
47
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
48
|
+
const next = {
|
|
49
|
+
...state,
|
|
50
|
+
updated_at: options.attemptedAt,
|
|
51
|
+
last_upload_attempt_at: options.attemptedAt,
|
|
52
|
+
last_upload_success_at: options.attemptedAt,
|
|
53
|
+
last_upload_failure_reason: null,
|
|
54
|
+
pending_uploads: [],
|
|
55
|
+
};
|
|
56
|
+
await writeUploadSpoolState(paths, next);
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
export async function recordUploadFailure(paths, entry) {
|
|
60
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
61
|
+
const createdAt = entry.created_at ?? entry.last_attempt_at;
|
|
62
|
+
const spoolEntry = {
|
|
63
|
+
spool_id: `upload-${crypto.randomUUID()}`,
|
|
64
|
+
created_at: createdAt,
|
|
65
|
+
...entry,
|
|
66
|
+
};
|
|
67
|
+
const pending = [
|
|
68
|
+
spoolEntry,
|
|
69
|
+
...state.pending_uploads.filter((candidate) => candidate.work_context_id !== spoolEntry.work_context_id ||
|
|
70
|
+
candidate.ticket_id !== spoolEntry.ticket_id),
|
|
71
|
+
].slice(0, MAX_PENDING_UPLOADS);
|
|
72
|
+
await writeUploadSpoolState(paths, {
|
|
73
|
+
...state,
|
|
74
|
+
updated_at: entry.last_attempt_at,
|
|
75
|
+
last_upload_attempt_at: entry.last_attempt_at,
|
|
76
|
+
last_upload_failure_reason: entry.failure_reason,
|
|
77
|
+
pending_uploads: pending,
|
|
78
|
+
});
|
|
79
|
+
return spoolEntry;
|
|
80
|
+
}
|
|
81
|
+
function parseUploadSpoolState(value) {
|
|
82
|
+
if (!value || typeof value !== "object")
|
|
83
|
+
return emptyUploadSpoolState();
|
|
84
|
+
const record = value;
|
|
85
|
+
return {
|
|
86
|
+
schema_version: "cockpit-upload-spool.v1",
|
|
87
|
+
updated_at: optionalString(record["updated_at"]),
|
|
88
|
+
last_upload_attempt_at: optionalString(record["last_upload_attempt_at"]),
|
|
89
|
+
last_upload_success_at: optionalString(record["last_upload_success_at"]),
|
|
90
|
+
last_upload_failure_reason: optionalString(record["last_upload_failure_reason"]),
|
|
91
|
+
pending_uploads: Array.isArray(record["pending_uploads"])
|
|
92
|
+
? record["pending_uploads"]
|
|
93
|
+
.map(parseUploadSpoolEntry)
|
|
94
|
+
.filter((entry) => Boolean(entry))
|
|
95
|
+
: [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function parseUploadSpoolEntry(value) {
|
|
99
|
+
if (!value || typeof value !== "object")
|
|
100
|
+
return null;
|
|
101
|
+
const record = value;
|
|
102
|
+
const spoolId = optionalString(record["spool_id"]);
|
|
103
|
+
const createdAt = optionalString(record["created_at"]);
|
|
104
|
+
const lastAttemptAt = optionalString(record["last_attempt_at"]);
|
|
105
|
+
const dashboardUrl = optionalString(record["dashboard_url"]);
|
|
106
|
+
const failureReason = optionalString(record["failure_reason"]);
|
|
107
|
+
const retryCommand = optionalString(record["retry_command"]);
|
|
108
|
+
if (!spoolId || !createdAt || !lastAttemptAt || !dashboardUrl || !failureReason) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
spool_id: spoolId,
|
|
113
|
+
created_at: createdAt,
|
|
114
|
+
last_attempt_at: lastAttemptAt,
|
|
115
|
+
dashboard_url: dashboardUrl,
|
|
116
|
+
work_context_id: optionalString(record["work_context_id"]),
|
|
117
|
+
ticket_id: optionalString(record["ticket_id"]),
|
|
118
|
+
repo_label: optionalString(record["repo_label"]),
|
|
119
|
+
branch: optionalString(record["branch"]),
|
|
120
|
+
event_count: optionalNumber(record["event_count"]),
|
|
121
|
+
source_scan_count: optionalNumber(record["source_scan_count"]),
|
|
122
|
+
risk_flag_count: optionalNumber(record["risk_flag_count"]),
|
|
123
|
+
failure_reason: failureReason,
|
|
124
|
+
retry_command: retryCommand ?? "cockpit sync",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function writeUploadSpoolState(paths, state) {
|
|
128
|
+
const filePath = uploadSpoolStatePath(paths);
|
|
129
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
130
|
+
await fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, {
|
|
131
|
+
mode: 0o600,
|
|
132
|
+
});
|
|
133
|
+
if (process.platform !== "win32") {
|
|
134
|
+
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function uploadSpoolStatePath(paths) {
|
|
138
|
+
return path.join(paths.spool_dir, SPOOL_STATE_FILENAME);
|
|
139
|
+
}
|
|
140
|
+
function optionalString(value) {
|
|
141
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
142
|
+
}
|
|
143
|
+
function optionalNumber(value) {
|
|
144
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
145
|
+
}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { TelemetryIngestEnvelopeSchema, TelemetryIngestEventDtoSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContext, } from "./local-state.js";
|
|
4
|
+
import { runLocalSourceCollectors } from "./adapters/local-sources.js";
|
|
5
|
+
import { recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
|
|
6
|
+
export class LocalUploadBlockedError extends Error {
|
|
7
|
+
blocker;
|
|
8
|
+
retry_hint;
|
|
9
|
+
constructor(blocker, message, retryHint) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "LocalUploadBlockedError";
|
|
12
|
+
this.blocker = blocker;
|
|
13
|
+
this.retry_hint = retryHint;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function buildLocalAmbientEnvelope(options = {}) {
|
|
17
|
+
const now = options.now ?? new Date();
|
|
18
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
19
|
+
const config = await readLocalCollectorConfig(paths).catch(() => {
|
|
20
|
+
throw new LocalUploadBlockedError("not_installed", "Local collector config missing. Run `cockpit install` before `cockpit sync`.", "cockpit install --dashboard-url <dashboard-url>");
|
|
21
|
+
});
|
|
22
|
+
const sessionFile = await readLocalCollectorSessionFile(paths).catch(() => {
|
|
23
|
+
throw new LocalUploadBlockedError("unpaired", "No paired collector session found. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
|
|
24
|
+
});
|
|
25
|
+
const session = await readLocalSessionReference(paths);
|
|
26
|
+
if (session.session_state !== "valid") {
|
|
27
|
+
throw new LocalUploadBlockedError("unpaired", session.session_state === "expired"
|
|
28
|
+
? "Collector session expired. Run `cockpit login` or `cockpit pair` again before `cockpit sync`."
|
|
29
|
+
: "Collector is not paired. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
|
|
30
|
+
}
|
|
31
|
+
const activeContext = await readLocalWorkContext(paths).catch(() => {
|
|
32
|
+
throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --repo \"$PWD\"` before `cockpit sync`.", "cockpit start --repo \"$PWD\"");
|
|
33
|
+
});
|
|
34
|
+
const repoRoot = path.resolve(options.repoRoot ?? activeContext.repo);
|
|
35
|
+
const repoLabel = safeRepoLabel(repoRoot);
|
|
36
|
+
const uploadContext = makeUploadWorkContext({
|
|
37
|
+
activeContext,
|
|
38
|
+
session,
|
|
39
|
+
repoLabel,
|
|
40
|
+
now,
|
|
41
|
+
});
|
|
42
|
+
const sourceCollection = await runLocalSourceCollectors({
|
|
43
|
+
repoRoot,
|
|
44
|
+
branch: uploadContext.branch,
|
|
45
|
+
operatorId: session.operator_id,
|
|
46
|
+
sessionId: session.session_id,
|
|
47
|
+
workContextId: uploadContext.work_context_id,
|
|
48
|
+
activeWorkContext: activeContext,
|
|
49
|
+
now,
|
|
50
|
+
});
|
|
51
|
+
const binding = sourceCollection.binding;
|
|
52
|
+
const ticketBinding = selectedTicketBindingCandidate(binding);
|
|
53
|
+
const uploadWorkContext = {
|
|
54
|
+
...uploadContext,
|
|
55
|
+
active_ticket_id: binding.selected_ticket_id ?? undefined,
|
|
56
|
+
ticket_binding_candidates: ticketBinding ? [ticketBinding] : binding.candidates,
|
|
57
|
+
};
|
|
58
|
+
const safeRiskFlags = sourceCollection.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel));
|
|
59
|
+
const events = [
|
|
60
|
+
makeSourceScanCompletedEvent({
|
|
61
|
+
context: uploadWorkContext,
|
|
62
|
+
generatedAt: now.toISOString(),
|
|
63
|
+
binding,
|
|
64
|
+
ticketBinding,
|
|
65
|
+
scans: sourceCollection.scans,
|
|
66
|
+
gitChangedFileCount: sourceCollection.facts.git?.changed_file_count ?? 0,
|
|
67
|
+
gitAddedLines: sourceCollection.facts.git?.added_lines ?? 0,
|
|
68
|
+
gitDeletedLines: sourceCollection.facts.git?.deleted_lines ?? 0,
|
|
69
|
+
carOpenTicketCount: sourceCollection.facts.car?.open_ticket_count ?? 0,
|
|
70
|
+
riskFlags: safeRiskFlags,
|
|
71
|
+
}),
|
|
72
|
+
];
|
|
73
|
+
const envelope = TelemetryIngestEnvelopeSchema.parse({
|
|
74
|
+
envelope_version: "telemetry-ingest.v1",
|
|
75
|
+
generated_at: now.toISOString(),
|
|
76
|
+
collector_version: config.collector_version ?? LOCAL_COLLECTOR_VERSION,
|
|
77
|
+
session_reference: sanitizeSessionReference(session),
|
|
78
|
+
work_context: uploadWorkContext,
|
|
79
|
+
source_scan_results: sanitizeSourceScanResults(sourceCollection.scans, repoLabel),
|
|
80
|
+
events,
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
envelope,
|
|
84
|
+
dashboard_url: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
|
|
85
|
+
device_token: sessionFile.device_token,
|
|
86
|
+
ticket_id: binding.selected_ticket_id ?? null,
|
|
87
|
+
binding,
|
|
88
|
+
event_count: envelope.events.length,
|
|
89
|
+
source_scan_count: envelope.source_scan_results.length,
|
|
90
|
+
risk_flag_count: safeRiskFlags.length,
|
|
91
|
+
repo_label: repoLabel,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export async function syncLocalAmbientEnvelope(options = {}) {
|
|
95
|
+
const attemptedAt = (options.now ?? new Date()).toISOString();
|
|
96
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
97
|
+
let built;
|
|
98
|
+
try {
|
|
99
|
+
built = await buildLocalAmbientEnvelope(options);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof LocalUploadBlockedError) {
|
|
103
|
+
await recordUploadBlocked(paths, {
|
|
104
|
+
attemptedAt,
|
|
105
|
+
reason: error.message,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
111
|
+
if (!fetchImpl) {
|
|
112
|
+
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const response = await fetchImpl(`${built.dashboard_url}/api/ambient/ingest`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: {
|
|
118
|
+
"Authorization": `Bearer ${built.device_token}`,
|
|
119
|
+
"Content-Type": "application/json",
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify(built.envelope),
|
|
122
|
+
});
|
|
123
|
+
const responseBody = await readResponseJson(response);
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw new Error(responseErrorMessage(responseBody, `Ambient ingest failed with HTTP ${response.status}`));
|
|
126
|
+
}
|
|
127
|
+
await recordUploadSuccess(paths, { attemptedAt });
|
|
128
|
+
return {
|
|
129
|
+
status: "uploaded",
|
|
130
|
+
dashboard_url: built.dashboard_url,
|
|
131
|
+
ticket_id: built.ticket_id,
|
|
132
|
+
work_context_id: built.envelope.work_context.work_context_id,
|
|
133
|
+
event_count: built.event_count,
|
|
134
|
+
source_scan_count: built.source_scan_count,
|
|
135
|
+
risk_flag_count: built.risk_flag_count,
|
|
136
|
+
http_status: response.status,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const failureReason = error instanceof Error ? error.message : String(error);
|
|
141
|
+
const entry = await recordUploadFailure(paths, {
|
|
142
|
+
last_attempt_at: attemptedAt,
|
|
143
|
+
dashboard_url: built.dashboard_url,
|
|
144
|
+
work_context_id: built.envelope.work_context.work_context_id,
|
|
145
|
+
ticket_id: built.ticket_id,
|
|
146
|
+
repo_label: built.repo_label,
|
|
147
|
+
branch: built.envelope.work_context.branch,
|
|
148
|
+
event_count: built.event_count,
|
|
149
|
+
source_scan_count: built.source_scan_count,
|
|
150
|
+
risk_flag_count: built.risk_flag_count,
|
|
151
|
+
failure_reason: failureReason,
|
|
152
|
+
retry_command: "cockpit sync",
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
status: "spooled",
|
|
156
|
+
dashboard_url: built.dashboard_url,
|
|
157
|
+
ticket_id: built.ticket_id,
|
|
158
|
+
work_context_id: built.envelope.work_context.work_context_id,
|
|
159
|
+
event_count: built.event_count,
|
|
160
|
+
source_scan_count: built.source_scan_count,
|
|
161
|
+
risk_flag_count: built.risk_flag_count,
|
|
162
|
+
failure_reason: failureReason,
|
|
163
|
+
spool_entry_id: entry.spool_id,
|
|
164
|
+
retry_command: entry.retry_command,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function makeUploadWorkContext(options) {
|
|
169
|
+
const provenance = makeCollectorProvenance({
|
|
170
|
+
context: options.activeContext,
|
|
171
|
+
session: options.session,
|
|
172
|
+
repoLabel: options.repoLabel,
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
...options.activeContext,
|
|
176
|
+
repo: options.repoLabel,
|
|
177
|
+
operator_id: options.session.operator_id,
|
|
178
|
+
session_id: options.session.session_id,
|
|
179
|
+
updated_at: options.now.toISOString(),
|
|
180
|
+
provenance,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function makeSourceScanCompletedEvent(options) {
|
|
184
|
+
if (!options.context.provenance) {
|
|
185
|
+
throw new Error("Upload work context is missing provenance.");
|
|
186
|
+
}
|
|
187
|
+
return TelemetryIngestEventDtoSchema.parse({
|
|
188
|
+
event_id: `ambient-sync:${options.context.work_context_id}:${options.generatedAt}`,
|
|
189
|
+
event_type: "source_scan_completed",
|
|
190
|
+
occurred_at: options.generatedAt,
|
|
191
|
+
provenance: options.context.provenance,
|
|
192
|
+
privacy_classification: "metadata",
|
|
193
|
+
redaction: {
|
|
194
|
+
privacy_classification: "metadata",
|
|
195
|
+
redaction_status: "metadata_only",
|
|
196
|
+
redacted_fields: [
|
|
197
|
+
"prompt_body",
|
|
198
|
+
"response_body",
|
|
199
|
+
"diff_body",
|
|
200
|
+
"transcript_body",
|
|
201
|
+
"git.changed_paths",
|
|
202
|
+
"local_file_paths",
|
|
203
|
+
],
|
|
204
|
+
raw_evidence_pointer_ids: [],
|
|
205
|
+
redacted_summary: "Collector uploaded metadata-only local work, source scan, binding, and risk summaries.",
|
|
206
|
+
},
|
|
207
|
+
redacted_summary: "Collector uploaded metadata-only source scan, ticket binding, and risk summaries.",
|
|
208
|
+
metrics: {
|
|
209
|
+
git_changed_file_count: options.gitChangedFileCount,
|
|
210
|
+
git_added_lines: options.gitAddedLines,
|
|
211
|
+
git_deleted_lines: options.gitDeletedLines,
|
|
212
|
+
car_open_ticket_count: options.carOpenTicketCount,
|
|
213
|
+
source_scan_count: options.scans.length,
|
|
214
|
+
risk_flag_count: options.riskFlags.length,
|
|
215
|
+
},
|
|
216
|
+
attributes: {
|
|
217
|
+
repo_label: options.context.repo,
|
|
218
|
+
branch: options.context.branch,
|
|
219
|
+
ticket_binding_state: options.binding.state,
|
|
220
|
+
ticket_binding_source: options.binding.selected_source ?? "none",
|
|
221
|
+
ticket_id: options.binding.selected_ticket_id ?? "unbound",
|
|
222
|
+
source_adapters: options.scans.map((scan) => scan.adapter.adapter_name),
|
|
223
|
+
source_statuses: options.scans.map((scan) => `${scan.adapter.adapter_name}:${scan.status}`),
|
|
224
|
+
redaction_mode: "metadata_only",
|
|
225
|
+
raw_payload_included: false,
|
|
226
|
+
},
|
|
227
|
+
ticket_binding: options.ticketBinding ?? undefined,
|
|
228
|
+
risk_flags: options.riskFlags,
|
|
229
|
+
raw_evidence_pointers: [],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function makeCollectorProvenance(options) {
|
|
233
|
+
return {
|
|
234
|
+
capture_source: "collector_runtime",
|
|
235
|
+
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
236
|
+
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
237
|
+
repo: options.repoLabel,
|
|
238
|
+
branch: options.context.branch,
|
|
239
|
+
operator_id: options.session.operator_id,
|
|
240
|
+
session_id: options.session.session_id,
|
|
241
|
+
work_context_id: options.context.work_context_id,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function sanitizeSessionReference(session) {
|
|
245
|
+
return {
|
|
246
|
+
...session,
|
|
247
|
+
session_file_path: "local-session-file",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function sanitizeSourceScanResults(scans, repoLabel) {
|
|
251
|
+
return scans.map((scan) => ({
|
|
252
|
+
...scan,
|
|
253
|
+
diagnostic_labels: scan.diagnostic_labels.map(sanitizeDiagnosticLabel),
|
|
254
|
+
events: scan.events.map((event) => ({
|
|
255
|
+
...event,
|
|
256
|
+
raw_evidence_pointers: [],
|
|
257
|
+
redaction: {
|
|
258
|
+
...event.redaction,
|
|
259
|
+
raw_evidence_pointer_ids: [],
|
|
260
|
+
},
|
|
261
|
+
})),
|
|
262
|
+
risk_flags: scan.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel)),
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
function sanitizeDiagnosticLabel(label) {
|
|
266
|
+
if (label.includes("\n") || label.includes("/") || label.includes("\\")) {
|
|
267
|
+
const prefix = label.split(":", 1)[0]?.trim();
|
|
268
|
+
return prefix ? `${prefix}:redacted` : "diagnostic_redacted";
|
|
269
|
+
}
|
|
270
|
+
return label.length > 160 ? `${label.slice(0, 157)}...` : label;
|
|
271
|
+
}
|
|
272
|
+
function sanitizeRiskFlag(flag, repoLabel) {
|
|
273
|
+
return {
|
|
274
|
+
...flag,
|
|
275
|
+
provenance: {
|
|
276
|
+
...flag.provenance,
|
|
277
|
+
repo: repoLabel,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function selectedTicketBindingCandidate(binding) {
|
|
282
|
+
if (!binding.selected_ticket_id || !binding.selected_source)
|
|
283
|
+
return null;
|
|
284
|
+
return (binding.candidates.find((candidate) => candidate.ticket_id === binding.selected_ticket_id &&
|
|
285
|
+
candidate.binding_source === binding.selected_source) ?? {
|
|
286
|
+
ticket_id: binding.selected_ticket_id,
|
|
287
|
+
binding_source: binding.selected_source,
|
|
288
|
+
confidence: 1,
|
|
289
|
+
evidence_labels: [`bound_by:${binding.selected_source}`],
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
async function readResponseJson(response) {
|
|
293
|
+
const text = await response.text();
|
|
294
|
+
if (!text)
|
|
295
|
+
return {};
|
|
296
|
+
try {
|
|
297
|
+
return JSON.parse(text);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return { message: text };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function responseErrorMessage(value, fallback) {
|
|
304
|
+
if (value && typeof value === "object") {
|
|
305
|
+
const record = value;
|
|
306
|
+
const message = record["message"] ?? record["error"];
|
|
307
|
+
if (typeof message === "string" && message.trim())
|
|
308
|
+
return message;
|
|
309
|
+
}
|
|
310
|
+
return fallback;
|
|
311
|
+
}
|
|
312
|
+
function normalizeDashboardUrl(value) {
|
|
313
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
314
|
+
if (!normalized)
|
|
315
|
+
throw new Error("Dashboard URL cannot be empty.");
|
|
316
|
+
return normalized;
|
|
317
|
+
}
|
|
318
|
+
function safeRepoLabel(repoRoot) {
|
|
319
|
+
const basename = path.basename(repoRoot.replace(/[\\/]+$/, ""));
|
|
320
|
+
return basename || "repo";
|
|
321
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bli-cockpit/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cockpit": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"prebuild": "npm run build --workspace=@bli-cockpit/local-collector",
|
|
21
|
+
"build": "node ../../scripts/build-public-cli.mjs",
|
|
22
|
+
"pretypecheck": "npm run build",
|
|
23
|
+
"typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
|
|
24
|
+
"pretest": "npm run build",
|
|
25
|
+
"test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@bli-cockpit/telemetry-core": "0.1.0"
|
|
29
|
+
}
|
|
30
|
+
}
|