lula2 0.0.5 → 0.0.6
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 +291 -8
- package/dist/_app/env.js +1 -0
- package/dist/_app/immutable/assets/0.DtiRW3lO.css +1 -0
- package/dist/_app/immutable/assets/DynamicControlEditor.BkVTzFZ-.css +1 -0
- package/dist/_app/immutable/chunks/7x_q-1ab.js +1 -0
- package/dist/_app/immutable/chunks/B19gt6-g.js +2 -0
- package/dist/_app/immutable/chunks/BR-0Dorr.js +1 -0
- package/dist/_app/immutable/chunks/B_3ksxz5.js +2 -0
- package/dist/_app/immutable/chunks/Bg_R1qWi.js +3 -0
- package/dist/_app/immutable/chunks/D3aNP_lg.js +1 -0
- package/dist/_app/immutable/chunks/D4Q_ObIy.js +1 -0
- package/dist/_app/immutable/chunks/DsnmJJEf.js +1 -0
- package/dist/_app/immutable/chunks/XY2j_owG.js +66 -0
- package/dist/_app/immutable/chunks/rzN25oDf.js +1 -0
- package/dist/_app/immutable/entry/app.r0uOd9qg.js +2 -0
- package/dist/_app/immutable/entry/start.DvoqR0rc.js +1 -0
- package/dist/_app/immutable/nodes/0.Ct6FAss_.js +1 -0
- package/dist/_app/immutable/nodes/1.DLoKuy8Q.js +1 -0
- package/dist/_app/immutable/nodes/2.IRkwSmiB.js +1 -0
- package/dist/_app/immutable/nodes/3.BrTg-ZHv.js +1 -0
- package/dist/_app/immutable/nodes/4.Blq-4WQS.js +9 -0
- package/dist/_app/version.json +1 -0
- package/dist/cli/commands/crawl.js +128 -0
- package/dist/cli/commands/ui.js +2769 -0
- package/dist/cli/commands/version.js +30 -0
- package/dist/cli/server/index.js +2713 -0
- package/dist/cli/server/server.js +2702 -0
- package/dist/cli/server/serverState.js +1199 -0
- package/dist/cli/server/spreadsheetRoutes.js +788 -0
- package/dist/cli/server/types.js +0 -0
- package/dist/cli/server/websocketServer.js +2625 -0
- package/dist/cli/utils/debug.js +24 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +38 -0
- package/dist/index.js +2924 -37
- package/dist/lula.png +0 -0
- package/dist/lula2 +2 -0
- package/package.json +120 -72
- package/src/app.css +192 -0
- package/src/app.d.ts +13 -0
- package/src/app.html +13 -0
- package/src/lib/actions/fadeWhenScrollable.ts +39 -0
- package/src/lib/actions/modal.ts +230 -0
- package/src/lib/actions/tooltip.ts +82 -0
- package/src/lib/components/control-sets/ControlSetInfo.svelte +20 -0
- package/src/lib/components/control-sets/ControlSetSelector.svelte +46 -0
- package/src/lib/components/control-sets/index.ts +5 -0
- package/src/lib/components/controls/ControlDetailsPanel.svelte +235 -0
- package/src/lib/components/controls/ControlsList.svelte +608 -0
- package/src/lib/components/controls/DynamicControlEditor.svelte +298 -0
- package/src/lib/components/controls/MappingCard.svelte +105 -0
- package/src/lib/components/controls/MappingForm.svelte +188 -0
- package/src/lib/components/controls/index.ts +9 -0
- package/src/lib/components/controls/renderers/EditableFieldRenderer.svelte +103 -0
- package/src/lib/components/controls/renderers/FieldRenderer.svelte +49 -0
- package/src/lib/components/controls/renderers/index.ts +5 -0
- package/src/lib/components/controls/tabs/CustomFieldsTab.svelte +130 -0
- package/src/lib/components/controls/tabs/ImplementationTab.svelte +127 -0
- package/src/lib/components/controls/tabs/MappingsTab.svelte +182 -0
- package/src/lib/components/controls/tabs/OverviewTab.svelte +151 -0
- package/src/lib/components/controls/tabs/TimelineTab.svelte +41 -0
- package/src/lib/components/controls/tabs/index.ts +8 -0
- package/src/lib/components/controls/utils/ProcessedTextRenderer.svelte +63 -0
- package/src/lib/components/controls/utils/textProcessor.ts +164 -0
- package/src/lib/components/forms/DynamicControlForm.svelte +340 -0
- package/src/lib/components/forms/DynamicField.svelte +494 -0
- package/src/lib/components/forms/FormField.svelte +107 -0
- package/src/lib/components/forms/index.ts +6 -0
- package/src/lib/components/setup/ExistingControlSets.svelte +284 -0
- package/src/lib/components/setup/SpreadsheetImport.svelte +968 -0
- package/src/lib/components/setup/index.ts +5 -0
- package/src/lib/components/ui/Dropdown.svelte +107 -0
- package/src/lib/components/ui/EmptyState.svelte +80 -0
- package/src/lib/components/ui/FeatureToggle.svelte +50 -0
- package/src/lib/components/ui/SearchBar.svelte +73 -0
- package/src/lib/components/ui/StatusBadge.svelte +79 -0
- package/src/lib/components/ui/TabNavigation.svelte +48 -0
- package/src/lib/components/ui/Tooltip.svelte +120 -0
- package/src/lib/components/ui/index.ts +10 -0
- package/src/lib/components/version-control/DiffViewer.svelte +292 -0
- package/src/lib/components/version-control/TimelineItem.svelte +107 -0
- package/src/lib/components/version-control/YamlDiffViewer.svelte +428 -0
- package/src/lib/components/version-control/index.ts +6 -0
- package/src/lib/form-types.ts +57 -0
- package/src/lib/formatUtils.ts +17 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/types.ts +180 -0
- package/src/lib/websocket.ts +359 -0
- package/src/routes/+layout.svelte +236 -0
- package/src/routes/+page.svelte +38 -0
- package/src/routes/control/[id]/+page.svelte +112 -0
- package/src/routes/setup/+page.svelte +241 -0
- package/src/stores/compliance.ts +95 -0
- package/src/styles/highlightjs.css +20 -0
- package/src/styles/modal.css +58 -0
- package/src/styles/tables.css +111 -0
- package/src/styles/tooltip.css +65 -0
- package/dist/controls/index.d.ts +0 -18
- package/dist/controls/index.d.ts.map +0 -1
- package/dist/controls/index.js +0 -18
- package/dist/crawl.d.ts +0 -62
- package/dist/crawl.d.ts.map +0 -1
- package/dist/crawl.js +0 -172
- package/dist/index.d.ts +0 -8
- package/dist/index.d.ts.map +0 -1
- package/src/controls/index.ts +0 -19
- package/src/crawl.ts +0 -227
- package/src/index.ts +0 -46
|
@@ -0,0 +1,2769 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// cli/utils/debug.ts
|
|
12
|
+
var debug_exports = {};
|
|
13
|
+
__export(debug_exports, {
|
|
14
|
+
debug: () => debug,
|
|
15
|
+
debugError: () => debugError,
|
|
16
|
+
isDebugEnabled: () => isDebugEnabled,
|
|
17
|
+
setDebugMode: () => setDebugMode
|
|
18
|
+
});
|
|
19
|
+
function setDebugMode(enabled) {
|
|
20
|
+
debugEnabled = enabled || process.env.DEBUG === "true" || process.env.DEBUG === "1";
|
|
21
|
+
}
|
|
22
|
+
function debug(...args) {
|
|
23
|
+
if (debugEnabled) {
|
|
24
|
+
console.log("[DEBUG]", ...args);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function debugError(...args) {
|
|
28
|
+
if (debugEnabled) {
|
|
29
|
+
console.error("[DEBUG ERROR]", ...args);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function isDebugEnabled() {
|
|
33
|
+
return debugEnabled;
|
|
34
|
+
}
|
|
35
|
+
var debugEnabled;
|
|
36
|
+
var init_debug = __esm({
|
|
37
|
+
"cli/utils/debug.ts"() {
|
|
38
|
+
"use strict";
|
|
39
|
+
debugEnabled = false;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// cli/server/infrastructure/controlHelpers.ts
|
|
44
|
+
import { existsSync, readFileSync } from "fs";
|
|
45
|
+
import { join } from "path";
|
|
46
|
+
import * as yaml from "js-yaml";
|
|
47
|
+
function loadControlSetMetadata(baseDir) {
|
|
48
|
+
const path = join(baseDir, "lula.yaml");
|
|
49
|
+
if (metadataPath !== path || !controlSetMetadata) {
|
|
50
|
+
if (existsSync(path)) {
|
|
51
|
+
try {
|
|
52
|
+
const content = readFileSync(path, "utf8");
|
|
53
|
+
controlSetMetadata = yaml.load(content);
|
|
54
|
+
metadataPath = path;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(`Failed to parse lula.yaml at ${path}:`, error);
|
|
57
|
+
controlSetMetadata = {};
|
|
58
|
+
metadataPath = path;
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
controlSetMetadata = {};
|
|
62
|
+
metadataPath = path;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return controlSetMetadata;
|
|
66
|
+
}
|
|
67
|
+
function getControlIdField(baseDir) {
|
|
68
|
+
if (baseDir) {
|
|
69
|
+
loadControlSetMetadata(baseDir);
|
|
70
|
+
}
|
|
71
|
+
return controlSetMetadata?.control_id_field || "id";
|
|
72
|
+
}
|
|
73
|
+
function getControlId(control, baseDir) {
|
|
74
|
+
if (!control || typeof control !== "object") {
|
|
75
|
+
throw new Error("Invalid control object provided");
|
|
76
|
+
}
|
|
77
|
+
const idField = getControlIdField(baseDir);
|
|
78
|
+
const configuredId = control[idField];
|
|
79
|
+
if (configuredId && typeof configuredId === "string") {
|
|
80
|
+
return configuredId;
|
|
81
|
+
}
|
|
82
|
+
if (idField !== "id" && control.id) {
|
|
83
|
+
return control.id;
|
|
84
|
+
}
|
|
85
|
+
const possibleIdFields = [
|
|
86
|
+
"ap-acronym",
|
|
87
|
+
"control-id",
|
|
88
|
+
"control_id",
|
|
89
|
+
"controlId"
|
|
90
|
+
];
|
|
91
|
+
for (const field of possibleIdFields) {
|
|
92
|
+
const value = control[field];
|
|
93
|
+
if (value && typeof value === "string") {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const availableFields = Object.keys(control).filter(
|
|
98
|
+
(key) => control[key] !== void 0
|
|
99
|
+
);
|
|
100
|
+
throw new Error(
|
|
101
|
+
`No control ID found in control object. Available fields: ${availableFields.join(", ")}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
var controlSetMetadata, metadataPath;
|
|
105
|
+
var init_controlHelpers = __esm({
|
|
106
|
+
"cli/server/infrastructure/controlHelpers.ts"() {
|
|
107
|
+
"use strict";
|
|
108
|
+
controlSetMetadata = null;
|
|
109
|
+
metadataPath = null;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// cli/server/infrastructure/fileStore.ts
|
|
114
|
+
var fileStore_exports = {};
|
|
115
|
+
__export(fileStore_exports, {
|
|
116
|
+
FileStore: () => FileStore
|
|
117
|
+
});
|
|
118
|
+
import {
|
|
119
|
+
existsSync as existsSync2,
|
|
120
|
+
promises as fs,
|
|
121
|
+
mkdirSync,
|
|
122
|
+
readdirSync,
|
|
123
|
+
readFileSync as readFileSync2,
|
|
124
|
+
statSync,
|
|
125
|
+
unlinkSync,
|
|
126
|
+
writeFileSync
|
|
127
|
+
} from "fs";
|
|
128
|
+
import * as yaml2 from "js-yaml";
|
|
129
|
+
import { join as join2 } from "path";
|
|
130
|
+
var FileStore;
|
|
131
|
+
var init_fileStore = __esm({
|
|
132
|
+
"cli/server/infrastructure/fileStore.ts"() {
|
|
133
|
+
"use strict";
|
|
134
|
+
init_controlHelpers();
|
|
135
|
+
FileStore = class {
|
|
136
|
+
baseDir;
|
|
137
|
+
controlsDir;
|
|
138
|
+
mappingsDir;
|
|
139
|
+
// Simple cache - just control ID to filename mapping
|
|
140
|
+
controlMetadataCache = /* @__PURE__ */ new Map();
|
|
141
|
+
constructor(options) {
|
|
142
|
+
this.baseDir = options.baseDir;
|
|
143
|
+
this.controlsDir = join2(this.baseDir, "controls");
|
|
144
|
+
this.mappingsDir = join2(this.baseDir, "mappings");
|
|
145
|
+
if (existsSync2(this.controlsDir)) {
|
|
146
|
+
this.refreshControlsCache();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Get simple filename from control ID
|
|
151
|
+
*/
|
|
152
|
+
getControlFilename(controlId) {
|
|
153
|
+
const sanitized = controlId.replace(/[^\w\-]/g, "_");
|
|
154
|
+
return `${sanitized}.yaml`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Get family name from control ID
|
|
158
|
+
*/
|
|
159
|
+
getControlFamily(controlId) {
|
|
160
|
+
return controlId.split("-")[0];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Ensure required directories exist
|
|
164
|
+
*/
|
|
165
|
+
ensureDirectories() {
|
|
166
|
+
if (!this.baseDir || this.baseDir === "." || this.baseDir === process.cwd()) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const lulaConfigPath = join2(this.baseDir, "lula.yaml");
|
|
170
|
+
if (!existsSync2(lulaConfigPath)) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!existsSync2(this.controlsDir)) {
|
|
174
|
+
mkdirSync(this.controlsDir, { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
if (!existsSync2(this.mappingsDir)) {
|
|
177
|
+
mkdirSync(this.mappingsDir, { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Get control metadata by ID
|
|
182
|
+
*/
|
|
183
|
+
getControlMetadata(controlId) {
|
|
184
|
+
return this.controlMetadataCache.get(controlId);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Load a control by ID
|
|
188
|
+
*/
|
|
189
|
+
async loadControl(controlId) {
|
|
190
|
+
const sanitizedId = controlId.replace(/[^\w\-]/g, "_");
|
|
191
|
+
const possibleFlatPaths = [
|
|
192
|
+
join2(this.controlsDir, `${controlId}.yaml`),
|
|
193
|
+
join2(this.controlsDir, `${sanitizedId}.yaml`)
|
|
194
|
+
];
|
|
195
|
+
for (const flatFilePath of possibleFlatPaths) {
|
|
196
|
+
if (existsSync2(flatFilePath)) {
|
|
197
|
+
try {
|
|
198
|
+
const content = readFileSync2(flatFilePath, "utf8");
|
|
199
|
+
const parsed = yaml2.load(content);
|
|
200
|
+
if (!parsed.id) {
|
|
201
|
+
parsed.id = controlId.replace(/_(\d)/g, ".$1");
|
|
202
|
+
}
|
|
203
|
+
return parsed;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.error(`Failed to load control ${controlId} from flat structure:`, error);
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Failed to load control ${controlId}: ${error instanceof Error ? error.message : String(error)}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const family = this.getControlFamily(controlId);
|
|
213
|
+
const familyDir = join2(this.controlsDir, family);
|
|
214
|
+
const possibleFamilyPaths = [
|
|
215
|
+
join2(familyDir, `${controlId}.yaml`),
|
|
216
|
+
join2(familyDir, `${sanitizedId}.yaml`)
|
|
217
|
+
];
|
|
218
|
+
for (const filePath of possibleFamilyPaths) {
|
|
219
|
+
if (existsSync2(filePath)) {
|
|
220
|
+
try {
|
|
221
|
+
const content = readFileSync2(filePath, "utf8");
|
|
222
|
+
const parsed = yaml2.load(content);
|
|
223
|
+
if (!parsed.id) {
|
|
224
|
+
parsed.id = controlId.replace(/_(\d)/g, ".$1");
|
|
225
|
+
}
|
|
226
|
+
return parsed;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
console.error(`Failed to load control ${controlId}:`, error);
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Failed to load control ${controlId}: ${error instanceof Error ? error.message : String(error)}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Save a control
|
|
239
|
+
*/
|
|
240
|
+
async saveControl(control) {
|
|
241
|
+
this.ensureDirectories();
|
|
242
|
+
const controlId = getControlId(control, this.baseDir);
|
|
243
|
+
const family = this.getControlFamily(controlId);
|
|
244
|
+
const filename = this.getControlFilename(controlId);
|
|
245
|
+
const familyDir = join2(this.controlsDir, family);
|
|
246
|
+
const filePath = join2(familyDir, filename);
|
|
247
|
+
if (!existsSync2(familyDir)) {
|
|
248
|
+
mkdirSync(familyDir, { recursive: true });
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
let yamlContent;
|
|
252
|
+
if (existsSync2(filePath)) {
|
|
253
|
+
const existingContent = readFileSync2(filePath, "utf8");
|
|
254
|
+
const existingControl = yaml2.load(existingContent);
|
|
255
|
+
const fieldsToUpdate = {};
|
|
256
|
+
for (const key in control) {
|
|
257
|
+
if (key === "timeline" || key === "unifiedHistory" || key === "_metadata" || key === "id") {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (JSON.stringify(control[key]) !== JSON.stringify(existingControl[key])) {
|
|
261
|
+
fieldsToUpdate[key] = control[key];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (Object.keys(fieldsToUpdate).length > 0) {
|
|
265
|
+
const updatedControl = { ...existingControl, ...fieldsToUpdate };
|
|
266
|
+
yamlContent = yaml2.dump(updatedControl, {
|
|
267
|
+
indent: 2,
|
|
268
|
+
lineWidth: 80,
|
|
269
|
+
noRefs: true,
|
|
270
|
+
sortKeys: false
|
|
271
|
+
});
|
|
272
|
+
} else {
|
|
273
|
+
yamlContent = existingContent;
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
const controlToSave = { ...control };
|
|
277
|
+
delete controlToSave.timeline;
|
|
278
|
+
delete controlToSave.unifiedHistory;
|
|
279
|
+
delete controlToSave._metadata;
|
|
280
|
+
delete controlToSave.id;
|
|
281
|
+
yamlContent = yaml2.dump(controlToSave, {
|
|
282
|
+
indent: 2,
|
|
283
|
+
lineWidth: 80,
|
|
284
|
+
noRefs: true,
|
|
285
|
+
sortKeys: false
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
writeFileSync(filePath, yamlContent, "utf8");
|
|
289
|
+
this.controlMetadataCache.set(controlId, {
|
|
290
|
+
controlId,
|
|
291
|
+
filename,
|
|
292
|
+
family
|
|
293
|
+
});
|
|
294
|
+
} catch (error) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Failed to save control ${controlId}: ${error instanceof Error ? error.message : String(error)}`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Delete a control
|
|
302
|
+
*/
|
|
303
|
+
async deleteControl(controlId) {
|
|
304
|
+
const family = this.getControlFamily(controlId);
|
|
305
|
+
const filename = this.getControlFilename(controlId);
|
|
306
|
+
const familyDir = join2(this.controlsDir, family);
|
|
307
|
+
const filePath = join2(familyDir, filename);
|
|
308
|
+
if (existsSync2(filePath)) {
|
|
309
|
+
unlinkSync(filePath);
|
|
310
|
+
this.controlMetadataCache.delete(controlId);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Load all controls
|
|
315
|
+
*/
|
|
316
|
+
async loadAllControls() {
|
|
317
|
+
if (!existsSync2(this.controlsDir)) {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
const entries = readdirSync(this.controlsDir);
|
|
321
|
+
const yamlFiles = entries.filter((file) => file.endsWith(".yaml"));
|
|
322
|
+
if (yamlFiles.length > 0) {
|
|
323
|
+
const promises = yamlFiles.map(async (file) => {
|
|
324
|
+
try {
|
|
325
|
+
const filePath = join2(this.controlsDir, file);
|
|
326
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
327
|
+
const parsed = yaml2.load(content);
|
|
328
|
+
if (!parsed.id) {
|
|
329
|
+
parsed.id = getControlId(parsed, this.baseDir);
|
|
330
|
+
}
|
|
331
|
+
return parsed;
|
|
332
|
+
} catch (error) {
|
|
333
|
+
console.error(`Failed to load control from file ${file}:`, error);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
const results2 = await Promise.all(promises);
|
|
338
|
+
return results2.filter((c) => c !== null);
|
|
339
|
+
}
|
|
340
|
+
const families = entries.filter((name) => {
|
|
341
|
+
const familyPath = join2(this.controlsDir, name);
|
|
342
|
+
return statSync(familyPath).isDirectory();
|
|
343
|
+
});
|
|
344
|
+
const allPromises = [];
|
|
345
|
+
for (const family of families) {
|
|
346
|
+
const familyPath = join2(this.controlsDir, family);
|
|
347
|
+
const files = readdirSync(familyPath).filter((file) => file.endsWith(".yaml"));
|
|
348
|
+
const familyPromises = files.map(async (file) => {
|
|
349
|
+
try {
|
|
350
|
+
const controlId = file.replace(".yaml", "");
|
|
351
|
+
const control = await this.loadControl(controlId);
|
|
352
|
+
return control;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
console.error(`Failed to load control from file ${file}:`, error);
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
allPromises.push(...familyPromises);
|
|
359
|
+
}
|
|
360
|
+
const results = await Promise.all(allPromises);
|
|
361
|
+
return results.filter((c) => c !== null);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Load mappings from mappings directory
|
|
365
|
+
*/
|
|
366
|
+
async loadMappings() {
|
|
367
|
+
const mappings = [];
|
|
368
|
+
if (!existsSync2(this.mappingsDir)) {
|
|
369
|
+
return mappings;
|
|
370
|
+
}
|
|
371
|
+
const families = readdirSync(this.mappingsDir).filter((name) => {
|
|
372
|
+
const familyPath = join2(this.mappingsDir, name);
|
|
373
|
+
return statSync(familyPath).isDirectory();
|
|
374
|
+
});
|
|
375
|
+
for (const family of families) {
|
|
376
|
+
const familyPath = join2(this.mappingsDir, family);
|
|
377
|
+
const files = readdirSync(familyPath).filter((file) => file.endsWith("-mappings.yaml"));
|
|
378
|
+
for (const file of files) {
|
|
379
|
+
const mappingFile = join2(familyPath, file);
|
|
380
|
+
try {
|
|
381
|
+
const content = readFileSync2(mappingFile, "utf8");
|
|
382
|
+
const parsed = yaml2.load(content);
|
|
383
|
+
if (Array.isArray(parsed)) {
|
|
384
|
+
mappings.push(...parsed);
|
|
385
|
+
}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
console.error(`Failed to load mappings from ${family}/${file}:`, error);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return mappings;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Save a single mapping
|
|
395
|
+
*/
|
|
396
|
+
async saveMapping(mapping) {
|
|
397
|
+
this.ensureDirectories();
|
|
398
|
+
const controlId = mapping.control_id;
|
|
399
|
+
const family = this.getControlFamily(controlId);
|
|
400
|
+
const familyDir = join2(this.mappingsDir, family);
|
|
401
|
+
const mappingFile = join2(familyDir, `${controlId}-mappings.yaml`);
|
|
402
|
+
if (!existsSync2(familyDir)) {
|
|
403
|
+
mkdirSync(familyDir, { recursive: true });
|
|
404
|
+
}
|
|
405
|
+
let existingMappings = [];
|
|
406
|
+
if (existsSync2(mappingFile)) {
|
|
407
|
+
try {
|
|
408
|
+
const content = readFileSync2(mappingFile, "utf8");
|
|
409
|
+
existingMappings = yaml2.load(content) || [];
|
|
410
|
+
} catch (error) {
|
|
411
|
+
console.error(`Failed to parse existing mappings file: ${mappingFile}`, error);
|
|
412
|
+
existingMappings = [];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const existingIndex = existingMappings.findIndex((m) => m.uuid === mapping.uuid);
|
|
416
|
+
if (existingIndex >= 0) {
|
|
417
|
+
existingMappings[existingIndex] = mapping;
|
|
418
|
+
} else {
|
|
419
|
+
existingMappings.push(mapping);
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
const yamlContent = yaml2.dump(existingMappings, {
|
|
423
|
+
indent: 2,
|
|
424
|
+
lineWidth: -1,
|
|
425
|
+
noRefs: true
|
|
426
|
+
});
|
|
427
|
+
writeFileSync(mappingFile, yamlContent, "utf8");
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw new Error(
|
|
430
|
+
`Failed to save mapping for control ${controlId}: ${error instanceof Error ? error.message : String(error)}`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Delete a single mapping
|
|
436
|
+
*/
|
|
437
|
+
async deleteMapping(uuid) {
|
|
438
|
+
const mappingFiles = this.getAllMappingFiles();
|
|
439
|
+
for (const file of mappingFiles) {
|
|
440
|
+
try {
|
|
441
|
+
const content = readFileSync2(file, "utf8");
|
|
442
|
+
let mappings = yaml2.load(content) || [];
|
|
443
|
+
const originalLength = mappings.length;
|
|
444
|
+
mappings = mappings.filter((m) => m.uuid !== uuid);
|
|
445
|
+
if (mappings.length < originalLength) {
|
|
446
|
+
if (mappings.length === 0) {
|
|
447
|
+
unlinkSync(file);
|
|
448
|
+
} else {
|
|
449
|
+
const yamlContent = yaml2.dump(mappings, {
|
|
450
|
+
indent: 2,
|
|
451
|
+
lineWidth: -1,
|
|
452
|
+
noRefs: true
|
|
453
|
+
});
|
|
454
|
+
writeFileSync(file, yamlContent, "utf8");
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
} catch (error) {
|
|
459
|
+
console.error(`Error processing mapping file ${file}:`, error);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Get all mapping files
|
|
465
|
+
*/
|
|
466
|
+
getAllMappingFiles() {
|
|
467
|
+
const files = [];
|
|
468
|
+
if (!existsSync2(this.mappingsDir)) {
|
|
469
|
+
return files;
|
|
470
|
+
}
|
|
471
|
+
const flatFiles = readdirSync(this.mappingsDir).filter((file) => file.endsWith("-mappings.yaml")).map((file) => join2(this.mappingsDir, file));
|
|
472
|
+
files.push(...flatFiles);
|
|
473
|
+
const entries = readdirSync(this.mappingsDir, { withFileTypes: true });
|
|
474
|
+
for (const entry of entries) {
|
|
475
|
+
if (entry.isDirectory()) {
|
|
476
|
+
const familyDir = join2(this.mappingsDir, entry.name);
|
|
477
|
+
const familyFiles = readdirSync(familyDir).filter((file) => file.endsWith("-mappings.yaml")).map((file) => join2(familyDir, file));
|
|
478
|
+
files.push(...familyFiles);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return files;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Save mappings to per-control files
|
|
485
|
+
*/
|
|
486
|
+
async saveMappings(mappings) {
|
|
487
|
+
this.ensureDirectories();
|
|
488
|
+
const mappingsByControl = /* @__PURE__ */ new Map();
|
|
489
|
+
for (const mapping of mappings) {
|
|
490
|
+
const controlId = mapping.control_id;
|
|
491
|
+
if (!mappingsByControl.has(controlId)) {
|
|
492
|
+
mappingsByControl.set(controlId, []);
|
|
493
|
+
}
|
|
494
|
+
mappingsByControl.get(controlId).push(mapping);
|
|
495
|
+
}
|
|
496
|
+
for (const [controlId, controlMappings] of mappingsByControl) {
|
|
497
|
+
const family = this.getControlFamily(controlId);
|
|
498
|
+
const familyDir = join2(this.mappingsDir, family);
|
|
499
|
+
const mappingFile = join2(familyDir, `${controlId}-mappings.yaml`);
|
|
500
|
+
if (!existsSync2(familyDir)) {
|
|
501
|
+
mkdirSync(familyDir, { recursive: true });
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
const yamlContent = yaml2.dump(controlMappings, {
|
|
505
|
+
indent: 2,
|
|
506
|
+
lineWidth: -1,
|
|
507
|
+
noRefs: true
|
|
508
|
+
});
|
|
509
|
+
writeFileSync(mappingFile, yamlContent, "utf8");
|
|
510
|
+
} catch (error) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`Failed to save mappings for control ${controlId}: ${error instanceof Error ? error.message : String(error)}`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Refresh controls cache
|
|
519
|
+
*/
|
|
520
|
+
refreshControlsCache() {
|
|
521
|
+
this.controlMetadataCache.clear();
|
|
522
|
+
if (!existsSync2(this.controlsDir)) {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const entries = readdirSync(this.controlsDir);
|
|
526
|
+
const yamlFiles = entries.filter((file) => file.endsWith(".yaml"));
|
|
527
|
+
if (yamlFiles.length > 0) {
|
|
528
|
+
for (const filename of yamlFiles) {
|
|
529
|
+
const controlId = filename.replace(".yaml", "");
|
|
530
|
+
const family = this.getControlFamily(controlId);
|
|
531
|
+
this.controlMetadataCache.set(controlId, {
|
|
532
|
+
controlId,
|
|
533
|
+
filename,
|
|
534
|
+
family
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const families = entries.filter((name) => {
|
|
540
|
+
const familyPath = join2(this.controlsDir, name);
|
|
541
|
+
return statSync(familyPath).isDirectory();
|
|
542
|
+
});
|
|
543
|
+
for (const family of families) {
|
|
544
|
+
const familyPath = join2(this.controlsDir, family);
|
|
545
|
+
const files = readdirSync(familyPath).filter((file) => file.endsWith(".yaml"));
|
|
546
|
+
for (const filename of files) {
|
|
547
|
+
try {
|
|
548
|
+
const filePath = join2(familyPath, filename);
|
|
549
|
+
const content = readFileSync2(filePath, "utf8");
|
|
550
|
+
const parsed = yaml2.load(content);
|
|
551
|
+
const controlId = getControlId(parsed, this.baseDir);
|
|
552
|
+
this.controlMetadataCache.set(controlId, {
|
|
553
|
+
controlId,
|
|
554
|
+
filename,
|
|
555
|
+
family
|
|
556
|
+
});
|
|
557
|
+
} catch (error) {
|
|
558
|
+
console.error(`Failed to read control metadata from ${family}/${filename}:`, error);
|
|
559
|
+
const controlId = filename.replace(".yaml", "").replace(/_/g, "/");
|
|
560
|
+
this.controlMetadataCache.set(controlId, {
|
|
561
|
+
controlId,
|
|
562
|
+
filename,
|
|
563
|
+
family
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Get file store statistics
|
|
571
|
+
*/
|
|
572
|
+
getStats() {
|
|
573
|
+
const controlCount = this.controlMetadataCache.size;
|
|
574
|
+
let mappingCount = 0;
|
|
575
|
+
if (existsSync2(this.mappingsDir)) {
|
|
576
|
+
const families = readdirSync(this.mappingsDir).filter((name) => {
|
|
577
|
+
const familyPath = join2(this.mappingsDir, name);
|
|
578
|
+
return statSync(familyPath).isDirectory();
|
|
579
|
+
});
|
|
580
|
+
mappingCount = families.length;
|
|
581
|
+
}
|
|
582
|
+
const familyCount = new Set(
|
|
583
|
+
Array.from(this.controlMetadataCache.values()).map((meta) => meta.family)
|
|
584
|
+
).size;
|
|
585
|
+
return {
|
|
586
|
+
controlCount,
|
|
587
|
+
mappingCount,
|
|
588
|
+
familyCount
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Clear all caches
|
|
593
|
+
*/
|
|
594
|
+
clearCache() {
|
|
595
|
+
this.controlMetadataCache.clear();
|
|
596
|
+
this.refreshControlsCache();
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// cli/server/infrastructure/yamlDiff.ts
|
|
603
|
+
import * as yaml3 from "js-yaml";
|
|
604
|
+
function createYamlDiff(oldYaml, newYaml, isArrayFile = false) {
|
|
605
|
+
try {
|
|
606
|
+
const emptyDefault = isArrayFile ? "[]" : "{}";
|
|
607
|
+
const oldData = yaml3.load(oldYaml || emptyDefault);
|
|
608
|
+
const newData = yaml3.load(newYaml || emptyDefault);
|
|
609
|
+
const changes = compareValues(oldData, newData, "");
|
|
610
|
+
return {
|
|
611
|
+
hasChanges: changes.length > 0,
|
|
612
|
+
changes,
|
|
613
|
+
summary: generateSummary(changes)
|
|
614
|
+
};
|
|
615
|
+
} catch (error) {
|
|
616
|
+
console.error("Error parsing YAML for diff:", error);
|
|
617
|
+
return {
|
|
618
|
+
hasChanges: false,
|
|
619
|
+
changes: [],
|
|
620
|
+
summary: "Error parsing YAML content"
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function compareValues(oldValue, newValue, basePath) {
|
|
625
|
+
const changes = [];
|
|
626
|
+
if (oldValue === null || oldValue === void 0) {
|
|
627
|
+
if (newValue !== null && newValue !== void 0) {
|
|
628
|
+
changes.push({
|
|
629
|
+
type: "added",
|
|
630
|
+
path: basePath || "root",
|
|
631
|
+
newValue,
|
|
632
|
+
description: `Added ${basePath || "content"}`
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
return changes;
|
|
636
|
+
}
|
|
637
|
+
if (newValue === null || newValue === void 0) {
|
|
638
|
+
changes.push({
|
|
639
|
+
type: "removed",
|
|
640
|
+
path: basePath || "root",
|
|
641
|
+
oldValue,
|
|
642
|
+
description: `Removed ${basePath || "content"}`
|
|
643
|
+
});
|
|
644
|
+
return changes;
|
|
645
|
+
}
|
|
646
|
+
if (Array.isArray(oldValue) || Array.isArray(newValue)) {
|
|
647
|
+
return compareArrays(oldValue, newValue, basePath);
|
|
648
|
+
}
|
|
649
|
+
if (typeof oldValue === "object" && typeof newValue === "object") {
|
|
650
|
+
return compareObjects(oldValue, newValue, basePath);
|
|
651
|
+
}
|
|
652
|
+
if (oldValue !== newValue) {
|
|
653
|
+
changes.push({
|
|
654
|
+
type: "modified",
|
|
655
|
+
path: basePath || "root",
|
|
656
|
+
oldValue,
|
|
657
|
+
newValue,
|
|
658
|
+
description: `Changed ${basePath || "value"}`
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
return changes;
|
|
662
|
+
}
|
|
663
|
+
function compareObjects(oldObj, newObj, basePath) {
|
|
664
|
+
const changes = [];
|
|
665
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
|
|
666
|
+
for (const key of allKeys) {
|
|
667
|
+
const currentPath = basePath ? `${basePath}.${key}` : key;
|
|
668
|
+
const oldValue = oldObj[key];
|
|
669
|
+
const newValue = newObj[key];
|
|
670
|
+
if (!(key in oldObj)) {
|
|
671
|
+
changes.push({
|
|
672
|
+
type: "added",
|
|
673
|
+
path: currentPath,
|
|
674
|
+
newValue,
|
|
675
|
+
description: `Added ${key}`
|
|
676
|
+
});
|
|
677
|
+
} else if (!(key in newObj)) {
|
|
678
|
+
changes.push({
|
|
679
|
+
type: "removed",
|
|
680
|
+
path: currentPath,
|
|
681
|
+
oldValue,
|
|
682
|
+
description: `Removed ${key}`
|
|
683
|
+
});
|
|
684
|
+
} else if (!deepEqual(oldValue, newValue)) {
|
|
685
|
+
if (typeof oldValue === "object" && typeof newValue === "object") {
|
|
686
|
+
changes.push(...compareValues(oldValue, newValue, currentPath));
|
|
687
|
+
} else {
|
|
688
|
+
changes.push({
|
|
689
|
+
type: "modified",
|
|
690
|
+
path: currentPath,
|
|
691
|
+
oldValue,
|
|
692
|
+
newValue,
|
|
693
|
+
description: `Changed ${key}`
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return changes;
|
|
699
|
+
}
|
|
700
|
+
function compareArrays(oldArr, newArr, basePath) {
|
|
701
|
+
const changes = [];
|
|
702
|
+
if (!Array.isArray(oldArr) && Array.isArray(newArr)) {
|
|
703
|
+
changes.push({
|
|
704
|
+
type: "modified",
|
|
705
|
+
path: basePath || "root",
|
|
706
|
+
oldValue: oldArr,
|
|
707
|
+
newValue: newArr,
|
|
708
|
+
description: `Changed ${basePath || "value"} from non-array to array`
|
|
709
|
+
});
|
|
710
|
+
return changes;
|
|
711
|
+
}
|
|
712
|
+
if (Array.isArray(oldArr) && !Array.isArray(newArr)) {
|
|
713
|
+
changes.push({
|
|
714
|
+
type: "modified",
|
|
715
|
+
path: basePath || "root",
|
|
716
|
+
oldValue: oldArr,
|
|
717
|
+
newValue: newArr,
|
|
718
|
+
description: `Changed ${basePath || "value"} from array to non-array`
|
|
719
|
+
});
|
|
720
|
+
return changes;
|
|
721
|
+
}
|
|
722
|
+
const oldArray = oldArr;
|
|
723
|
+
const newArray = newArr;
|
|
724
|
+
if (isMappingArray(oldArray) || isMappingArray(newArray)) {
|
|
725
|
+
return compareMappingArrays(oldArray, newArray, basePath);
|
|
726
|
+
}
|
|
727
|
+
if (oldArray.length !== newArray.length) {
|
|
728
|
+
changes.push({
|
|
729
|
+
type: "modified",
|
|
730
|
+
path: basePath || "root",
|
|
731
|
+
oldValue: oldArray,
|
|
732
|
+
newValue: newArray,
|
|
733
|
+
description: `Array ${basePath || "items"} changed from ${oldArray.length} to ${newArray.length} items`
|
|
734
|
+
});
|
|
735
|
+
} else {
|
|
736
|
+
for (let i = 0; i < oldArray.length; i++) {
|
|
737
|
+
const elementPath = `${basePath}[${i}]`;
|
|
738
|
+
if (!deepEqual(oldArray[i], newArray[i])) {
|
|
739
|
+
changes.push(...compareValues(oldArray[i], newArray[i], elementPath));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return changes;
|
|
744
|
+
}
|
|
745
|
+
function isMappingArray(arr) {
|
|
746
|
+
if (!Array.isArray(arr) || arr.length === 0) return false;
|
|
747
|
+
const firstItem = arr[0];
|
|
748
|
+
return typeof firstItem === "object" && firstItem !== null && "control_id" in firstItem && "uuid" in firstItem;
|
|
749
|
+
}
|
|
750
|
+
function compareMappingArrays(oldArr, newArr, basePath) {
|
|
751
|
+
const changes = [];
|
|
752
|
+
const oldMappings = /* @__PURE__ */ new Map();
|
|
753
|
+
const newMappings = /* @__PURE__ */ new Map();
|
|
754
|
+
for (const item of oldArr) {
|
|
755
|
+
if (typeof item === "object" && item !== null && "uuid" in item) {
|
|
756
|
+
oldMappings.set(item.uuid, item);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
for (const item of newArr) {
|
|
760
|
+
if (typeof item === "object" && item !== null && "uuid" in item) {
|
|
761
|
+
newMappings.set(item.uuid, item);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
for (const [uuid, mapping] of newMappings) {
|
|
765
|
+
if (!oldMappings.has(uuid)) {
|
|
766
|
+
changes.push({
|
|
767
|
+
type: "added",
|
|
768
|
+
path: `mapping`,
|
|
769
|
+
newValue: mapping,
|
|
770
|
+
description: `Added mapping`
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
for (const [uuid, mapping] of oldMappings) {
|
|
775
|
+
if (!newMappings.has(uuid)) {
|
|
776
|
+
changes.push({
|
|
777
|
+
type: "removed",
|
|
778
|
+
path: `mapping`,
|
|
779
|
+
oldValue: mapping,
|
|
780
|
+
description: `Removed mapping`
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
for (const [uuid, oldMapping] of oldMappings) {
|
|
785
|
+
if (newMappings.has(uuid)) {
|
|
786
|
+
const newMapping = newMappings.get(uuid);
|
|
787
|
+
if (!deepEqual(oldMapping, newMapping)) {
|
|
788
|
+
changes.push({
|
|
789
|
+
type: "modified",
|
|
790
|
+
path: `mapping`,
|
|
791
|
+
oldValue: oldMapping,
|
|
792
|
+
newValue: newMapping,
|
|
793
|
+
description: `Modified mapping`
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (changes.length === 0 && oldArr.length !== newArr.length) {
|
|
799
|
+
changes.push({
|
|
800
|
+
type: "modified",
|
|
801
|
+
path: basePath || "mappings",
|
|
802
|
+
oldValue: oldArr,
|
|
803
|
+
newValue: newArr,
|
|
804
|
+
description: `Mappings changed from ${oldArr.length} to ${newArr.length} items`
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
return changes;
|
|
808
|
+
}
|
|
809
|
+
function deepEqual(a, b) {
|
|
810
|
+
if (a === b) return true;
|
|
811
|
+
if (a === null || b === null) return false;
|
|
812
|
+
if (typeof a !== typeof b) return false;
|
|
813
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
814
|
+
if (a.length !== b.length) return false;
|
|
815
|
+
for (let i = 0; i < a.length; i++) {
|
|
816
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
817
|
+
}
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
821
|
+
const aObj = a;
|
|
822
|
+
const bObj = b;
|
|
823
|
+
const aKeys = Object.keys(aObj);
|
|
824
|
+
const bKeys = Object.keys(bObj);
|
|
825
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
826
|
+
for (const key of aKeys) {
|
|
827
|
+
if (!bKeys.includes(key)) return false;
|
|
828
|
+
if (!deepEqual(aObj[key], bObj[key])) return false;
|
|
829
|
+
}
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
function generateSummary(changes) {
|
|
835
|
+
if (changes.length === 0) {
|
|
836
|
+
return "No changes detected";
|
|
837
|
+
}
|
|
838
|
+
const added = changes.filter((c) => c.type === "added").length;
|
|
839
|
+
const removed = changes.filter((c) => c.type === "removed").length;
|
|
840
|
+
const modified = changes.filter((c) => c.type === "modified").length;
|
|
841
|
+
const parts = [];
|
|
842
|
+
if (added > 0) parts.push(`${added} added`);
|
|
843
|
+
if (removed > 0) parts.push(`${removed} removed`);
|
|
844
|
+
if (modified > 0) parts.push(`${modified} modified`);
|
|
845
|
+
return parts.join(", ");
|
|
846
|
+
}
|
|
847
|
+
var init_yamlDiff = __esm({
|
|
848
|
+
"cli/server/infrastructure/yamlDiff.ts"() {
|
|
849
|
+
"use strict";
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
// cli/server/infrastructure/gitHistory.ts
|
|
854
|
+
var gitHistory_exports = {};
|
|
855
|
+
__export(gitHistory_exports, {
|
|
856
|
+
GitHistoryUtil: () => GitHistoryUtil
|
|
857
|
+
});
|
|
858
|
+
import * as fs2 from "fs";
|
|
859
|
+
import * as git from "isomorphic-git";
|
|
860
|
+
import { relative } from "path";
|
|
861
|
+
var GitHistoryUtil;
|
|
862
|
+
var init_gitHistory = __esm({
|
|
863
|
+
"cli/server/infrastructure/gitHistory.ts"() {
|
|
864
|
+
"use strict";
|
|
865
|
+
init_yamlDiff();
|
|
866
|
+
GitHistoryUtil = class {
|
|
867
|
+
baseDir;
|
|
868
|
+
constructor(baseDir) {
|
|
869
|
+
this.baseDir = baseDir;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Check if the directory is a git repository
|
|
873
|
+
*/
|
|
874
|
+
async isGitRepository() {
|
|
875
|
+
try {
|
|
876
|
+
const gitDir = await git.findRoot({ fs: fs2, filepath: process.cwd() });
|
|
877
|
+
return !!gitDir;
|
|
878
|
+
} catch {
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Get git history for a specific file
|
|
884
|
+
*/
|
|
885
|
+
async getFileHistory(filePath, limit = 50) {
|
|
886
|
+
const isGitRepo = await this.isGitRepository();
|
|
887
|
+
if (!isGitRepo) {
|
|
888
|
+
return {
|
|
889
|
+
filePath,
|
|
890
|
+
commits: [],
|
|
891
|
+
totalCommits: 0,
|
|
892
|
+
firstCommit: null,
|
|
893
|
+
lastCommit: null
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
const gitRoot = await git.findRoot({ fs: fs2, filepath: process.cwd() });
|
|
898
|
+
const relativePath = relative(gitRoot, filePath);
|
|
899
|
+
const commits = await git.log({
|
|
900
|
+
fs: fs2,
|
|
901
|
+
dir: gitRoot,
|
|
902
|
+
filepath: relativePath,
|
|
903
|
+
depth: limit
|
|
904
|
+
});
|
|
905
|
+
if (!commits || commits.length === 0) {
|
|
906
|
+
return {
|
|
907
|
+
filePath,
|
|
908
|
+
commits: [],
|
|
909
|
+
totalCommits: 0,
|
|
910
|
+
firstCommit: null,
|
|
911
|
+
lastCommit: null
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
const gitCommits = await this.convertIsomorphicCommits(commits, relativePath, gitRoot);
|
|
915
|
+
return {
|
|
916
|
+
filePath,
|
|
917
|
+
commits: gitCommits,
|
|
918
|
+
totalCommits: gitCommits.length,
|
|
919
|
+
firstCommit: gitCommits[gitCommits.length - 1] || null,
|
|
920
|
+
lastCommit: gitCommits[0] || null
|
|
921
|
+
};
|
|
922
|
+
} catch (error) {
|
|
923
|
+
const err = error;
|
|
924
|
+
if (err?.code === "NotFoundError" || err?.message?.includes("Could not find file")) {
|
|
925
|
+
return {
|
|
926
|
+
filePath,
|
|
927
|
+
commits: [],
|
|
928
|
+
totalCommits: 0,
|
|
929
|
+
firstCommit: null,
|
|
930
|
+
lastCommit: null
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
console.error(`Unexpected error getting git history for ${filePath}:`, error);
|
|
934
|
+
return {
|
|
935
|
+
filePath,
|
|
936
|
+
commits: [],
|
|
937
|
+
totalCommits: 0,
|
|
938
|
+
firstCommit: null,
|
|
939
|
+
lastCommit: null
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Get the total number of commits for a file
|
|
945
|
+
*/
|
|
946
|
+
async getFileCommitCount(filePath) {
|
|
947
|
+
const isGitRepo = await this.isGitRepository();
|
|
948
|
+
if (!isGitRepo) {
|
|
949
|
+
return 0;
|
|
950
|
+
}
|
|
951
|
+
try {
|
|
952
|
+
const gitRoot = await git.findRoot({ fs: fs2, filepath: process.cwd() });
|
|
953
|
+
const relativePath = relative(gitRoot, filePath);
|
|
954
|
+
const commits = await git.log({
|
|
955
|
+
fs: fs2,
|
|
956
|
+
dir: gitRoot,
|
|
957
|
+
filepath: relativePath
|
|
958
|
+
});
|
|
959
|
+
return commits.length;
|
|
960
|
+
} catch {
|
|
961
|
+
return 0;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Get the latest commit info for a file
|
|
966
|
+
*/
|
|
967
|
+
async getLatestCommit(filePath) {
|
|
968
|
+
const history = await this.getFileHistory(filePath, 1);
|
|
969
|
+
return history.lastCommit;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Get file content at a specific commit (public method)
|
|
973
|
+
*/
|
|
974
|
+
async getFileContentAtCommit(filePath, commitHash) {
|
|
975
|
+
const isGitRepo = await this.isGitRepository();
|
|
976
|
+
if (!isGitRepo) {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
try {
|
|
980
|
+
const gitRoot = await git.findRoot({ fs: fs2, filepath: process.cwd() });
|
|
981
|
+
const relativePath = relative(gitRoot, filePath);
|
|
982
|
+
return await this.getFileAtCommit(commitHash, relativePath, gitRoot);
|
|
983
|
+
} catch (error) {
|
|
984
|
+
console.error(`Error getting file content at commit ${commitHash}:`, error);
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Convert isomorphic-git commits to our GitCommit format
|
|
990
|
+
*/
|
|
991
|
+
async convertIsomorphicCommits(commits, relativePath, gitRoot) {
|
|
992
|
+
const gitCommits = [];
|
|
993
|
+
for (let i = 0; i < commits.length; i++) {
|
|
994
|
+
const commit = commits[i];
|
|
995
|
+
const includeDiff = i < 5;
|
|
996
|
+
let changes = { insertions: 0, deletions: 0, files: 1 };
|
|
997
|
+
let diff;
|
|
998
|
+
let yamlDiff;
|
|
999
|
+
if (includeDiff) {
|
|
1000
|
+
try {
|
|
1001
|
+
const diffResult = await this.getCommitDiff(commit.oid, relativePath, gitRoot);
|
|
1002
|
+
changes = diffResult.changes;
|
|
1003
|
+
diff = diffResult.diff;
|
|
1004
|
+
yamlDiff = diffResult.yamlDiff;
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
gitCommits.push({
|
|
1009
|
+
hash: commit.oid,
|
|
1010
|
+
shortHash: commit.oid.substring(0, 7),
|
|
1011
|
+
author: commit.commit.author.name,
|
|
1012
|
+
authorEmail: commit.commit.author.email,
|
|
1013
|
+
date: new Date(commit.commit.author.timestamp * 1e3).toISOString(),
|
|
1014
|
+
message: commit.commit.message,
|
|
1015
|
+
changes,
|
|
1016
|
+
...diff && { diff },
|
|
1017
|
+
...yamlDiff && { yamlDiff }
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
return gitCommits;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Get diff for a specific commit and file
|
|
1024
|
+
*/
|
|
1025
|
+
async getCommitDiff(commitOid, relativePath, gitRoot) {
|
|
1026
|
+
try {
|
|
1027
|
+
const commit = await git.readCommit({ fs: fs2, dir: gitRoot, oid: commitOid });
|
|
1028
|
+
const parentOid = commit.commit.parent.length > 0 ? commit.commit.parent[0] : null;
|
|
1029
|
+
if (!parentOid) {
|
|
1030
|
+
const currentContent2 = await this.getFileAtCommit(commitOid, relativePath, gitRoot);
|
|
1031
|
+
if (currentContent2) {
|
|
1032
|
+
const lines = currentContent2.split("\n");
|
|
1033
|
+
const isMappingFile2 = relativePath.includes("-mappings.yaml");
|
|
1034
|
+
const yamlDiff2 = createYamlDiff("", currentContent2, isMappingFile2);
|
|
1035
|
+
return {
|
|
1036
|
+
changes: { insertions: lines.length, deletions: 0, files: 1 },
|
|
1037
|
+
diff: `--- /dev/null
|
|
1038
|
+
+++ b/${relativePath}
|
|
1039
|
+
@@ -0,0 +1,${lines.length} @@
|
|
1040
|
+
` + lines.map((line) => "+" + line).join("\n"),
|
|
1041
|
+
yamlDiff: yamlDiff2
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
return { changes: { insertions: 0, deletions: 0, files: 1 } };
|
|
1045
|
+
}
|
|
1046
|
+
const currentContent = await this.getFileAtCommit(commitOid, relativePath, gitRoot);
|
|
1047
|
+
const parentContent = await this.getFileAtCommit(parentOid, relativePath, gitRoot);
|
|
1048
|
+
if (!currentContent && !parentContent) {
|
|
1049
|
+
return { changes: { insertions: 0, deletions: 0, files: 1 } };
|
|
1050
|
+
}
|
|
1051
|
+
const currentLines = currentContent ? currentContent.split("\n") : [];
|
|
1052
|
+
const parentLines = parentContent ? parentContent.split("\n") : [];
|
|
1053
|
+
const diff = this.createSimpleDiff(parentLines, currentLines, relativePath);
|
|
1054
|
+
const { insertions, deletions } = this.countChanges(parentLines, currentLines);
|
|
1055
|
+
const isMappingFile = relativePath.includes("-mappings.yaml");
|
|
1056
|
+
const yamlDiff = createYamlDiff(parentContent || "", currentContent || "", isMappingFile);
|
|
1057
|
+
return {
|
|
1058
|
+
changes: { insertions, deletions, files: 1 },
|
|
1059
|
+
diff,
|
|
1060
|
+
yamlDiff
|
|
1061
|
+
};
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
return { changes: { insertions: 0, deletions: 0, files: 1 } };
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Get file content at a specific commit
|
|
1068
|
+
*/
|
|
1069
|
+
async getFileAtCommit(commitOid, filepath, gitRoot) {
|
|
1070
|
+
try {
|
|
1071
|
+
const { blob } = await git.readBlob({
|
|
1072
|
+
fs: fs2,
|
|
1073
|
+
dir: gitRoot,
|
|
1074
|
+
oid: commitOid,
|
|
1075
|
+
filepath
|
|
1076
|
+
});
|
|
1077
|
+
return new TextDecoder().decode(blob);
|
|
1078
|
+
} catch (_error) {
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Create a simple unified diff between two file versions
|
|
1084
|
+
*/
|
|
1085
|
+
createSimpleDiff(oldLines, newLines, filepath) {
|
|
1086
|
+
const diffLines = [];
|
|
1087
|
+
diffLines.push(`--- a/${filepath}`);
|
|
1088
|
+
diffLines.push(`+++ b/${filepath}`);
|
|
1089
|
+
const oldCount = oldLines.length;
|
|
1090
|
+
const newCount = newLines.length;
|
|
1091
|
+
diffLines.push(`@@ -1,${oldCount} +1,${newCount} @@`);
|
|
1092
|
+
let i = 0, j = 0;
|
|
1093
|
+
while (i < oldLines.length || j < newLines.length) {
|
|
1094
|
+
if (i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j]) {
|
|
1095
|
+
diffLines.push(` ${oldLines[i]}`);
|
|
1096
|
+
i++;
|
|
1097
|
+
j++;
|
|
1098
|
+
} else if (i < oldLines.length && (j >= newLines.length || oldLines[i] !== newLines[j])) {
|
|
1099
|
+
diffLines.push(`-${oldLines[i]}`);
|
|
1100
|
+
i++;
|
|
1101
|
+
} else if (j < newLines.length) {
|
|
1102
|
+
diffLines.push(`+${newLines[j]}`);
|
|
1103
|
+
j++;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return diffLines.join("\n");
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Count insertions and deletions between two file versions
|
|
1110
|
+
*/
|
|
1111
|
+
countChanges(oldLines, newLines) {
|
|
1112
|
+
let insertions = 0;
|
|
1113
|
+
let deletions = 0;
|
|
1114
|
+
const maxLines = Math.max(oldLines.length, newLines.length);
|
|
1115
|
+
for (let i = 0; i < maxLines; i++) {
|
|
1116
|
+
const oldLine = i < oldLines.length ? oldLines[i] : null;
|
|
1117
|
+
const newLine = i < newLines.length ? newLines[i] : null;
|
|
1118
|
+
if (oldLine === null) {
|
|
1119
|
+
insertions++;
|
|
1120
|
+
} else if (newLine === null) {
|
|
1121
|
+
deletions++;
|
|
1122
|
+
} else if (oldLine !== newLine) {
|
|
1123
|
+
insertions++;
|
|
1124
|
+
deletions++;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return { insertions, deletions };
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Get git stats for the entire repository
|
|
1131
|
+
*/
|
|
1132
|
+
async getRepositoryStats() {
|
|
1133
|
+
const isGitRepo = await this.isGitRepository();
|
|
1134
|
+
if (!isGitRepo) {
|
|
1135
|
+
return {
|
|
1136
|
+
totalCommits: 0,
|
|
1137
|
+
contributors: 0,
|
|
1138
|
+
lastCommitDate: null,
|
|
1139
|
+
firstCommitDate: null
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
try {
|
|
1143
|
+
const gitRoot = await git.findRoot({ fs: fs2, filepath: process.cwd() });
|
|
1144
|
+
const commits = await git.log({ fs: fs2, dir: gitRoot });
|
|
1145
|
+
const contributorEmails = /* @__PURE__ */ new Set();
|
|
1146
|
+
commits.forEach((commit) => {
|
|
1147
|
+
contributorEmails.add(commit.commit.author.email);
|
|
1148
|
+
});
|
|
1149
|
+
const firstCommit = commits[commits.length - 1];
|
|
1150
|
+
const lastCommit = commits[0];
|
|
1151
|
+
return {
|
|
1152
|
+
totalCommits: commits.length,
|
|
1153
|
+
contributors: contributorEmails.size,
|
|
1154
|
+
lastCommitDate: lastCommit ? new Date(lastCommit.commit.author.timestamp * 1e3).toISOString() : null,
|
|
1155
|
+
firstCommitDate: firstCommit ? new Date(firstCommit.commit.author.timestamp * 1e3).toISOString() : null
|
|
1156
|
+
};
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
console.error("Error getting repository stats:", error);
|
|
1159
|
+
return {
|
|
1160
|
+
totalCommits: 0,
|
|
1161
|
+
contributors: 0,
|
|
1162
|
+
lastCommitDate: null,
|
|
1163
|
+
firstCommitDate: null
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
// cli/server/serverState.ts
|
|
1172
|
+
var serverState_exports = {};
|
|
1173
|
+
__export(serverState_exports, {
|
|
1174
|
+
addControlToIndexes: () => addControlToIndexes,
|
|
1175
|
+
addMappingToIndexes: () => addMappingToIndexes,
|
|
1176
|
+
getCurrentControlSetPath: () => getCurrentControlSetPath,
|
|
1177
|
+
getServerState: () => getServerState,
|
|
1178
|
+
initializeServerState: () => initializeServerState,
|
|
1179
|
+
loadAllData: () => loadAllData,
|
|
1180
|
+
saveMappingsToFile: () => saveMappingsToFile
|
|
1181
|
+
});
|
|
1182
|
+
import { join as join3 } from "path";
|
|
1183
|
+
function initializeServerState(controlSetDir, subdir = ".") {
|
|
1184
|
+
const fullPath = subdir === "." ? controlSetDir : join3(controlSetDir, subdir);
|
|
1185
|
+
serverState = {
|
|
1186
|
+
CONTROL_SET_DIR: controlSetDir,
|
|
1187
|
+
currentSubdir: subdir,
|
|
1188
|
+
fileStore: new FileStore({ baseDir: fullPath }),
|
|
1189
|
+
gitHistory: new GitHistoryUtil(fullPath),
|
|
1190
|
+
controlsCache: /* @__PURE__ */ new Map(),
|
|
1191
|
+
mappingsCache: /* @__PURE__ */ new Map(),
|
|
1192
|
+
controlsByFamily: /* @__PURE__ */ new Map(),
|
|
1193
|
+
mappingsByFamily: /* @__PURE__ */ new Map(),
|
|
1194
|
+
mappingsByControl: /* @__PURE__ */ new Map()
|
|
1195
|
+
};
|
|
1196
|
+
return serverState;
|
|
1197
|
+
}
|
|
1198
|
+
function getServerState() {
|
|
1199
|
+
if (!serverState) {
|
|
1200
|
+
throw new Error("Server state not initialized. Call initializeServerState() first.");
|
|
1201
|
+
}
|
|
1202
|
+
return serverState;
|
|
1203
|
+
}
|
|
1204
|
+
function getCurrentControlSetPath() {
|
|
1205
|
+
const state = getServerState();
|
|
1206
|
+
return state.currentSubdir === "." ? state.CONTROL_SET_DIR : join3(state.CONTROL_SET_DIR, state.currentSubdir);
|
|
1207
|
+
}
|
|
1208
|
+
function addControlToIndexes(control) {
|
|
1209
|
+
const state = getServerState();
|
|
1210
|
+
const family = control.family;
|
|
1211
|
+
if (!family) {
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (!state.controlsByFamily.has(family)) {
|
|
1215
|
+
state.controlsByFamily.set(family, /* @__PURE__ */ new Set());
|
|
1216
|
+
}
|
|
1217
|
+
state.controlsByFamily.get(family).add(control.id);
|
|
1218
|
+
}
|
|
1219
|
+
function addMappingToIndexes(mapping) {
|
|
1220
|
+
const state = getServerState();
|
|
1221
|
+
const family = mapping.control_id.split("-")[0];
|
|
1222
|
+
if (!state.mappingsByFamily.has(family)) {
|
|
1223
|
+
state.mappingsByFamily.set(family, /* @__PURE__ */ new Set());
|
|
1224
|
+
}
|
|
1225
|
+
state.mappingsByFamily.get(family).add(mapping.uuid);
|
|
1226
|
+
if (!state.mappingsByControl.has(mapping.control_id)) {
|
|
1227
|
+
state.mappingsByControl.set(mapping.control_id, /* @__PURE__ */ new Set());
|
|
1228
|
+
}
|
|
1229
|
+
state.mappingsByControl.get(mapping.control_id).add(mapping.uuid);
|
|
1230
|
+
}
|
|
1231
|
+
async function loadAllData() {
|
|
1232
|
+
const state = getServerState();
|
|
1233
|
+
debug("Loading data into memory...");
|
|
1234
|
+
try {
|
|
1235
|
+
const controls = await state.fileStore.loadAllControls();
|
|
1236
|
+
for (const control of controls) {
|
|
1237
|
+
state.controlsCache.set(control.id, control);
|
|
1238
|
+
addControlToIndexes(control);
|
|
1239
|
+
}
|
|
1240
|
+
debug(`Loaded ${controls.length} controls from individual files`);
|
|
1241
|
+
const mappings = await state.fileStore.loadMappings();
|
|
1242
|
+
for (const mapping of mappings) {
|
|
1243
|
+
state.mappingsCache.set(mapping.uuid, mapping);
|
|
1244
|
+
addMappingToIndexes(mapping);
|
|
1245
|
+
}
|
|
1246
|
+
debug(`Loaded ${mappings.length} mappings`);
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
console.error("Error loading data:", error);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
async function saveMappingsToFile() {
|
|
1252
|
+
const state = getServerState();
|
|
1253
|
+
try {
|
|
1254
|
+
const allMappings = Array.from(state.mappingsCache.values());
|
|
1255
|
+
await state.fileStore.saveMappings(allMappings);
|
|
1256
|
+
debug(`Saved ${allMappings.length} mappings`);
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
console.error("Error saving mappings:", error);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
var serverState;
|
|
1262
|
+
var init_serverState = __esm({
|
|
1263
|
+
"cli/server/serverState.ts"() {
|
|
1264
|
+
"use strict";
|
|
1265
|
+
init_debug();
|
|
1266
|
+
init_fileStore();
|
|
1267
|
+
init_gitHistory();
|
|
1268
|
+
serverState = void 0;
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
// cli/server/spreadsheetRoutes.ts
|
|
1273
|
+
var spreadsheetRoutes_exports = {};
|
|
1274
|
+
__export(spreadsheetRoutes_exports, {
|
|
1275
|
+
default: () => spreadsheetRoutes_default,
|
|
1276
|
+
scanControlSets: () => scanControlSets
|
|
1277
|
+
});
|
|
1278
|
+
import { parse as parseCSVSync } from "csv-parse/sync";
|
|
1279
|
+
import ExcelJS from "exceljs";
|
|
1280
|
+
import express from "express";
|
|
1281
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
1282
|
+
import { glob } from "glob";
|
|
1283
|
+
import * as yaml4 from "js-yaml";
|
|
1284
|
+
import multer from "multer";
|
|
1285
|
+
import { dirname, join as join4, relative as relative2 } from "path";
|
|
1286
|
+
async function scanControlSets() {
|
|
1287
|
+
const state = getServerState();
|
|
1288
|
+
const baseDir = state.CONTROL_SET_DIR;
|
|
1289
|
+
const pattern = "**/lula.yaml";
|
|
1290
|
+
const files = await glob(pattern, {
|
|
1291
|
+
cwd: baseDir,
|
|
1292
|
+
ignore: ["node_modules/**", "dist/**", "build/**"],
|
|
1293
|
+
maxDepth: 5
|
|
1294
|
+
});
|
|
1295
|
+
const controlSets = files.map((file) => {
|
|
1296
|
+
const fullPath = join4(baseDir, file);
|
|
1297
|
+
const dirPath = dirname(fullPath);
|
|
1298
|
+
const relativePath = relative2(baseDir, dirPath) || ".";
|
|
1299
|
+
try {
|
|
1300
|
+
const content = readFileSync3(fullPath, "utf8");
|
|
1301
|
+
const data = yaml4.load(content);
|
|
1302
|
+
if (data.id === "default") {
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
return {
|
|
1306
|
+
path: relativePath,
|
|
1307
|
+
name: data.name || "Unnamed Control Set",
|
|
1308
|
+
description: data.description || "",
|
|
1309
|
+
controlCount: data.controlCount || 0,
|
|
1310
|
+
file
|
|
1311
|
+
};
|
|
1312
|
+
} catch (_err) {
|
|
1313
|
+
return {
|
|
1314
|
+
path: relativePath,
|
|
1315
|
+
name: "Invalid lula.yaml",
|
|
1316
|
+
description: "Could not parse file",
|
|
1317
|
+
controlCount: 0,
|
|
1318
|
+
file
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
}).filter((cs) => cs !== null);
|
|
1322
|
+
return { controlSets };
|
|
1323
|
+
}
|
|
1324
|
+
function applyNamingConvention(fieldName, convention) {
|
|
1325
|
+
if (!fieldName) return fieldName;
|
|
1326
|
+
const cleanedName = fieldName.trim();
|
|
1327
|
+
switch (convention) {
|
|
1328
|
+
case "camelCase":
|
|
1329
|
+
return toCamelCase(cleanedName);
|
|
1330
|
+
case "snake_case":
|
|
1331
|
+
return toSnakeCase(cleanedName);
|
|
1332
|
+
case "kebab-case":
|
|
1333
|
+
return toKebabCase(cleanedName);
|
|
1334
|
+
case "lowercase":
|
|
1335
|
+
return cleanedName.replace(/\W+/g, "").toLowerCase();
|
|
1336
|
+
case "original":
|
|
1337
|
+
return cleanedName;
|
|
1338
|
+
default:
|
|
1339
|
+
return toCamelCase(cleanedName);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
function toCamelCase(str) {
|
|
1343
|
+
return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => {
|
|
1344
|
+
return index === 0 ? word.toLowerCase() : word.toUpperCase();
|
|
1345
|
+
}).replace(/\s+/g, "");
|
|
1346
|
+
}
|
|
1347
|
+
function toSnakeCase(str) {
|
|
1348
|
+
return str.replace(/\W+/g, " ").split(/ |\s/).map((word) => word.toLowerCase()).join("_");
|
|
1349
|
+
}
|
|
1350
|
+
function toKebabCase(str) {
|
|
1351
|
+
return str.replace(/\W+/g, " ").split(/ |\s/).map((word) => word.toLowerCase()).join("-");
|
|
1352
|
+
}
|
|
1353
|
+
function detectValueType(value) {
|
|
1354
|
+
if (typeof value === "boolean") return "boolean";
|
|
1355
|
+
if (typeof value === "number") return "number";
|
|
1356
|
+
if (typeof value === "string") {
|
|
1357
|
+
const lowerValue = value.toLowerCase().trim();
|
|
1358
|
+
if (lowerValue === "true" || lowerValue === "false" || lowerValue === "yes" || lowerValue === "no" || lowerValue === "y" || lowerValue === "n") {
|
|
1359
|
+
return "boolean";
|
|
1360
|
+
}
|
|
1361
|
+
if (!isNaN(Number(value)) && value.trim() !== "") {
|
|
1362
|
+
return "number";
|
|
1363
|
+
}
|
|
1364
|
+
const datePatterns = [
|
|
1365
|
+
/^\d{4}-\d{2}-\d{2}$/,
|
|
1366
|
+
// YYYY-MM-DD
|
|
1367
|
+
/^\d{2}\/\d{2}\/\d{4}$/,
|
|
1368
|
+
// MM/DD/YYYY
|
|
1369
|
+
/^\d{1,2}\/\d{1,2}\/\d{2,4}$/
|
|
1370
|
+
// M/D/YY or MM/DD/YYYY
|
|
1371
|
+
];
|
|
1372
|
+
if (datePatterns.some((pattern) => pattern.test(value))) {
|
|
1373
|
+
return "date";
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return "string";
|
|
1377
|
+
}
|
|
1378
|
+
function parseCSV(content) {
|
|
1379
|
+
try {
|
|
1380
|
+
const records = parseCSVSync(content, {
|
|
1381
|
+
// Don't treat first row as headers - we'll handle that ourselves
|
|
1382
|
+
columns: false,
|
|
1383
|
+
// Skip empty lines
|
|
1384
|
+
skip_empty_lines: true,
|
|
1385
|
+
// Handle different line endings
|
|
1386
|
+
relax_column_count: true,
|
|
1387
|
+
// Trim whitespace from fields
|
|
1388
|
+
trim: true,
|
|
1389
|
+
// Handle quoted fields properly
|
|
1390
|
+
quote: '"',
|
|
1391
|
+
// Standard escape character
|
|
1392
|
+
escape: '"',
|
|
1393
|
+
// Auto-detect delimiter (usually comma)
|
|
1394
|
+
delimiter: ","
|
|
1395
|
+
});
|
|
1396
|
+
return records;
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
console.error("CSV parsing error:", error);
|
|
1399
|
+
return content.split(/\r?\n/).map((line) => line.split(","));
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
function extractFamilyFromControlId(controlId) {
|
|
1403
|
+
if (!controlId) return "UNKNOWN";
|
|
1404
|
+
controlId = controlId.trim();
|
|
1405
|
+
const match = controlId.match(/^([A-Za-z]+)[-._ ]?\d/);
|
|
1406
|
+
if (match) {
|
|
1407
|
+
return match[1].toUpperCase();
|
|
1408
|
+
}
|
|
1409
|
+
const letterMatch = controlId.match(/^([A-Za-z]+)/);
|
|
1410
|
+
if (letterMatch) {
|
|
1411
|
+
return letterMatch[1].toUpperCase();
|
|
1412
|
+
}
|
|
1413
|
+
return controlId.substring(0, 2).toUpperCase();
|
|
1414
|
+
}
|
|
1415
|
+
function exportAsCSV(controls, metadata, res) {
|
|
1416
|
+
const fieldSchema = metadata?.fieldSchema?.fields || {};
|
|
1417
|
+
const controlIdField = metadata?.control_id_field || "id";
|
|
1418
|
+
const allFields = /* @__PURE__ */ new Set();
|
|
1419
|
+
controls.forEach((control) => {
|
|
1420
|
+
Object.keys(control).forEach((key) => allFields.add(key));
|
|
1421
|
+
});
|
|
1422
|
+
const fieldMapping = [];
|
|
1423
|
+
if (allFields.has(controlIdField)) {
|
|
1424
|
+
const idSchema = fieldSchema[controlIdField];
|
|
1425
|
+
fieldMapping.push({
|
|
1426
|
+
fieldName: controlIdField,
|
|
1427
|
+
displayName: idSchema?.original_name || "Control ID"
|
|
1428
|
+
});
|
|
1429
|
+
allFields.delete(controlIdField);
|
|
1430
|
+
} else if (allFields.has("id")) {
|
|
1431
|
+
fieldMapping.push({
|
|
1432
|
+
fieldName: "id",
|
|
1433
|
+
displayName: "Control ID"
|
|
1434
|
+
});
|
|
1435
|
+
allFields.delete("id");
|
|
1436
|
+
}
|
|
1437
|
+
if (allFields.has("family")) {
|
|
1438
|
+
const familySchema = fieldSchema["family"];
|
|
1439
|
+
fieldMapping.push({
|
|
1440
|
+
fieldName: "family",
|
|
1441
|
+
displayName: familySchema?.original_name || "Family"
|
|
1442
|
+
});
|
|
1443
|
+
allFields.delete("family");
|
|
1444
|
+
}
|
|
1445
|
+
Array.from(allFields).filter((field) => field !== "mappings" && field !== "mappings_count").sort().forEach((field) => {
|
|
1446
|
+
const schema = fieldSchema[field];
|
|
1447
|
+
const displayName = schema?.original_name || field.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
|
1448
|
+
fieldMapping.push({ fieldName: field, displayName });
|
|
1449
|
+
});
|
|
1450
|
+
if (allFields.has("mappings_count")) {
|
|
1451
|
+
fieldMapping.push({ fieldName: "mappings_count", displayName: "Mappings Count" });
|
|
1452
|
+
}
|
|
1453
|
+
if (allFields.has("mappings")) {
|
|
1454
|
+
fieldMapping.push({ fieldName: "mappings", displayName: "Mappings" });
|
|
1455
|
+
}
|
|
1456
|
+
const csvRows = [];
|
|
1457
|
+
csvRows.push(fieldMapping.map((field) => `"${field.displayName}"`).join(","));
|
|
1458
|
+
controls.forEach((control) => {
|
|
1459
|
+
const row = fieldMapping.map(({ fieldName }) => {
|
|
1460
|
+
const value = control[fieldName];
|
|
1461
|
+
if (value === void 0 || value === null) return '""';
|
|
1462
|
+
if (fieldName === "mappings" && Array.isArray(value)) {
|
|
1463
|
+
const mappingsStr = value.map(
|
|
1464
|
+
(m) => `${m.status}: ${m.description.substring(0, 50)}${m.description.length > 50 ? "..." : ""}`
|
|
1465
|
+
).join("; ");
|
|
1466
|
+
return `"${mappingsStr.replace(/"/g, '""')}"`;
|
|
1467
|
+
}
|
|
1468
|
+
if (Array.isArray(value)) return `"${value.join("; ").replace(/"/g, '""')}"`;
|
|
1469
|
+
if (typeof value === "object") return `"${JSON.stringify(value).replace(/"/g, '""')}"`;
|
|
1470
|
+
return `"${String(value).replace(/"/g, '""')}"`;
|
|
1471
|
+
});
|
|
1472
|
+
csvRows.push(row.join(","));
|
|
1473
|
+
});
|
|
1474
|
+
const csvContent = csvRows.join("\n");
|
|
1475
|
+
const fileName = `${metadata?.name || "controls"}_export_${Date.now()}.csv`;
|
|
1476
|
+
res.setHeader("Content-Type", "text/csv");
|
|
1477
|
+
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
1478
|
+
res.send(csvContent);
|
|
1479
|
+
}
|
|
1480
|
+
async function exportAsExcel(controls, metadata, res) {
|
|
1481
|
+
const fieldSchema = metadata?.fieldSchema?.fields || {};
|
|
1482
|
+
const controlIdField = metadata?.control_id_field || "id";
|
|
1483
|
+
const worksheetData = controls.map((control) => {
|
|
1484
|
+
const exportControl = {};
|
|
1485
|
+
if (control[controlIdField]) {
|
|
1486
|
+
const idSchema = fieldSchema[controlIdField];
|
|
1487
|
+
const idDisplayName = idSchema?.original_name || "Control ID";
|
|
1488
|
+
exportControl[idDisplayName] = control[controlIdField];
|
|
1489
|
+
} else if (control.id) {
|
|
1490
|
+
exportControl["Control ID"] = control.id;
|
|
1491
|
+
}
|
|
1492
|
+
if (control.family) {
|
|
1493
|
+
const familySchema = fieldSchema["family"];
|
|
1494
|
+
const familyDisplayName = familySchema?.original_name || "Family";
|
|
1495
|
+
exportControl[familyDisplayName] = control.family;
|
|
1496
|
+
}
|
|
1497
|
+
Object.keys(control).forEach((key) => {
|
|
1498
|
+
if (key === controlIdField || key === "id" || key === "family") return;
|
|
1499
|
+
const schema = fieldSchema[key];
|
|
1500
|
+
const displayName = schema?.original_name || (key === "mappings_count" ? "Mappings Count" : key === "mappings" ? "Mappings" : key.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()));
|
|
1501
|
+
const value = control[key];
|
|
1502
|
+
if (key === "mappings" && Array.isArray(value)) {
|
|
1503
|
+
exportControl[displayName] = value.map(
|
|
1504
|
+
(m) => `${m.status}: ${m.description.substring(0, 100)}${m.description.length > 100 ? "..." : ""}`
|
|
1505
|
+
).join("\n");
|
|
1506
|
+
} else if (Array.isArray(value)) {
|
|
1507
|
+
exportControl[displayName] = value.join("; ");
|
|
1508
|
+
} else if (typeof value === "object" && value !== null) {
|
|
1509
|
+
exportControl[displayName] = JSON.stringify(value);
|
|
1510
|
+
} else {
|
|
1511
|
+
exportControl[displayName] = value;
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
return exportControl;
|
|
1515
|
+
});
|
|
1516
|
+
const wb = new ExcelJS.Workbook();
|
|
1517
|
+
const ws = wb.addWorksheet("Controls");
|
|
1518
|
+
const headers = Object.keys(worksheetData[0] || {});
|
|
1519
|
+
ws.columns = headers.map((header) => ({
|
|
1520
|
+
header,
|
|
1521
|
+
key: header,
|
|
1522
|
+
width: Math.min(
|
|
1523
|
+
Math.max(
|
|
1524
|
+
header.length,
|
|
1525
|
+
...worksheetData.map((row) => String(row[header] || "").length)
|
|
1526
|
+
) + 2,
|
|
1527
|
+
50
|
|
1528
|
+
)
|
|
1529
|
+
// Auto-size with max width of 50
|
|
1530
|
+
}));
|
|
1531
|
+
worksheetData.forEach((row) => {
|
|
1532
|
+
ws.addRow(row);
|
|
1533
|
+
});
|
|
1534
|
+
ws.getRow(1).font = { bold: true };
|
|
1535
|
+
ws.getRow(1).fill = {
|
|
1536
|
+
type: "pattern",
|
|
1537
|
+
pattern: "solid",
|
|
1538
|
+
fgColor: { argb: "FFE0E0E0" }
|
|
1539
|
+
};
|
|
1540
|
+
if (metadata) {
|
|
1541
|
+
const metaSheet = wb.addWorksheet("Metadata");
|
|
1542
|
+
const cleanMetadata = { ...metadata };
|
|
1543
|
+
delete cleanMetadata.fieldSchema;
|
|
1544
|
+
metaSheet.columns = [
|
|
1545
|
+
{ header: "Property", key: "property", width: 30 },
|
|
1546
|
+
{ header: "Value", key: "value", width: 50 }
|
|
1547
|
+
];
|
|
1548
|
+
Object.entries(cleanMetadata).forEach(([key, value]) => {
|
|
1549
|
+
metaSheet.addRow({ property: key, value: String(value) });
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
const buffer = await wb.xlsx.writeBuffer();
|
|
1553
|
+
const fileName = `${metadata?.name || "controls"}_export_${Date.now()}.xlsx`;
|
|
1554
|
+
res.setHeader(
|
|
1555
|
+
"Content-Type",
|
|
1556
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
1557
|
+
);
|
|
1558
|
+
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
1559
|
+
res.send(buffer);
|
|
1560
|
+
}
|
|
1561
|
+
function exportAsJSON(controls, metadata, res) {
|
|
1562
|
+
const exportData = {
|
|
1563
|
+
metadata: metadata || {},
|
|
1564
|
+
controlCount: controls.length,
|
|
1565
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1566
|
+
controls
|
|
1567
|
+
};
|
|
1568
|
+
const fileName = `${metadata?.name || "controls"}_export_${Date.now()}.json`;
|
|
1569
|
+
res.setHeader("Content-Type", "application/json");
|
|
1570
|
+
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
1571
|
+
res.json(exportData);
|
|
1572
|
+
}
|
|
1573
|
+
var router, upload, spreadsheetRoutes_default;
|
|
1574
|
+
var init_spreadsheetRoutes = __esm({
|
|
1575
|
+
"cli/server/spreadsheetRoutes.ts"() {
|
|
1576
|
+
"use strict";
|
|
1577
|
+
init_debug();
|
|
1578
|
+
init_serverState();
|
|
1579
|
+
router = express.Router();
|
|
1580
|
+
upload = multer({
|
|
1581
|
+
storage: multer.memoryStorage(),
|
|
1582
|
+
limits: { fileSize: 50 * 1024 * 1024 }
|
|
1583
|
+
// 50MB limit
|
|
1584
|
+
});
|
|
1585
|
+
router.post("/import-spreadsheet", upload.single("file"), async (req, res) => {
|
|
1586
|
+
try {
|
|
1587
|
+
if (!req.file) {
|
|
1588
|
+
return res.status(400).json({ error: "No file uploaded" });
|
|
1589
|
+
}
|
|
1590
|
+
const {
|
|
1591
|
+
controlIdField = "Control ID",
|
|
1592
|
+
startRow = "1",
|
|
1593
|
+
controlSetName = "Imported Control Set",
|
|
1594
|
+
controlSetDescription = "Imported from spreadsheet"
|
|
1595
|
+
} = req.body;
|
|
1596
|
+
debug("Import parameters received:", {
|
|
1597
|
+
controlIdField,
|
|
1598
|
+
startRow,
|
|
1599
|
+
controlSetName,
|
|
1600
|
+
controlSetDescription
|
|
1601
|
+
});
|
|
1602
|
+
const namingConvention = "kebab-case";
|
|
1603
|
+
const skipEmpty = true;
|
|
1604
|
+
const skipEmptyRows = true;
|
|
1605
|
+
const fileName = req.file.originalname || "";
|
|
1606
|
+
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
1607
|
+
let rawData = [];
|
|
1608
|
+
if (isCSV) {
|
|
1609
|
+
const csvContent = req.file.buffer.toString("utf-8");
|
|
1610
|
+
rawData = parseCSV(csvContent);
|
|
1611
|
+
} else {
|
|
1612
|
+
const workbook = new ExcelJS.Workbook();
|
|
1613
|
+
const buffer = Buffer.from(req.file.buffer);
|
|
1614
|
+
await workbook.xlsx.load(buffer);
|
|
1615
|
+
const worksheet = workbook.worksheets[0];
|
|
1616
|
+
if (!worksheet) {
|
|
1617
|
+
return res.status(400).json({ error: "No worksheet found in file" });
|
|
1618
|
+
}
|
|
1619
|
+
worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
|
1620
|
+
const rowData = [];
|
|
1621
|
+
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
1622
|
+
rowData[colNumber - 1] = cell.value;
|
|
1623
|
+
});
|
|
1624
|
+
rawData[rowNumber - 1] = rowData;
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
const startRowIndex = parseInt(startRow) - 1;
|
|
1628
|
+
if (rawData.length <= startRowIndex) {
|
|
1629
|
+
return res.status(400).json({ error: "Start row exceeds sheet data" });
|
|
1630
|
+
}
|
|
1631
|
+
const headers = rawData[startRowIndex];
|
|
1632
|
+
if (!headers || headers.length === 0) {
|
|
1633
|
+
return res.status(400).json({ error: "No headers found at specified row" });
|
|
1634
|
+
}
|
|
1635
|
+
debug("Headers found:", headers);
|
|
1636
|
+
debug(
|
|
1637
|
+
"After conversion, looking for control ID field:",
|
|
1638
|
+
applyNamingConvention(controlIdField, namingConvention)
|
|
1639
|
+
);
|
|
1640
|
+
const controls = [];
|
|
1641
|
+
const families = /* @__PURE__ */ new Map();
|
|
1642
|
+
const fieldMetadata = /* @__PURE__ */ new Map();
|
|
1643
|
+
headers.forEach((header) => {
|
|
1644
|
+
if (header) {
|
|
1645
|
+
const cleanName = applyNamingConvention(header, namingConvention);
|
|
1646
|
+
fieldMetadata.set(cleanName, {
|
|
1647
|
+
originalName: header,
|
|
1648
|
+
cleanName,
|
|
1649
|
+
type: "string",
|
|
1650
|
+
maxLength: 0,
|
|
1651
|
+
hasMultipleLines: false,
|
|
1652
|
+
uniqueValues: /* @__PURE__ */ new Set(),
|
|
1653
|
+
emptyCount: 0,
|
|
1654
|
+
totalCount: 0,
|
|
1655
|
+
examples: []
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
for (let i = startRowIndex + 1; i < rawData.length; i++) {
|
|
1660
|
+
const row = rawData[i];
|
|
1661
|
+
if (!row || row.length === 0) continue;
|
|
1662
|
+
const control = {};
|
|
1663
|
+
let hasData = false;
|
|
1664
|
+
headers.forEach((header, index) => {
|
|
1665
|
+
if (header && row[index] !== void 0 && row[index] !== null) {
|
|
1666
|
+
const value = typeof row[index] === "string" ? row[index].trim() : row[index];
|
|
1667
|
+
const fieldName = applyNamingConvention(header, namingConvention);
|
|
1668
|
+
const metadata = fieldMetadata.get(fieldName);
|
|
1669
|
+
metadata.totalCount++;
|
|
1670
|
+
if (value === "" || value === null || value === void 0) {
|
|
1671
|
+
metadata.emptyCount++;
|
|
1672
|
+
if (skipEmpty) return;
|
|
1673
|
+
} else {
|
|
1674
|
+
const normalizedValue = typeof value === "string" ? value.trim() : value;
|
|
1675
|
+
if (normalizedValue !== "") {
|
|
1676
|
+
metadata.uniqueValues.add(normalizedValue);
|
|
1677
|
+
}
|
|
1678
|
+
const valueType = detectValueType(value);
|
|
1679
|
+
if (metadata.type === "string" || metadata.totalCount === 1) {
|
|
1680
|
+
metadata.type = valueType;
|
|
1681
|
+
} else if (metadata.type !== valueType) {
|
|
1682
|
+
metadata.type = "mixed";
|
|
1683
|
+
}
|
|
1684
|
+
if (typeof value === "string") {
|
|
1685
|
+
const length = value.length;
|
|
1686
|
+
if (length > metadata.maxLength) {
|
|
1687
|
+
metadata.maxLength = length;
|
|
1688
|
+
}
|
|
1689
|
+
if (value.includes("\n") || length > 100) {
|
|
1690
|
+
metadata.hasMultipleLines = true;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (metadata.examples.length < 3 && normalizedValue !== "") {
|
|
1694
|
+
metadata.examples.push(normalizedValue);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
control[fieldName] = value;
|
|
1698
|
+
hasData = true;
|
|
1699
|
+
}
|
|
1700
|
+
});
|
|
1701
|
+
if (hasData && (!skipEmptyRows || Object.keys(control).length > 0)) {
|
|
1702
|
+
const controlIdFieldName = applyNamingConvention(controlIdField, namingConvention);
|
|
1703
|
+
const controlId = control[controlIdFieldName];
|
|
1704
|
+
if (!controlId) {
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
const family = extractFamilyFromControlId(controlId);
|
|
1708
|
+
control.family = family;
|
|
1709
|
+
controls.push(control);
|
|
1710
|
+
if (!families.has(family)) {
|
|
1711
|
+
families.set(family, []);
|
|
1712
|
+
}
|
|
1713
|
+
families.get(family).push(control);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
const state = getServerState();
|
|
1717
|
+
const folderName = toKebabCase(controlSetName || "imported-controls");
|
|
1718
|
+
const baseDir = join4(state.CONTROL_SET_DIR || process.cwd(), folderName);
|
|
1719
|
+
if (!existsSync3(baseDir)) {
|
|
1720
|
+
mkdirSync2(baseDir, { recursive: true });
|
|
1721
|
+
}
|
|
1722
|
+
const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
|
|
1723
|
+
let frontendFieldSchema = null;
|
|
1724
|
+
if (req.body.fieldSchema) {
|
|
1725
|
+
try {
|
|
1726
|
+
frontendFieldSchema = JSON.parse(req.body.fieldSchema);
|
|
1727
|
+
} catch (e) {
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
const fields = {};
|
|
1731
|
+
let displayOrder = 1;
|
|
1732
|
+
const controlIdFieldNameClean = applyNamingConvention(controlIdField, namingConvention);
|
|
1733
|
+
const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
|
|
1734
|
+
fields["family"] = {
|
|
1735
|
+
type: "string",
|
|
1736
|
+
ui_type: familyOptions.length <= 50 ? "select" : "short_text",
|
|
1737
|
+
// Make select if reasonable number of families
|
|
1738
|
+
is_array: false,
|
|
1739
|
+
max_length: 10,
|
|
1740
|
+
usage_count: controls.length,
|
|
1741
|
+
usage_percentage: 100,
|
|
1742
|
+
required: true,
|
|
1743
|
+
visible: true,
|
|
1744
|
+
show_in_table: true,
|
|
1745
|
+
editable: false,
|
|
1746
|
+
display_order: displayOrder++,
|
|
1747
|
+
category: "core",
|
|
1748
|
+
tab: "overview"
|
|
1749
|
+
};
|
|
1750
|
+
if (familyOptions.length <= 50) {
|
|
1751
|
+
fields["family"].options = familyOptions;
|
|
1752
|
+
}
|
|
1753
|
+
fieldMetadata.forEach((metadata, fieldName) => {
|
|
1754
|
+
if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
const frontendConfig = frontendFieldSchema?.find((f) => f.fieldName === fieldName);
|
|
1758
|
+
if (frontendFieldSchema && !frontendConfig) {
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const usageCount = metadata.totalCount - metadata.emptyCount;
|
|
1762
|
+
const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
|
|
1763
|
+
let uiType = "short_text";
|
|
1764
|
+
const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
|
|
1765
|
+
const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
|
|
1766
|
+
nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
|
|
1767
|
+
metadata.maxLength <= 100 && // Reasonably short values only
|
|
1768
|
+
metadata.uniqueValues.size / nonEmptyCount <= 0.3;
|
|
1769
|
+
if (metadata.hasMultipleLines || metadata.maxLength > 500) {
|
|
1770
|
+
uiType = "textarea";
|
|
1771
|
+
} else if (isDropdownCandidate) {
|
|
1772
|
+
uiType = "select";
|
|
1773
|
+
} else if (metadata.type === "boolean") {
|
|
1774
|
+
uiType = "checkbox";
|
|
1775
|
+
} else if (metadata.type === "number") {
|
|
1776
|
+
uiType = "number";
|
|
1777
|
+
} else if (metadata.type === "date") {
|
|
1778
|
+
uiType = "date";
|
|
1779
|
+
} else if (metadata.maxLength <= 50) {
|
|
1780
|
+
uiType = "short_text";
|
|
1781
|
+
} else if (metadata.maxLength <= 200) {
|
|
1782
|
+
uiType = "medium_text";
|
|
1783
|
+
} else {
|
|
1784
|
+
uiType = "long_text";
|
|
1785
|
+
}
|
|
1786
|
+
let category = frontendConfig?.category || "custom";
|
|
1787
|
+
if (!frontendConfig) {
|
|
1788
|
+
if (fieldName.includes("status") || fieldName.includes("state")) {
|
|
1789
|
+
category = "compliance";
|
|
1790
|
+
} else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
|
|
1791
|
+
category = "core";
|
|
1792
|
+
} else if (fieldName.includes("note") || fieldName.includes("comment")) {
|
|
1793
|
+
category = "notes";
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
const isControlIdField = fieldName === controlIdFieldNameClean;
|
|
1797
|
+
const fieldDef = {
|
|
1798
|
+
type: metadata.type,
|
|
1799
|
+
ui_type: uiType,
|
|
1800
|
+
is_array: false,
|
|
1801
|
+
max_length: metadata.maxLength,
|
|
1802
|
+
usage_count: usageCount,
|
|
1803
|
+
usage_percentage: usagePercentage,
|
|
1804
|
+
required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
|
|
1805
|
+
// Control ID is always required
|
|
1806
|
+
visible: frontendConfig?.tab !== "hidden",
|
|
1807
|
+
show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
|
|
1808
|
+
// Always show control ID in table
|
|
1809
|
+
editable: isControlIdField ? false : true,
|
|
1810
|
+
// Control ID is not editable
|
|
1811
|
+
display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
|
|
1812
|
+
// Control ID is always first
|
|
1813
|
+
category: isControlIdField ? "core" : category,
|
|
1814
|
+
// Control ID is always core
|
|
1815
|
+
tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
|
|
1816
|
+
// Control ID is always in overview
|
|
1817
|
+
};
|
|
1818
|
+
if (uiType === "select") {
|
|
1819
|
+
fieldDef.options = Array.from(metadata.uniqueValues).sort();
|
|
1820
|
+
}
|
|
1821
|
+
if (frontendConfig?.originalName || metadata.originalName) {
|
|
1822
|
+
fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
|
|
1823
|
+
}
|
|
1824
|
+
fields[fieldName] = fieldDef;
|
|
1825
|
+
});
|
|
1826
|
+
const fieldSchema = {
|
|
1827
|
+
fields,
|
|
1828
|
+
total_controls: controls.length,
|
|
1829
|
+
analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1830
|
+
};
|
|
1831
|
+
const controlSetData = {
|
|
1832
|
+
name: controlSetName,
|
|
1833
|
+
description: controlSetDescription,
|
|
1834
|
+
version: "1.0.0",
|
|
1835
|
+
control_id_field: controlIdFieldNameClean,
|
|
1836
|
+
// Add this to indicate which field is the control ID
|
|
1837
|
+
controlCount: controls.length,
|
|
1838
|
+
families: uniqueFamilies,
|
|
1839
|
+
fieldSchema
|
|
1840
|
+
};
|
|
1841
|
+
writeFileSync2(join4(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
|
|
1842
|
+
const controlsDir = join4(baseDir, "controls");
|
|
1843
|
+
families.forEach((familyControls, family) => {
|
|
1844
|
+
const familyDir = join4(controlsDir, family);
|
|
1845
|
+
if (!existsSync3(familyDir)) {
|
|
1846
|
+
mkdirSync2(familyDir, { recursive: true });
|
|
1847
|
+
}
|
|
1848
|
+
familyControls.forEach((control) => {
|
|
1849
|
+
const controlId = control[controlIdFieldNameClean];
|
|
1850
|
+
if (!controlId) {
|
|
1851
|
+
console.error("Missing control ID for control:", control);
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
const controlIdStr = String(controlId).slice(0, 50);
|
|
1855
|
+
const fileName2 = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
|
|
1856
|
+
const filePath = join4(familyDir, fileName2);
|
|
1857
|
+
const filteredControl = {};
|
|
1858
|
+
if (control.family !== void 0) {
|
|
1859
|
+
filteredControl.family = control.family;
|
|
1860
|
+
}
|
|
1861
|
+
Object.keys(control).forEach((fieldName) => {
|
|
1862
|
+
if (fieldName === "family") return;
|
|
1863
|
+
const isInFrontendSchema = frontendFieldSchema?.some((f) => f.fieldName === fieldName);
|
|
1864
|
+
const isInFieldsMetadata = fields.hasOwnProperty(fieldName);
|
|
1865
|
+
if (isInFrontendSchema || isInFieldsMetadata) {
|
|
1866
|
+
filteredControl[fieldName] = control[fieldName];
|
|
1867
|
+
}
|
|
1868
|
+
});
|
|
1869
|
+
writeFileSync2(filePath, yaml4.dump(filteredControl));
|
|
1870
|
+
});
|
|
1871
|
+
});
|
|
1872
|
+
res.json({
|
|
1873
|
+
success: true,
|
|
1874
|
+
controlCount: controls.length,
|
|
1875
|
+
families: Array.from(families.keys()),
|
|
1876
|
+
outputDir: folderName
|
|
1877
|
+
// Return just the folder name, not full path
|
|
1878
|
+
});
|
|
1879
|
+
} catch (error) {
|
|
1880
|
+
console.error("Error processing spreadsheet:", error);
|
|
1881
|
+
res.status(500).json({ error: "Failed to process spreadsheet" });
|
|
1882
|
+
}
|
|
1883
|
+
});
|
|
1884
|
+
router.get("/export-controls", async (req, res) => {
|
|
1885
|
+
try {
|
|
1886
|
+
const format = req.query.format || "csv";
|
|
1887
|
+
const state = getServerState();
|
|
1888
|
+
const fileStore = state.fileStore;
|
|
1889
|
+
if (!fileStore) {
|
|
1890
|
+
return res.status(500).json({ error: "No control set loaded" });
|
|
1891
|
+
}
|
|
1892
|
+
const controls = await fileStore.loadAllControls();
|
|
1893
|
+
const mappings = await fileStore.loadMappings();
|
|
1894
|
+
let metadata = {};
|
|
1895
|
+
try {
|
|
1896
|
+
const metadataPath2 = join4(state.CONTROL_SET_DIR, "lula.yaml");
|
|
1897
|
+
if (existsSync3(metadataPath2)) {
|
|
1898
|
+
const metadataContent = readFileSync3(metadataPath2, "utf8");
|
|
1899
|
+
metadata = yaml4.load(metadataContent);
|
|
1900
|
+
}
|
|
1901
|
+
} catch (err) {
|
|
1902
|
+
debug("Could not load metadata:", err);
|
|
1903
|
+
}
|
|
1904
|
+
if (!controls || controls.length === 0) {
|
|
1905
|
+
return res.status(404).json({ error: "No controls found" });
|
|
1906
|
+
}
|
|
1907
|
+
const controlsWithMappings = controls.map((control) => {
|
|
1908
|
+
const controlIdField = metadata?.control_id_field || "id";
|
|
1909
|
+
const controlId = control[controlIdField] || control.id;
|
|
1910
|
+
const controlMappings = mappings.filter((m) => m.control_id === controlId);
|
|
1911
|
+
return {
|
|
1912
|
+
...control,
|
|
1913
|
+
mappings_count: controlMappings.length,
|
|
1914
|
+
mappings: controlMappings.map((m) => ({
|
|
1915
|
+
uuid: m.uuid,
|
|
1916
|
+
status: m.status,
|
|
1917
|
+
description: m.justification || ""
|
|
1918
|
+
}))
|
|
1919
|
+
};
|
|
1920
|
+
});
|
|
1921
|
+
debug(`Exporting ${controlsWithMappings.length} controls as ${format}`);
|
|
1922
|
+
switch (format.toLowerCase()) {
|
|
1923
|
+
case "csv":
|
|
1924
|
+
return exportAsCSV(controlsWithMappings, metadata, res);
|
|
1925
|
+
case "excel":
|
|
1926
|
+
case "xlsx":
|
|
1927
|
+
return await exportAsExcel(controlsWithMappings, metadata, res);
|
|
1928
|
+
case "json":
|
|
1929
|
+
return exportAsJSON(controlsWithMappings, metadata, res);
|
|
1930
|
+
default:
|
|
1931
|
+
return res.status(400).json({ error: `Unsupported format: ${format}` });
|
|
1932
|
+
}
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
console.error("Export error:", error);
|
|
1935
|
+
res.status(500).json({ error: error.message });
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
router.post("/parse-excel", upload.single("file"), async (req, res) => {
|
|
1939
|
+
try {
|
|
1940
|
+
if (!req.file) {
|
|
1941
|
+
return res.status(400).json({ error: "No file uploaded" });
|
|
1942
|
+
}
|
|
1943
|
+
const fileName = req.file.originalname || "";
|
|
1944
|
+
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
1945
|
+
let sheets = [];
|
|
1946
|
+
let rows = [];
|
|
1947
|
+
if (isCSV) {
|
|
1948
|
+
const csvContent = req.file.buffer.toString("utf-8");
|
|
1949
|
+
rows = parseCSV(csvContent);
|
|
1950
|
+
sheets = ["Sheet1"];
|
|
1951
|
+
} else {
|
|
1952
|
+
const workbook = new ExcelJS.Workbook();
|
|
1953
|
+
const buffer = Buffer.from(req.file.buffer);
|
|
1954
|
+
await workbook.xlsx.load(buffer);
|
|
1955
|
+
sheets = workbook.worksheets.map((ws) => ws.name);
|
|
1956
|
+
const worksheet = workbook.worksheets[0];
|
|
1957
|
+
if (!worksheet) {
|
|
1958
|
+
return res.status(400).json({ error: "No worksheet found in file" });
|
|
1959
|
+
}
|
|
1960
|
+
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
1961
|
+
const rowData = [];
|
|
1962
|
+
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
1963
|
+
rowData[colNumber - 1] = cell.value;
|
|
1964
|
+
});
|
|
1965
|
+
rows.push(rowData);
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
const headerCandidates = rows.slice(0, 5).map((row, index) => ({
|
|
1969
|
+
row: index + 1,
|
|
1970
|
+
preview: row.slice(0, 4).filter((v) => v != null).join(", ") + (row.length > 4 ? ", ..." : "")
|
|
1971
|
+
}));
|
|
1972
|
+
res.json({
|
|
1973
|
+
sheets,
|
|
1974
|
+
selectedSheet: sheets[0],
|
|
1975
|
+
rowPreviews: headerCandidates,
|
|
1976
|
+
totalRows: rows.length,
|
|
1977
|
+
sampleData: rows.slice(0, 10)
|
|
1978
|
+
// First 10 rows for preview
|
|
1979
|
+
});
|
|
1980
|
+
} catch (error) {
|
|
1981
|
+
console.error("Error parsing Excel file:", error);
|
|
1982
|
+
res.status(500).json({ error: "Failed to parse Excel file" });
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
router.post("/parse-excel-sheet", upload.single("file"), async (req, res) => {
|
|
1986
|
+
try {
|
|
1987
|
+
const { sheetName, headerRow } = req.body;
|
|
1988
|
+
if (!req.file) {
|
|
1989
|
+
return res.status(400).json({ error: "No file uploaded" });
|
|
1990
|
+
}
|
|
1991
|
+
const fileName = req.file.originalname || "";
|
|
1992
|
+
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
1993
|
+
let rows = [];
|
|
1994
|
+
if (isCSV) {
|
|
1995
|
+
const csvContent = req.file.buffer.toString("utf-8");
|
|
1996
|
+
rows = parseCSV(csvContent);
|
|
1997
|
+
} else {
|
|
1998
|
+
const workbook = new ExcelJS.Workbook();
|
|
1999
|
+
const buffer = Buffer.from(req.file.buffer);
|
|
2000
|
+
await workbook.xlsx.load(buffer);
|
|
2001
|
+
const worksheet = workbook.getWorksheet(sheetName);
|
|
2002
|
+
if (!worksheet) {
|
|
2003
|
+
return res.status(400).json({ error: `Sheet "${sheetName}" not found` });
|
|
2004
|
+
}
|
|
2005
|
+
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
2006
|
+
const rowData = [];
|
|
2007
|
+
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
2008
|
+
rowData[colNumber - 1] = cell.value;
|
|
2009
|
+
});
|
|
2010
|
+
rows.push(rowData);
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
const headerRowIndex = parseInt(headerRow) - 1;
|
|
2014
|
+
const headers = rows[headerRowIndex] || [];
|
|
2015
|
+
const fields = headers.filter((h) => h && typeof h === "string");
|
|
2016
|
+
const sampleData = rows.slice(headerRowIndex + 1, headerRowIndex + 4).map((row) => {
|
|
2017
|
+
const obj = {};
|
|
2018
|
+
headers.forEach((header, index) => {
|
|
2019
|
+
if (header) {
|
|
2020
|
+
obj[header] = row[index];
|
|
2021
|
+
}
|
|
2022
|
+
});
|
|
2023
|
+
return obj;
|
|
2024
|
+
});
|
|
2025
|
+
res.json({
|
|
2026
|
+
fields,
|
|
2027
|
+
sampleData,
|
|
2028
|
+
controlCount: rows.length - headerRowIndex - 1
|
|
2029
|
+
});
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
console.error("Error parsing Excel sheet:", error);
|
|
2032
|
+
res.status(500).json({ error: "Failed to parse Excel sheet" });
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
spreadsheetRoutes_default = router;
|
|
2036
|
+
}
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
// cli/commands/ui.ts
|
|
2040
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2041
|
+
import open from "open";
|
|
2042
|
+
import { join as join7 } from "path";
|
|
2043
|
+
|
|
2044
|
+
// cli/server/server.ts
|
|
2045
|
+
init_serverState();
|
|
2046
|
+
init_spreadsheetRoutes();
|
|
2047
|
+
import cors from "cors";
|
|
2048
|
+
import express2 from "express";
|
|
2049
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3 } from "fs";
|
|
2050
|
+
import { createServer as createHttpServer } from "http";
|
|
2051
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
2052
|
+
import { fileURLToPath } from "url";
|
|
2053
|
+
|
|
2054
|
+
// cli/server/websocketServer.ts
|
|
2055
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
2056
|
+
init_debug();
|
|
2057
|
+
init_controlHelpers();
|
|
2058
|
+
init_serverState();
|
|
2059
|
+
import * as yaml5 from "js-yaml";
|
|
2060
|
+
import { join as join5 } from "path";
|
|
2061
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
2062
|
+
var WebSocketManager = class {
|
|
2063
|
+
wss = null;
|
|
2064
|
+
clients = /* @__PURE__ */ new Set();
|
|
2065
|
+
/**
|
|
2066
|
+
* Handle incoming commands from WebSocket clients
|
|
2067
|
+
* @param message - The command message from the client
|
|
2068
|
+
* @param ws - The WebSocket connection that sent the message
|
|
2069
|
+
*/
|
|
2070
|
+
async handleCommand(message, ws) {
|
|
2071
|
+
const { type, payload } = message;
|
|
2072
|
+
try {
|
|
2073
|
+
switch (type) {
|
|
2074
|
+
case "update-control": {
|
|
2075
|
+
const state = getServerState();
|
|
2076
|
+
if (payload && payload.id) {
|
|
2077
|
+
const existingControl = state.controlsCache.get(payload.id);
|
|
2078
|
+
if (!existingControl) {
|
|
2079
|
+
console.error("Control not found:", payload.id);
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
const updatedControl = { ...existingControl, ...payload };
|
|
2083
|
+
await state.fileStore.saveControl(updatedControl);
|
|
2084
|
+
state.controlsCache.set(updatedControl.id, updatedControl);
|
|
2085
|
+
const family = updatedControl.family || updatedControl.id.split("-")[0];
|
|
2086
|
+
if (!state.controlsByFamily.has(family)) {
|
|
2087
|
+
state.controlsByFamily.set(family, /* @__PURE__ */ new Set());
|
|
2088
|
+
}
|
|
2089
|
+
const familyControlIds = state.controlsByFamily.get(family);
|
|
2090
|
+
if (familyControlIds) {
|
|
2091
|
+
familyControlIds.add(updatedControl.id);
|
|
2092
|
+
}
|
|
2093
|
+
ws.send(
|
|
2094
|
+
JSON.stringify({
|
|
2095
|
+
type: "control-updated",
|
|
2096
|
+
payload: { id: payload.id, success: true }
|
|
2097
|
+
})
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
break;
|
|
2101
|
+
}
|
|
2102
|
+
case "refresh-controls": {
|
|
2103
|
+
const state = getServerState();
|
|
2104
|
+
state.controlsCache.clear();
|
|
2105
|
+
state.controlsByFamily.clear();
|
|
2106
|
+
const { loadAllData: loadAllData2 } = await Promise.resolve().then(() => (init_serverState(), serverState_exports));
|
|
2107
|
+
await loadAllData2();
|
|
2108
|
+
this.broadcastState();
|
|
2109
|
+
break;
|
|
2110
|
+
}
|
|
2111
|
+
case "switch-control-set": {
|
|
2112
|
+
if (payload && payload.path) {
|
|
2113
|
+
const { initializeServerState: initializeServerState2, loadAllData: loadAllData2 } = await Promise.resolve().then(() => (init_serverState(), serverState_exports));
|
|
2114
|
+
const currentState = getServerState();
|
|
2115
|
+
initializeServerState2(currentState.CONTROL_SET_DIR, payload.path);
|
|
2116
|
+
await loadAllData2();
|
|
2117
|
+
this.broadcastState();
|
|
2118
|
+
}
|
|
2119
|
+
break;
|
|
2120
|
+
}
|
|
2121
|
+
case "create-mapping": {
|
|
2122
|
+
const state = getServerState();
|
|
2123
|
+
if (payload && payload.control_id) {
|
|
2124
|
+
const mapping = payload;
|
|
2125
|
+
if (!mapping.uuid) {
|
|
2126
|
+
const crypto = await import("crypto");
|
|
2127
|
+
mapping.uuid = crypto.randomUUID();
|
|
2128
|
+
}
|
|
2129
|
+
await state.fileStore.saveMapping(mapping);
|
|
2130
|
+
state.mappingsCache.set(mapping.uuid, mapping);
|
|
2131
|
+
const family = mapping.control_id.split("-")[0];
|
|
2132
|
+
if (!state.mappingsByFamily.has(family)) {
|
|
2133
|
+
state.mappingsByFamily.set(family, /* @__PURE__ */ new Set());
|
|
2134
|
+
}
|
|
2135
|
+
state.mappingsByFamily.get(family)?.add(mapping.uuid);
|
|
2136
|
+
if (!state.mappingsByControl.has(mapping.control_id)) {
|
|
2137
|
+
state.mappingsByControl.set(mapping.control_id, /* @__PURE__ */ new Set());
|
|
2138
|
+
}
|
|
2139
|
+
state.mappingsByControl.get(mapping.control_id)?.add(mapping.uuid);
|
|
2140
|
+
ws.send(
|
|
2141
|
+
JSON.stringify({
|
|
2142
|
+
type: "mapping-created",
|
|
2143
|
+
payload: { uuid: mapping.uuid, success: true }
|
|
2144
|
+
})
|
|
2145
|
+
);
|
|
2146
|
+
this.broadcastState();
|
|
2147
|
+
}
|
|
2148
|
+
break;
|
|
2149
|
+
}
|
|
2150
|
+
case "update-mapping": {
|
|
2151
|
+
const state = getServerState();
|
|
2152
|
+
if (payload && payload.uuid) {
|
|
2153
|
+
const mapping = payload;
|
|
2154
|
+
await state.fileStore.saveMapping(mapping);
|
|
2155
|
+
state.mappingsCache.set(mapping.uuid, mapping);
|
|
2156
|
+
ws.send(
|
|
2157
|
+
JSON.stringify({
|
|
2158
|
+
type: "mapping-updated",
|
|
2159
|
+
payload: { uuid: mapping.uuid, success: true }
|
|
2160
|
+
})
|
|
2161
|
+
);
|
|
2162
|
+
this.broadcastState();
|
|
2163
|
+
}
|
|
2164
|
+
break;
|
|
2165
|
+
}
|
|
2166
|
+
case "delete-mapping": {
|
|
2167
|
+
const state = getServerState();
|
|
2168
|
+
if (payload && payload.uuid) {
|
|
2169
|
+
const uuid = payload.uuid;
|
|
2170
|
+
const mapping = state.mappingsCache.get(uuid);
|
|
2171
|
+
if (mapping) {
|
|
2172
|
+
await state.fileStore.deleteMapping(uuid);
|
|
2173
|
+
state.mappingsCache.delete(uuid);
|
|
2174
|
+
const family = mapping.control_id.split("-")[0];
|
|
2175
|
+
state.mappingsByFamily.get(family)?.delete(uuid);
|
|
2176
|
+
state.mappingsByControl.get(mapping.control_id)?.delete(uuid);
|
|
2177
|
+
ws.send(
|
|
2178
|
+
JSON.stringify({
|
|
2179
|
+
type: "mapping-deleted",
|
|
2180
|
+
payload: { uuid, success: true }
|
|
2181
|
+
})
|
|
2182
|
+
);
|
|
2183
|
+
this.broadcastState();
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
break;
|
|
2187
|
+
}
|
|
2188
|
+
case "scan-control-sets": {
|
|
2189
|
+
const { scanControlSets: scanControlSets2 } = await Promise.resolve().then(() => (init_spreadsheetRoutes(), spreadsheetRoutes_exports));
|
|
2190
|
+
try {
|
|
2191
|
+
const controlSets = await scanControlSets2();
|
|
2192
|
+
ws.send(
|
|
2193
|
+
JSON.stringify({
|
|
2194
|
+
type: "control-sets-list",
|
|
2195
|
+
payload: controlSets
|
|
2196
|
+
})
|
|
2197
|
+
);
|
|
2198
|
+
} catch (error) {
|
|
2199
|
+
console.error("Error scanning control sets:", error);
|
|
2200
|
+
ws.send(
|
|
2201
|
+
JSON.stringify({
|
|
2202
|
+
type: "error",
|
|
2203
|
+
payload: { message: `Failed to scan control sets: ${error.message}` }
|
|
2204
|
+
})
|
|
2205
|
+
);
|
|
2206
|
+
}
|
|
2207
|
+
break;
|
|
2208
|
+
}
|
|
2209
|
+
case "get-control": {
|
|
2210
|
+
if (payload && payload.id) {
|
|
2211
|
+
const { FileStore: FileStore2 } = await Promise.resolve().then(() => (init_fileStore(), fileStore_exports));
|
|
2212
|
+
const currentPath = getCurrentControlSetPath();
|
|
2213
|
+
const fileStore = new FileStore2({ baseDir: currentPath });
|
|
2214
|
+
const controlId = payload.id;
|
|
2215
|
+
const control = await fileStore.loadControl(controlId);
|
|
2216
|
+
if (control) {
|
|
2217
|
+
if (!control.id) {
|
|
2218
|
+
control.id = controlId;
|
|
2219
|
+
}
|
|
2220
|
+
const { GitHistoryUtil: GitHistoryUtil2 } = await Promise.resolve().then(() => (init_gitHistory(), gitHistory_exports));
|
|
2221
|
+
const { execSync } = await import("child_process");
|
|
2222
|
+
let timeline = null;
|
|
2223
|
+
try {
|
|
2224
|
+
const currentPath2 = getCurrentControlSetPath();
|
|
2225
|
+
const { existsSync: existsSync6 } = await import("fs");
|
|
2226
|
+
const family = control.family || control.id.split("-")[0];
|
|
2227
|
+
const familyDir = join5(currentPath2, "controls", family);
|
|
2228
|
+
const possibleFilenames = [
|
|
2229
|
+
`${control.id}.yaml`,
|
|
2230
|
+
`${control.id.replace(/\./g, "_")}.yaml`,
|
|
2231
|
+
// AC-1.1 -> AC-1_1.yaml
|
|
2232
|
+
`${control.id.replace(/[^\w\-]/g, "_")}.yaml`
|
|
2233
|
+
// General sanitization
|
|
2234
|
+
];
|
|
2235
|
+
let controlPath = "";
|
|
2236
|
+
for (const filename of possibleFilenames) {
|
|
2237
|
+
const testPath = join5(familyDir, filename);
|
|
2238
|
+
if (existsSync6(testPath)) {
|
|
2239
|
+
controlPath = testPath;
|
|
2240
|
+
break;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
if (!controlPath) {
|
|
2244
|
+
for (const filename of possibleFilenames) {
|
|
2245
|
+
const testPath = join5(currentPath2, "controls", filename);
|
|
2246
|
+
if (existsSync6(testPath)) {
|
|
2247
|
+
controlPath = testPath;
|
|
2248
|
+
break;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
debug(`Getting timeline for control ${control.id}:`);
|
|
2253
|
+
debug(` Current path: ${currentPath2}`);
|
|
2254
|
+
debug(` Control path found: ${controlPath}`);
|
|
2255
|
+
debug(` File exists: ${existsSync6(controlPath)}`);
|
|
2256
|
+
if (!controlPath) {
|
|
2257
|
+
console.error(`Could not find file for control ${control.id}`);
|
|
2258
|
+
timeline = null;
|
|
2259
|
+
} else {
|
|
2260
|
+
const gitUtil = new GitHistoryUtil2(currentPath2);
|
|
2261
|
+
const controlHistory = await gitUtil.getFileHistory(controlPath);
|
|
2262
|
+
debug(`Git history for ${control.id}:`, {
|
|
2263
|
+
path: controlPath,
|
|
2264
|
+
totalCommits: controlHistory.totalCommits,
|
|
2265
|
+
commits: controlHistory.commits?.length || 0
|
|
2266
|
+
});
|
|
2267
|
+
const mappingFilename = `${control.id}-mappings.yaml`;
|
|
2268
|
+
const mappingPath = join5(currentPath2, "mappings", family, mappingFilename);
|
|
2269
|
+
let mappingHistory = { commits: [], totalCommits: 0 };
|
|
2270
|
+
if (existsSync6(mappingPath)) {
|
|
2271
|
+
mappingHistory = await gitUtil.getFileHistory(mappingPath);
|
|
2272
|
+
debug(`Mapping history for ${control.id}:`, {
|
|
2273
|
+
path: mappingPath,
|
|
2274
|
+
totalCommits: mappingHistory.totalCommits,
|
|
2275
|
+
commits: mappingHistory.commits?.length || 0
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
let hasPendingChanges = false;
|
|
2279
|
+
try {
|
|
2280
|
+
try {
|
|
2281
|
+
execSync(`git ls-files --error-unmatch "${controlPath}"`, {
|
|
2282
|
+
encoding: "utf8",
|
|
2283
|
+
cwd: process.cwd(),
|
|
2284
|
+
stdio: "pipe"
|
|
2285
|
+
});
|
|
2286
|
+
const gitStatus = execSync(`git status --porcelain "${controlPath}"`, {
|
|
2287
|
+
encoding: "utf8",
|
|
2288
|
+
cwd: process.cwd()
|
|
2289
|
+
}).trim();
|
|
2290
|
+
hasPendingChanges = gitStatus.length > 0;
|
|
2291
|
+
if (hasPendingChanges) {
|
|
2292
|
+
debug(
|
|
2293
|
+
`Control ${payload.id} has pending changes: ${gitStatus.substring(0, 2)}`
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
} catch {
|
|
2297
|
+
hasPendingChanges = true;
|
|
2298
|
+
debug(`Control ${payload.id} is untracked (new file)`);
|
|
2299
|
+
}
|
|
2300
|
+
} catch {
|
|
2301
|
+
}
|
|
2302
|
+
if (existsSync6(mappingPath)) {
|
|
2303
|
+
try {
|
|
2304
|
+
try {
|
|
2305
|
+
execSync(`git ls-files --error-unmatch "${mappingPath}"`, {
|
|
2306
|
+
encoding: "utf8",
|
|
2307
|
+
cwd: process.cwd(),
|
|
2308
|
+
stdio: "pipe"
|
|
2309
|
+
});
|
|
2310
|
+
const gitStatus = execSync(`git status --porcelain "${mappingPath}"`, {
|
|
2311
|
+
encoding: "utf8",
|
|
2312
|
+
cwd: process.cwd()
|
|
2313
|
+
}).trim();
|
|
2314
|
+
if (gitStatus.length > 0) {
|
|
2315
|
+
hasPendingChanges = true;
|
|
2316
|
+
debug(`Mapping file has pending changes: ${gitStatus.substring(0, 2)}`);
|
|
2317
|
+
}
|
|
2318
|
+
} catch {
|
|
2319
|
+
hasPendingChanges = true;
|
|
2320
|
+
debug(`Mapping file is untracked`);
|
|
2321
|
+
}
|
|
2322
|
+
} catch {
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
const allCommits = [
|
|
2326
|
+
...(controlHistory.commits || []).map((c) => ({
|
|
2327
|
+
...c,
|
|
2328
|
+
source: "control"
|
|
2329
|
+
})),
|
|
2330
|
+
...(mappingHistory.commits || []).map((c) => ({ ...c, source: "mapping" }))
|
|
2331
|
+
];
|
|
2332
|
+
allCommits.sort(
|
|
2333
|
+
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
|
2334
|
+
);
|
|
2335
|
+
timeline = {
|
|
2336
|
+
commits: allCommits,
|
|
2337
|
+
totalCommits: controlHistory.totalCommits + mappingHistory.totalCommits,
|
|
2338
|
+
controlCommits: controlHistory.totalCommits || 0,
|
|
2339
|
+
mappingCommits: mappingHistory.totalCommits || 0,
|
|
2340
|
+
hasPendingChanges
|
|
2341
|
+
};
|
|
2342
|
+
if (timeline.totalCommits === 0 && hasPendingChanges) {
|
|
2343
|
+
debug(`No git history for control ${payload.id} - showing as pending`);
|
|
2344
|
+
timeline.commits = [
|
|
2345
|
+
{
|
|
2346
|
+
hash: "pending",
|
|
2347
|
+
shortHash: "pending",
|
|
2348
|
+
author: "Current User",
|
|
2349
|
+
authorEmail: "",
|
|
2350
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2351
|
+
message: "Pending changes (uncommitted)",
|
|
2352
|
+
isPending: true,
|
|
2353
|
+
changes: {
|
|
2354
|
+
insertions: 0,
|
|
2355
|
+
deletions: 0,
|
|
2356
|
+
files: 1
|
|
2357
|
+
},
|
|
2358
|
+
source: "control"
|
|
2359
|
+
}
|
|
2360
|
+
];
|
|
2361
|
+
timeline.totalCommits = 1;
|
|
2362
|
+
} else if (hasPendingChanges && timeline.totalCommits > 0) {
|
|
2363
|
+
timeline.commits.unshift({
|
|
2364
|
+
hash: "pending",
|
|
2365
|
+
shortHash: "pending",
|
|
2366
|
+
author: "Current User",
|
|
2367
|
+
authorEmail: "",
|
|
2368
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2369
|
+
message: "Pending changes (uncommitted)",
|
|
2370
|
+
isPending: true,
|
|
2371
|
+
changes: {
|
|
2372
|
+
insertions: 0,
|
|
2373
|
+
deletions: 0,
|
|
2374
|
+
files: 1
|
|
2375
|
+
},
|
|
2376
|
+
source: "control"
|
|
2377
|
+
});
|
|
2378
|
+
timeline.totalCommits += 1;
|
|
2379
|
+
}
|
|
2380
|
+
debug(`Final timeline for ${control.id}:`, {
|
|
2381
|
+
totalCommits: timeline.totalCommits,
|
|
2382
|
+
commits: timeline.commits?.length || 0,
|
|
2383
|
+
hasPending: timeline.hasPendingChanges
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
} catch (error) {
|
|
2387
|
+
console.error("Error fetching timeline:", error);
|
|
2388
|
+
timeline = null;
|
|
2389
|
+
}
|
|
2390
|
+
ws.send(
|
|
2391
|
+
JSON.stringify({
|
|
2392
|
+
type: "control-details",
|
|
2393
|
+
payload: { ...control, timeline }
|
|
2394
|
+
})
|
|
2395
|
+
);
|
|
2396
|
+
} else {
|
|
2397
|
+
ws.send(
|
|
2398
|
+
JSON.stringify({
|
|
2399
|
+
type: "error",
|
|
2400
|
+
payload: { message: `Control not found: ${payload.id}` }
|
|
2401
|
+
})
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
break;
|
|
2406
|
+
}
|
|
2407
|
+
default:
|
|
2408
|
+
console.warn("Unknown command type:", type);
|
|
2409
|
+
ws.send(
|
|
2410
|
+
JSON.stringify({
|
|
2411
|
+
type: "error",
|
|
2412
|
+
payload: { message: `Unknown command: ${type}` }
|
|
2413
|
+
})
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
} catch (error) {
|
|
2417
|
+
console.error("Error handling command:", error);
|
|
2418
|
+
ws.send(
|
|
2419
|
+
JSON.stringify({
|
|
2420
|
+
type: "error",
|
|
2421
|
+
payload: { message: error.message }
|
|
2422
|
+
})
|
|
2423
|
+
);
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Get the complete application state for broadcasting
|
|
2428
|
+
* @returns The complete state object or null if error
|
|
2429
|
+
*/
|
|
2430
|
+
getCompleteState() {
|
|
2431
|
+
try {
|
|
2432
|
+
const state = getServerState();
|
|
2433
|
+
const currentPath = getCurrentControlSetPath();
|
|
2434
|
+
let controlSetData = {};
|
|
2435
|
+
try {
|
|
2436
|
+
const controlSetFile = join5(currentPath, "lula.yaml");
|
|
2437
|
+
const content = readFileSync4(controlSetFile, "utf8");
|
|
2438
|
+
controlSetData = yaml5.load(content);
|
|
2439
|
+
} catch {
|
|
2440
|
+
controlSetData = {
|
|
2441
|
+
id: "unknown",
|
|
2442
|
+
name: "Unknown Control Set"
|
|
2443
|
+
};
|
|
2444
|
+
}
|
|
2445
|
+
const controlsMetadata = Array.from(state.controlsCache.values()).map((control) => {
|
|
2446
|
+
if (!control.id) {
|
|
2447
|
+
control.id = getControlId(control, currentPath);
|
|
2448
|
+
}
|
|
2449
|
+
const metadata = {
|
|
2450
|
+
id: control.id,
|
|
2451
|
+
family: control.family
|
|
2452
|
+
};
|
|
2453
|
+
const fieldSchema = controlSetData.fieldSchema?.fields || {};
|
|
2454
|
+
for (const [fieldName, fieldConfig] of Object.entries(fieldSchema)) {
|
|
2455
|
+
if (fieldConfig.tab === "overview" && control[fieldName] !== void 0) {
|
|
2456
|
+
metadata[fieldName] = control[fieldName];
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
if (control.title !== void 0) metadata.title = control.title;
|
|
2460
|
+
if (control.implementation_status !== void 0)
|
|
2461
|
+
metadata.implementation_status = control.implementation_status;
|
|
2462
|
+
if (control.compliance_status !== void 0)
|
|
2463
|
+
metadata.compliance_status = control.compliance_status;
|
|
2464
|
+
return metadata;
|
|
2465
|
+
});
|
|
2466
|
+
return {
|
|
2467
|
+
...controlSetData,
|
|
2468
|
+
// Spread control set properties at root level
|
|
2469
|
+
currentPath,
|
|
2470
|
+
controls: controlsMetadata,
|
|
2471
|
+
// Send lightweight metadata instead of full controls
|
|
2472
|
+
mappings: Array.from(state.mappingsCache.values()),
|
|
2473
|
+
families: Array.from(state.controlsByFamily.keys()).sort(),
|
|
2474
|
+
totalControls: state.controlsCache.size,
|
|
2475
|
+
totalMappings: state.mappingsCache.size
|
|
2476
|
+
};
|
|
2477
|
+
} catch (error) {
|
|
2478
|
+
console.error("Error getting complete state:", error);
|
|
2479
|
+
return null;
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Send state updates to client in chunks for better performance
|
|
2484
|
+
* @param ws - The WebSocket connection to send to
|
|
2485
|
+
* @param fullData - Whether to send full data or summaries
|
|
2486
|
+
*/
|
|
2487
|
+
sendStateInChunks(ws, fullData = false) {
|
|
2488
|
+
try {
|
|
2489
|
+
const state = getServerState();
|
|
2490
|
+
const currentPath = getCurrentControlSetPath();
|
|
2491
|
+
let controlSetData = {};
|
|
2492
|
+
try {
|
|
2493
|
+
const controlSetFile = join5(currentPath, "lula.yaml");
|
|
2494
|
+
const content = readFileSync4(controlSetFile, "utf8");
|
|
2495
|
+
controlSetData = yaml5.load(content);
|
|
2496
|
+
} catch {
|
|
2497
|
+
controlSetData = {
|
|
2498
|
+
id: "unknown",
|
|
2499
|
+
name: "Unknown Control Set"
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
ws.send(
|
|
2503
|
+
JSON.stringify({
|
|
2504
|
+
type: "metadata-update",
|
|
2505
|
+
payload: {
|
|
2506
|
+
...controlSetData,
|
|
2507
|
+
currentPath,
|
|
2508
|
+
families: Array.from(state.controlsByFamily.keys()).sort(),
|
|
2509
|
+
totalControls: state.controlsCache.size,
|
|
2510
|
+
totalMappings: state.mappingsCache.size
|
|
2511
|
+
}
|
|
2512
|
+
})
|
|
2513
|
+
);
|
|
2514
|
+
const controlSummaries = Array.from(state.controlsCache.values()).map((control) => {
|
|
2515
|
+
const controlId = control.id || getControlId(control, currentPath);
|
|
2516
|
+
const summary = {
|
|
2517
|
+
id: controlId,
|
|
2518
|
+
family: control.family || control["control-acronym"]?.toString().split("-")[0] || ""
|
|
2519
|
+
};
|
|
2520
|
+
if (controlSetData.field_schema?.fields) {
|
|
2521
|
+
for (const [fieldName] of Object.entries(controlSetData.field_schema.fields)) {
|
|
2522
|
+
if (control[fieldName] !== void 0) {
|
|
2523
|
+
summary[fieldName] = control[fieldName];
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
} else {
|
|
2527
|
+
Object.assign(summary, control);
|
|
2528
|
+
}
|
|
2529
|
+
return summary;
|
|
2530
|
+
});
|
|
2531
|
+
setTimeout(() => {
|
|
2532
|
+
ws.send(
|
|
2533
|
+
JSON.stringify({
|
|
2534
|
+
type: "controls-update",
|
|
2535
|
+
payload: fullData ? Array.from(state.controlsCache.values()) : controlSummaries
|
|
2536
|
+
})
|
|
2537
|
+
);
|
|
2538
|
+
}, 10);
|
|
2539
|
+
setTimeout(() => {
|
|
2540
|
+
ws.send(
|
|
2541
|
+
JSON.stringify({
|
|
2542
|
+
type: "mappings-update",
|
|
2543
|
+
payload: Array.from(state.mappingsCache.values())
|
|
2544
|
+
})
|
|
2545
|
+
);
|
|
2546
|
+
}, 20);
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
console.error("Error sending state in chunks:", error);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* Initialize the WebSocket server
|
|
2553
|
+
* @param server - The HTTP server to attach to
|
|
2554
|
+
*/
|
|
2555
|
+
initialize(server) {
|
|
2556
|
+
this.wss = new WebSocketServer({ server, path: "/ws" });
|
|
2557
|
+
this.wss.on("connection", (ws) => {
|
|
2558
|
+
debug("New WebSocket client connected");
|
|
2559
|
+
this.clients.add(ws);
|
|
2560
|
+
const initialState = this.getCompleteState();
|
|
2561
|
+
if (initialState) {
|
|
2562
|
+
ws.send(
|
|
2563
|
+
JSON.stringify({
|
|
2564
|
+
type: "state-update",
|
|
2565
|
+
payload: initialState
|
|
2566
|
+
})
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
2569
|
+
ws.on("message", async (message) => {
|
|
2570
|
+
try {
|
|
2571
|
+
const data = JSON.parse(message.toString());
|
|
2572
|
+
debug("Received WebSocket message:", data);
|
|
2573
|
+
await this.handleCommand(data, ws);
|
|
2574
|
+
} catch (error) {
|
|
2575
|
+
console.error("Invalid WebSocket message:", error);
|
|
2576
|
+
ws.send(
|
|
2577
|
+
JSON.stringify({
|
|
2578
|
+
type: "error",
|
|
2579
|
+
payload: { message: "Invalid message format" }
|
|
2580
|
+
})
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2583
|
+
});
|
|
2584
|
+
ws.on("close", () => {
|
|
2585
|
+
debug("WebSocket client disconnected");
|
|
2586
|
+
this.clients.delete(ws);
|
|
2587
|
+
});
|
|
2588
|
+
ws.on("error", (error) => {
|
|
2589
|
+
console.error("WebSocket error:", error);
|
|
2590
|
+
this.clients.delete(ws);
|
|
2591
|
+
});
|
|
2592
|
+
ws.send(JSON.stringify({ type: "connected" }));
|
|
2593
|
+
this.sendStateInChunks(ws);
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Broadcast a message to all connected clients
|
|
2598
|
+
* @param message - The message to broadcast
|
|
2599
|
+
*/
|
|
2600
|
+
broadcast(message) {
|
|
2601
|
+
const data = JSON.stringify(message);
|
|
2602
|
+
this.clients.forEach((client) => {
|
|
2603
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
2604
|
+
client.send(data);
|
|
2605
|
+
}
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
/**
|
|
2609
|
+
* Broadcast the complete state to all connected clients
|
|
2610
|
+
*/
|
|
2611
|
+
broadcastState() {
|
|
2612
|
+
const completeState = this.getCompleteState();
|
|
2613
|
+
if (completeState) {
|
|
2614
|
+
this.broadcast({
|
|
2615
|
+
type: "state-update",
|
|
2616
|
+
payload: completeState
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* Notify all clients that a control was updated
|
|
2622
|
+
* @param _controlId - The ID of the updated control (unused but kept for API compatibility)
|
|
2623
|
+
*/
|
|
2624
|
+
notifyControlUpdate(_controlId) {
|
|
2625
|
+
this.broadcastState();
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* Notify all clients that a mapping was created
|
|
2629
|
+
* @param _mapping - The created mapping (unused but kept for API compatibility)
|
|
2630
|
+
*/
|
|
2631
|
+
notifyMappingCreated(_mapping) {
|
|
2632
|
+
this.broadcastState();
|
|
2633
|
+
}
|
|
2634
|
+
/**
|
|
2635
|
+
* Notify all clients that a mapping was updated
|
|
2636
|
+
* @param _mapping - The updated mapping (unused but kept for API compatibility)
|
|
2637
|
+
*/
|
|
2638
|
+
notifyMappingUpdated(_mapping) {
|
|
2639
|
+
this.broadcastState();
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Notify all clients that a mapping was deleted
|
|
2643
|
+
* @param _uuid - The UUID of the deleted mapping (unused but kept for API compatibility)
|
|
2644
|
+
*/
|
|
2645
|
+
notifyMappingDeleted(_uuid) {
|
|
2646
|
+
this.broadcastState();
|
|
2647
|
+
}
|
|
2648
|
+
/**
|
|
2649
|
+
* Notify all clients to refresh their data
|
|
2650
|
+
*/
|
|
2651
|
+
notifyDataRefresh() {
|
|
2652
|
+
this.broadcastState();
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2655
|
+
var wsManager = new WebSocketManager();
|
|
2656
|
+
|
|
2657
|
+
// cli/server/server.ts
|
|
2658
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
2659
|
+
var __dirname = dirname2(__filename);
|
|
2660
|
+
async function createServer(options) {
|
|
2661
|
+
const { controlSetDir, port } = options;
|
|
2662
|
+
if (!existsSync4(controlSetDir)) {
|
|
2663
|
+
mkdirSync3(controlSetDir, { recursive: true });
|
|
2664
|
+
}
|
|
2665
|
+
initializeServerState(controlSetDir);
|
|
2666
|
+
await loadAllData();
|
|
2667
|
+
const app = express2();
|
|
2668
|
+
app.use(cors());
|
|
2669
|
+
app.use(express2.json({ limit: "50mb" }));
|
|
2670
|
+
const distPath = join6(__dirname, "../dist");
|
|
2671
|
+
app.use(express2.static(distPath));
|
|
2672
|
+
app.use("/api", spreadsheetRoutes_default);
|
|
2673
|
+
app.get("*", (req, res) => {
|
|
2674
|
+
res.sendFile(join6(distPath, "index.html"));
|
|
2675
|
+
});
|
|
2676
|
+
const httpServer = createHttpServer(app);
|
|
2677
|
+
wsManager.initialize(httpServer);
|
|
2678
|
+
return {
|
|
2679
|
+
app,
|
|
2680
|
+
start: () => {
|
|
2681
|
+
return new Promise((resolve) => {
|
|
2682
|
+
httpServer.listen(port, () => {
|
|
2683
|
+
console.log(`
|
|
2684
|
+
\u2728 Lula is running at http://localhost:${port}`);
|
|
2685
|
+
resolve();
|
|
2686
|
+
});
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
async function startServer(options) {
|
|
2692
|
+
const server = await createServer(options);
|
|
2693
|
+
await server.start();
|
|
2694
|
+
if (process.stdin.isTTY) {
|
|
2695
|
+
process.stdin.setRawMode(true);
|
|
2696
|
+
process.stdin.resume();
|
|
2697
|
+
process.stdin.setEncoding("utf8");
|
|
2698
|
+
console.log("\nPress ESC to close the app\n");
|
|
2699
|
+
process.stdin.on("data", async (key) => {
|
|
2700
|
+
const keyStr = key.toString();
|
|
2701
|
+
if (keyStr === "\x1B" || keyStr === "") {
|
|
2702
|
+
console.log("\n\nShutting down server...");
|
|
2703
|
+
try {
|
|
2704
|
+
await saveMappingsToFile();
|
|
2705
|
+
console.log("Changes saved successfully");
|
|
2706
|
+
} catch (error) {
|
|
2707
|
+
console.error("Error saving changes:", error);
|
|
2708
|
+
}
|
|
2709
|
+
process.exit(0);
|
|
2710
|
+
}
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
process.on("SIGINT", async () => {
|
|
2714
|
+
try {
|
|
2715
|
+
await saveMappingsToFile();
|
|
2716
|
+
} catch (error) {
|
|
2717
|
+
console.error("Error saving changes:", error);
|
|
2718
|
+
}
|
|
2719
|
+
process.exit(0);
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
// cli/server/index.ts
|
|
2724
|
+
init_serverState();
|
|
2725
|
+
init_fileStore();
|
|
2726
|
+
init_gitHistory();
|
|
2727
|
+
|
|
2728
|
+
// cli/commands/ui.ts
|
|
2729
|
+
var UICommand = class _UICommand {
|
|
2730
|
+
/**
|
|
2731
|
+
* Register the serve command with Commander
|
|
2732
|
+
* @param program The Commander program instance
|
|
2733
|
+
* @param parentDebugGetter Function to get debug flag from parent command
|
|
2734
|
+
*/
|
|
2735
|
+
static register(program, parentDebugGetter) {
|
|
2736
|
+
return program.command("ui", { isDefault: true }).description("Start the Lula web interface (default)").option("--dir <directory>", "Control set directory path").option("--port <port>", "Server port", "3000").option("--no-open-browser", "Do not open browser when starting the server").action(async (options) => {
|
|
2737
|
+
if (parentDebugGetter()) {
|
|
2738
|
+
console.log("Debug mode enabled");
|
|
2739
|
+
const { setDebugMode: setDebugMode2 } = await Promise.resolve().then(() => (init_debug(), debug_exports));
|
|
2740
|
+
setDebugMode2(true);
|
|
2741
|
+
}
|
|
2742
|
+
const uiCommand = new _UICommand();
|
|
2743
|
+
const controlSetDir = options.dir || process.cwd();
|
|
2744
|
+
await uiCommand.run({
|
|
2745
|
+
dir: controlSetDir,
|
|
2746
|
+
port: parseInt(options.port),
|
|
2747
|
+
openBrowser: options.openBrowser !== false
|
|
2748
|
+
// Default to true unless explicitly disabled
|
|
2749
|
+
});
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
async run(options) {
|
|
2753
|
+
const { dir, port, openBrowser } = options;
|
|
2754
|
+
const controlSetPath = join7(dir, "lula.yaml");
|
|
2755
|
+
const hasControlSet = existsSync5(controlSetPath);
|
|
2756
|
+
await startServer({ controlSetDir: dir, port });
|
|
2757
|
+
if (openBrowser) {
|
|
2758
|
+
const url = `http://localhost:${port}`;
|
|
2759
|
+
if (!hasControlSet) {
|
|
2760
|
+
await open(`${url}/setup`);
|
|
2761
|
+
} else {
|
|
2762
|
+
await open(url);
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
export {
|
|
2768
|
+
UICommand
|
|
2769
|
+
};
|