@trustgraph/config 0.0.1 → 0.0.2
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/cli.js +385 -0
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -1,2 +1,387 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import { writeFileSync } from 'fs';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import jsonata from 'jsonata';
|
|
7
|
+
import { parseArgs } from 'util';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_API_BASE = 'https://config-svc.app.trustgraph.ai/api';
|
|
10
|
+
|
|
11
|
+
// Parse command line arguments
|
|
12
|
+
const { values: args } = parseArgs({
|
|
13
|
+
options: {
|
|
14
|
+
api: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
short: 'a',
|
|
17
|
+
default: DEFAULT_API_BASE,
|
|
18
|
+
},
|
|
19
|
+
help: {
|
|
20
|
+
type: 'boolean',
|
|
21
|
+
short: 'h',
|
|
22
|
+
default: false,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (args.help) {
|
|
28
|
+
console.log(`
|
|
29
|
+
TrustGraph Configuration CLI
|
|
30
|
+
|
|
31
|
+
Usage: tg-config [options]
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
-a, --api <url> API base URL (default: ${DEFAULT_API_BASE})
|
|
35
|
+
-h, --help Show this help message
|
|
36
|
+
`);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const apiBase = args.api.replace(/\/$/, '');
|
|
41
|
+
|
|
42
|
+
// Fetch helpers
|
|
43
|
+
const fetchYaml = async (endpoint) => {
|
|
44
|
+
const url = `${apiBase}${endpoint}`;
|
|
45
|
+
const response = await fetch(url);
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
|
48
|
+
}
|
|
49
|
+
return yaml.load(await response.text());
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const fetchText = async (endpoint) => {
|
|
53
|
+
const url = `${apiBase}${endpoint}`;
|
|
54
|
+
const response = await fetch(url);
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
|
57
|
+
}
|
|
58
|
+
return response.text();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const fetchDoc = async (path) => {
|
|
62
|
+
const url = `${apiBase}/docs/${path}`;
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(url);
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
return `*Documentation file not found: ${path}*`;
|
|
67
|
+
}
|
|
68
|
+
return response.text();
|
|
69
|
+
} catch {
|
|
70
|
+
return `*Documentation file not found: ${path}*`;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Evaluate JSONata condition
|
|
75
|
+
const evaluateCondition = async (condition, state) => {
|
|
76
|
+
if (!condition) return true;
|
|
77
|
+
try {
|
|
78
|
+
const expr = jsonata(condition);
|
|
79
|
+
return Boolean(await expr.evaluate(state));
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Find next step based on transitions
|
|
86
|
+
const findNextStep = async (step, state) => {
|
|
87
|
+
for (const transition of step.transitions || []) {
|
|
88
|
+
if (await evaluateCondition(transition.when, state)) {
|
|
89
|
+
return transition.next || null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Set nested value in state
|
|
96
|
+
const setValue = (state, key, value) => {
|
|
97
|
+
const parts = key.split('.');
|
|
98
|
+
let obj = state;
|
|
99
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
100
|
+
if (!obj[parts[i]]) obj[parts[i]] = {};
|
|
101
|
+
obj = obj[parts[i]];
|
|
102
|
+
}
|
|
103
|
+
obj[parts[parts.length - 1]] = value;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Prompt for a step based on input type
|
|
107
|
+
const promptStep = async (step) => {
|
|
108
|
+
const input = step.input || {};
|
|
109
|
+
const type = input.type || 'select';
|
|
110
|
+
|
|
111
|
+
if (type === 'select') {
|
|
112
|
+
const options = input.options.map(opt => ({
|
|
113
|
+
value: opt.value,
|
|
114
|
+
label: opt.label + (opt.recommended ? ' (Recommended)' : ''),
|
|
115
|
+
hint: opt.description,
|
|
116
|
+
}));
|
|
117
|
+
const defaultOpt = input.options.find(opt => opt.recommended);
|
|
118
|
+
return await p.select({
|
|
119
|
+
message: step.title,
|
|
120
|
+
options,
|
|
121
|
+
initialValue: defaultOpt?.value,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (type === 'toggle') {
|
|
126
|
+
return await p.confirm({
|
|
127
|
+
message: step.title,
|
|
128
|
+
initialValue: input.default ?? false,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (type === 'number') {
|
|
133
|
+
const result = await p.text({
|
|
134
|
+
message: step.title,
|
|
135
|
+
initialValue: String(input.default ?? ''),
|
|
136
|
+
validate: (val) => {
|
|
137
|
+
const num = parseInt(val, 10);
|
|
138
|
+
if (isNaN(num)) return 'Please enter a valid number';
|
|
139
|
+
if (input.min !== undefined && num < input.min) return `Value must be at least ${input.min}`;
|
|
140
|
+
if (input.max !== undefined && num > input.max) return `Value must be at most ${input.max}`;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return parseInt(result, 10);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (type === 'text') {
|
|
147
|
+
return await p.text({
|
|
148
|
+
message: step.title,
|
|
149
|
+
initialValue: input.default ?? '',
|
|
150
|
+
placeholder: input.placeholder,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Generate installation documentation
|
|
158
|
+
const generateDocs = async (state, docsManifest) => {
|
|
159
|
+
const { documentation } = docsManifest;
|
|
160
|
+
const { categories, instructions } = documentation;
|
|
161
|
+
|
|
162
|
+
// Build category map
|
|
163
|
+
const categoryMap = Object.fromEntries(categories.map(c => [c.id, c]));
|
|
164
|
+
|
|
165
|
+
// Evaluate conditions and collect matching instructions
|
|
166
|
+
const matching = [];
|
|
167
|
+
const seenIds = new Set();
|
|
168
|
+
|
|
169
|
+
for (const instruction of instructions) {
|
|
170
|
+
// Check condition
|
|
171
|
+
let matches = false;
|
|
172
|
+
if (instruction.always) {
|
|
173
|
+
matches = true;
|
|
174
|
+
} else if (instruction.when) {
|
|
175
|
+
matches = await evaluateCondition(instruction.when, state);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (matches && !seenIds.has(instruction.id)) {
|
|
179
|
+
seenIds.add(instruction.id);
|
|
180
|
+
const cat = categoryMap[instruction.category];
|
|
181
|
+
matching.push({
|
|
182
|
+
...instruction,
|
|
183
|
+
categoryPriority: cat?.priority ?? 99,
|
|
184
|
+
categoryTitle: cat?.title ?? instruction.category,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Sort by category priority, then instruction priority
|
|
190
|
+
matching.sort((a, b) => {
|
|
191
|
+
if (a.categoryPriority !== b.categoryPriority) {
|
|
192
|
+
return a.categoryPriority - b.categoryPriority;
|
|
193
|
+
}
|
|
194
|
+
return (a.priority ?? 99) - (b.priority ?? 99);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Group by category
|
|
198
|
+
const grouped = {};
|
|
199
|
+
for (const item of matching) {
|
|
200
|
+
if (!grouped[item.category]) {
|
|
201
|
+
grouped[item.category] = {
|
|
202
|
+
title: item.categoryTitle,
|
|
203
|
+
priority: item.categoryPriority,
|
|
204
|
+
items: [],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
grouped[item.category].items.push(item);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Build output
|
|
211
|
+
const output = [`# ${documentation.title}\n`];
|
|
212
|
+
|
|
213
|
+
for (const cat of Object.values(grouped).sort((a, b) => a.priority - b.priority)) {
|
|
214
|
+
output.push(`\n## ${cat.title}\n`);
|
|
215
|
+
|
|
216
|
+
for (const item of cat.items) {
|
|
217
|
+
if (item.goal) {
|
|
218
|
+
output.push(`\n### ${item.goal}\n`);
|
|
219
|
+
}
|
|
220
|
+
if (item.file) {
|
|
221
|
+
const content = await fetchDoc(item.file);
|
|
222
|
+
output.push(content);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return output.join('\n');
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Format state for review display
|
|
231
|
+
const formatState = (obj, prefix = '') => {
|
|
232
|
+
const lines = [];
|
|
233
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
234
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
235
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
236
|
+
lines.push(...formatState(value, fullKey));
|
|
237
|
+
} else {
|
|
238
|
+
const displayValue = typeof value === 'boolean' ? (value ? 'Yes' : 'No') : String(value);
|
|
239
|
+
lines.push(` ${fullKey}: ${displayValue}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return lines;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// Main wizard
|
|
246
|
+
const main = async () => {
|
|
247
|
+
console.clear();
|
|
248
|
+
|
|
249
|
+
p.intro('TrustGraph Configuration');
|
|
250
|
+
|
|
251
|
+
// Load resources from API
|
|
252
|
+
const s = p.spinner();
|
|
253
|
+
s.start(`Loading from ${apiBase}...`);
|
|
254
|
+
|
|
255
|
+
let flowData, outputTemplate, docsManifest;
|
|
256
|
+
try {
|
|
257
|
+
flowData = await fetchYaml('/dialog-flow');
|
|
258
|
+
const outputTemplateText = await fetchText('/config-prepare');
|
|
259
|
+
outputTemplate = jsonata(outputTemplateText);
|
|
260
|
+
docsManifest = await fetchYaml('/docs-manifest');
|
|
261
|
+
s.stop('Resources loaded');
|
|
262
|
+
} catch (err) {
|
|
263
|
+
s.stop('Failed to load resources');
|
|
264
|
+
p.log.error(err.message);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
p.log.info(flowData.flow.title);
|
|
269
|
+
|
|
270
|
+
const state = {};
|
|
271
|
+
const history = []; // Track human-readable question/answer pairs
|
|
272
|
+
let currentStepId = flowData.flow.start;
|
|
273
|
+
|
|
274
|
+
// Walk through steps
|
|
275
|
+
while (currentStepId) {
|
|
276
|
+
const step = flowData.steps[currentStepId];
|
|
277
|
+
|
|
278
|
+
if (!step) {
|
|
279
|
+
p.log.error(`Unknown step: ${currentStepId}`);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Review step
|
|
284
|
+
if (step.type === 'review') {
|
|
285
|
+
const lines = history.map(h => ` ${h.question} ${h.answer}`);
|
|
286
|
+
p.log.info('Configuration Summary:\n' + lines.join('\n'));
|
|
287
|
+
|
|
288
|
+
// Generate config
|
|
289
|
+
const s = p.spinner();
|
|
290
|
+
s.start('Generating configuration...');
|
|
291
|
+
|
|
292
|
+
const config = await outputTemplate.evaluate(state);
|
|
293
|
+
|
|
294
|
+
s.stop('Configuration generated');
|
|
295
|
+
|
|
296
|
+
// Prompt for filename
|
|
297
|
+
const filename = await p.text({
|
|
298
|
+
message: 'Save deployment package as:',
|
|
299
|
+
initialValue: 'deploy.zip',
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (p.isCancel(filename)) {
|
|
303
|
+
p.cancel('Cancelled');
|
|
304
|
+
process.exit(0);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const s2 = p.spinner();
|
|
308
|
+
s2.start('Downloading...');
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const response = await fetch(config.api_url, {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: { 'Content-Type': 'application/json' },
|
|
314
|
+
body: JSON.stringify(config.templates),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (!response.ok) {
|
|
318
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const buffer = await response.arrayBuffer();
|
|
322
|
+
writeFileSync(filename, Buffer.from(buffer));
|
|
323
|
+
s2.stop(`Saved to ${filename}`);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
s2.stop('Download failed');
|
|
326
|
+
p.log.error(err.message);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Generate documentation
|
|
330
|
+
const s3 = p.spinner();
|
|
331
|
+
s3.start('Generating installation guide...');
|
|
332
|
+
|
|
333
|
+
const docs = await generateDocs(state, docsManifest);
|
|
334
|
+
|
|
335
|
+
s3.stop('Installation guide generated');
|
|
336
|
+
|
|
337
|
+
// Prompt for docs filename
|
|
338
|
+
const docsFilename = await p.text({
|
|
339
|
+
message: 'Save installation guide as:',
|
|
340
|
+
initialValue: 'INSTALLATION.md',
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (p.isCancel(docsFilename)) {
|
|
344
|
+
p.cancel('Cancelled');
|
|
345
|
+
process.exit(0);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
writeFileSync(docsFilename, docs);
|
|
349
|
+
p.log.success(`Saved to ${docsFilename}`);
|
|
350
|
+
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Regular step - prompt user
|
|
355
|
+
const value = await promptStep(step);
|
|
356
|
+
|
|
357
|
+
if (p.isCancel(value)) {
|
|
358
|
+
p.cancel('Cancelled');
|
|
359
|
+
process.exit(0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Save to state
|
|
363
|
+
if (step.state_key) {
|
|
364
|
+
setValue(state, step.state_key, value);
|
|
365
|
+
|
|
366
|
+
// Record human-readable question/answer
|
|
367
|
+
const input = step.input || {};
|
|
368
|
+
let displayValue;
|
|
369
|
+
if (input.type === 'select') {
|
|
370
|
+
const opt = input.options.find(o => o.value === value);
|
|
371
|
+
displayValue = opt?.label ?? value;
|
|
372
|
+
} else if (input.type === 'toggle') {
|
|
373
|
+
displayValue = value ? 'Yes' : 'No';
|
|
374
|
+
} else {
|
|
375
|
+
displayValue = String(value);
|
|
376
|
+
}
|
|
377
|
+
history.push({ question: step.title, answer: displayValue });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Find next step
|
|
381
|
+
currentStepId = await findNextStep(step, state);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
p.outro('Done!');
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
main().catch(console.error);
|