@joshualiamzn/open-stack 0.0.1

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/src/render.mjs ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Renders the OSI pipeline YAML configuration from the resolved config.
3
+ *
4
+ * Serverless: uses template_type + template_content for all sinks
5
+ * Managed: uses index_type for logs, traces, and service-map
6
+ */
7
+
8
+ // ── Index template content for serverless ────────────────────────────────────
9
+
10
+ const LOGS_TEMPLATE_CONTENT = '{"version":1,"template":{"mappings":{"date_detection":false,"_source":{"enabled":true},"dynamic_templates":[{"long_resource_attributes":{"mapping":{"type":"long"},"path_match":"resource.attributes.*","match_mapping_type":"long"}},{"double_resource_attributes":{"mapping":{"type":"double"},"path_match":"resource.attributes.*","match_mapping_type":"double"}},{"string_resource_attributes":{"mapping":{"type":"keyword","ignore_above":256},"path_match":"resource.attributes.*","match_mapping_type":"string"}},{"long_scope_attributes":{"mapping":{"type":"long"},"path_match":"instrumentationScope.attributes.*","match_mapping_type":"long"}},{"double_scope_attributes":{"mapping":{"type":"double"},"path_match":"instrumentationScope.attributes.*","match_mapping_type":"double"}},{"string_scope_attributes":{"mapping":{"type":"keyword","ignore_above":256},"path_match":"instrumentationScope.attributes.*","match_mapping_type":"string"}},{"long_attributes":{"mapping":{"type":"long"},"path_match":"attributes.*","match_mapping_type":"long"}},{"double_attributes":{"mapping":{"type":"double"},"path_match":"attributes.*","match_mapping_type":"double"}},{"string_attributes":{"mapping":{"type":"keyword","ignore_above":256},"path_match":"attributes.*","match_mapping_type":"string"}}],"properties":{"droppedAttributesCount":{"type":"integer"},"instrumentationScope":{"properties":{"droppedAttributesCount":{"type":"integer"},"schemaUrl":{"type":"keyword","ignore_above":256},"name":{"type":"keyword","ignore_above":128},"version":{"type":"keyword","ignore_above":64}}},"resource":{"properties":{"droppedAttributesCount":{"type":"integer"},"schemaUrl":{"type":"keyword","ignore_above":256}}},"severity":{"properties":{"number":{"type":"integer"},"text":{"type":"keyword","ignore_above":"32"}}},"body":{"type":"text"},"@timestamp":{"type":"date_nanos"},"time":{"type":"date_nanos"},"observedTime":{"type":"date_nanos"},"traceId":{"type":"keyword","ignore_above":32},"spanId":{"type":"keyword","ignore_above":16},"flags":{"type":"long"}}}}}';
11
+
12
+ const TRACES_TEMPLATE_CONTENT = '{"version":1,"template":{"mappings":{"date_detection":false,"_source":{"enabled":true},"dynamic_templates":[{"long_resource_attributes":{"mapping":{"type":"long"},"path_match":"resource.attributes.*","match_mapping_type":"long"}},{"double_resource_attributes":{"mapping":{"type":"double"},"path_match":"resource.attributes.*","match_mapping_type":"double"}},{"string_resource_attributes":{"mapping":{"type":"keyword","ignore_above":256},"path_match":"resource.attributes.*","match_mapping_type":"string"}},{"long_scope_attributes":{"mapping":{"type":"long"},"path_match":"instrumentationScope.attributes.*","match_mapping_type":"long"}},{"double_scope_attributes":{"mapping":{"type":"double"},"path_match":"instrumentationScope.attributes.*","match_mapping_type":"double"}},{"string_scope_attributes":{"mapping":{"type":"keyword","ignore_above":256},"path_match":"instrumentationScope.attributes.*","match_mapping_type":"string"}},{"long_attributes":{"mapping":{"type":"long"},"path_match":"attributes.*","match_mapping_type":"long"}},{"double_attributes":{"mapping":{"type":"double"},"path_match":"attributes.*","match_mapping_type":"double"}},{"string_attributes":{"mapping":{"type":"keyword","ignore_above":256},"path_match":"attributes.*","match_mapping_type":"string"}}],"properties":{"droppedAttributesCount":{"type":"integer"},"instrumentationScope":{"properties":{"droppedAttributesCount":{"type":"integer"},"schemaUrl":{"type":"keyword","ignore_above":256},"name":{"type":"keyword","ignore_above":128},"version":{"type":"keyword","ignore_above":64}}},"resource":{"properties":{"droppedAttributesCount":{"type":"integer"},"schemaUrl":{"type":"keyword","ignore_above":256}}},"traceId":{"type":"keyword","ignore_above":32},"spanId":{"type":"keyword","ignore_above":16},"parentSpanId":{"type":"keyword","ignore_above":16},"name":{"ignore_above":1024,"type":"keyword"},"traceState":{"ignore_above":1024,"type":"keyword"},"traceGroup":{"ignore_above":1024,"type":"keyword"},"traceGroupFields":{"properties":{"endTime":{"type":"date_nanos"},"durationInNanos":{"type":"long"},"statusCode":{"type":"integer"}}},"kind":{"type":"keyword","ignore_above":32},"serviceName":{"type":"keyword","ignore_above":256},"startTime":{"type":"date_nanos"},"endTime":{"type":"date_nanos"},"@timestamp":{"type":"date_nanos"},"time":{"type":"date_nanos"},"status":{"properties":{"code":{"type":"integer"},"message":{"type":"keyword","ignore_above":2048}}},"durationInNanos":{"type":"long"},"events":{"type":"nested","properties":{"name":{"type":"keyword","ignore_above":256},"attributes":{"type":"object"},"droppedAttributesCount":{"type":"integer"},"time":{"type":"date_nanos"}}},"droppedEventsCount":{"type":"integer"},"links":{"type":"nested","properties":{"traceId":{"type":"keyword","ignore_above":32},"spanId":{"type":"keyword","ignore_above":16},"traceState":{"ignore_above":1024,"type":"keyword"},"attributes":{"type":"object"},"droppedAttributesCount":{"type":"integer"}}},"droppedLinksCount":{"type":"integer"}}}}}';
13
+
14
+ const SERVICE_MAP_TEMPLATE_CONTENT = '{"version":0,"template":{"mappings":{"date_detection":false,"dynamic_templates":[{"long_group_by_attributes":{"path_match":"*.groupByAttributes.*","match_mapping_type":"long","mapping":{"type":"long"}}},{"double_group_by_attributes":{"path_match":"*.groupByAttributes.*","match_mapping_type":"double","mapping":{"type":"double"}}},{"string_group_by_attributes":{"path_match":"*.groupByAttributes.*","match_mapping_type":"string","mapping":{"type":"keyword"}}},{"long_operation_attributes":{"path_match":"*.attributes.*","match_mapping_type":"long","mapping":{"type":"long"}}},{"double_operation_attributes":{"path_match":"*.attributes.*","match_mapping_type":"double","mapping":{"type":"double"}}},{"string_operation_attributes":{"path_match":"*.attributes.*","match_mapping_type":"string","mapping":{"type":"keyword"}}}],"_source":{"enabled":true},"properties":{"sourceNode":{"properties":{"type":{"type":"keyword"},"keyAttributes":{"properties":{"environment":{"type":"keyword"},"name":{"type":"keyword"}}},"groupByAttributes":{"type":"object","dynamic":"true"}}},"targetNode":{"properties":{"type":{"type":"keyword"},"keyAttributes":{"properties":{"environment":{"type":"keyword"},"name":{"type":"keyword"}}},"groupByAttributes":{"type":"object","dynamic":"true"}}},"sourceOperation":{"properties":{"name":{"type":"keyword"},"attributes":{"type":"object","dynamic":"true"}}},"targetOperation":{"properties":{"name":{"type":"keyword"},"attributes":{"type":"object","dynamic":"true"}}},"nodeConnectionHash":{"type":"keyword"},"operationConnectionHash":{"type":"keyword"},"timestamp":{"type":"date","format":"strict_date_optional_time||epoch_millis"}}}}}';
15
+
16
+ // ── Sink config helpers ──────────────────────────────────────────────────────
17
+
18
+ function logsSinkConfig(cfg) {
19
+ if (cfg.serverless) {
20
+ return `\
21
+ index: 'logs-otel-v1'
22
+ template_type: 'index-template'
23
+ template_content: '${LOGS_TEMPLATE_CONTENT}'`;
24
+ }
25
+ return `\
26
+ index_type: log-analytics-plain`;
27
+ }
28
+
29
+ function tracesSinkConfig(cfg) {
30
+ if (cfg.serverless) {
31
+ return `\
32
+ index: 'otel-v1-apm-span'
33
+ template_type: 'index-template'
34
+ template_content: '${TRACES_TEMPLATE_CONTENT}'`;
35
+ }
36
+ return `\
37
+ index_type: trace-analytics-plain-raw`;
38
+ }
39
+
40
+ function serviceMapSinkConfig(cfg) {
41
+ if (cfg.serverless) {
42
+ return `\
43
+ index: 'otel-v2-apm-service-map'
44
+ template_type: 'index-template'
45
+ template_content: '${SERVICE_MAP_TEMPLATE_CONTENT}'`;
46
+ }
47
+ return `\
48
+ index_type: otel-v2-apm-service-map`;
49
+ }
50
+
51
+ // ── Pipeline renderer ────────────────────────────────────────────────────────
52
+
53
+ export function renderPipeline(cfg) {
54
+ const name = cfg.pipelineName;
55
+ const serverless = !!cfg.serverless;
56
+
57
+ return `\
58
+ version: '2'
59
+ extension:
60
+ osis_configuration_metadata:
61
+ builder_type: visual
62
+
63
+ # Main OTLP pipeline - receives all telemetry and routes by signal type
64
+ otlp-pipeline:
65
+ source:
66
+ otlp:
67
+ logs_path: '/${name}/v1/logs'
68
+ traces_path: '/${name}/v1/traces'
69
+ metrics_path: '/${name}/v1/metrics'
70
+ route:
71
+ - logs: 'getEventType() == "LOG"'
72
+ - traces: 'getEventType() == "TRACE"'
73
+ - metrics: 'getEventType() == "METRIC"'
74
+ processor: []
75
+ sink:
76
+ - pipeline:
77
+ name: otel-logs-pipeline
78
+ routes:
79
+ - logs
80
+ - pipeline:
81
+ name: otel-traces-pipeline
82
+ routes:
83
+ - traces
84
+ - pipeline:
85
+ name: otel-metrics-pipeline
86
+ routes:
87
+ - metrics
88
+
89
+ # Log processing pipeline
90
+ otel-logs-pipeline:
91
+ source:
92
+ pipeline:
93
+ name: otlp-pipeline
94
+ processor:
95
+ - copy_values:
96
+ entries:
97
+ - from_key: "time"
98
+ to_key: "@timestamp"
99
+ sink:
100
+ - opensearch:
101
+ hosts:
102
+ - '${cfg.opensearchEndpoint}'
103
+ ${logsSinkConfig(cfg)}
104
+ aws:
105
+ serverless: ${serverless}
106
+ region: '${cfg.region}'
107
+ sts_role_arn: "${cfg.iamRoleArn}"
108
+
109
+ # Trace fan-out pipeline
110
+ otel-traces-pipeline:
111
+ source:
112
+ pipeline:
113
+ name: otlp-pipeline
114
+ processor: []
115
+ sink:
116
+ - pipeline:
117
+ name: traces-raw-pipeline
118
+ routes: []
119
+ - pipeline:
120
+ name: service-map-pipeline
121
+ routes: []
122
+
123
+ # Raw trace storage pipeline
124
+ traces-raw-pipeline:
125
+ source:
126
+ pipeline:
127
+ name: otel-traces-pipeline
128
+ processor:
129
+ - otel_traces:
130
+ sink:
131
+ - opensearch:
132
+ hosts:
133
+ - '${cfg.opensearchEndpoint}'
134
+ ${tracesSinkConfig(cfg)}
135
+ aws:
136
+ serverless: ${serverless}
137
+ region: '${cfg.region}'
138
+ sts_role_arn: "${cfg.iamRoleArn}"
139
+
140
+ # Service map generation pipeline (APM)
141
+ service-map-pipeline:
142
+ source:
143
+ pipeline:
144
+ name: otel-traces-pipeline
145
+ processor:
146
+ - otel_apm_service_map:
147
+ db_path: /tmp/otel-apm-service-map
148
+ group_by_attributes:
149
+ - telemetry.sdk.language
150
+ window_duration: ${cfg.serviceMapWindow}
151
+ route:
152
+ - otel_apm_service_map_route: 'getEventType() == "SERVICE_MAP"'
153
+ - service_processed_metrics: 'getEventType() == "METRIC"'
154
+ sink:
155
+ - opensearch:
156
+ hosts:
157
+ - '${cfg.opensearchEndpoint}'
158
+ aws:
159
+ serverless: ${serverless}
160
+ region: '${cfg.region}'
161
+ sts_role_arn: "${cfg.iamRoleArn}"
162
+ routes:
163
+ - otel_apm_service_map_route
164
+ ${serviceMapSinkConfig(cfg)}
165
+ - prometheus:
166
+ url: '${cfg.prometheusUrl}'
167
+ aws:
168
+ region: '${cfg.region}'
169
+ routes:
170
+ - service_processed_metrics
171
+
172
+ # Metrics processing pipeline
173
+ otel-metrics-pipeline:
174
+ source:
175
+ pipeline:
176
+ name: otlp-pipeline
177
+ processor:
178
+ - otel_metrics:
179
+ sink:
180
+ - prometheus:
181
+ url: '${cfg.prometheusUrl}'
182
+ aws:
183
+ region: '${cfg.region}'
184
+ `;
185
+ }
package/src/repl.mjs ADDED
@@ -0,0 +1,91 @@
1
+ import { select, input } from '@inquirer/prompts';
2
+ import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
3
+ import {
4
+ printBanner, printError, printInfo,
5
+ printKeyHint, printPanel, theme,
6
+ } from './ui.mjs';
7
+ import { COMMANDS, COMMAND_CHOICES } from './commands/index.mjs';
8
+
9
+ /**
10
+ * Initialize session — prompt for region and verify AWS credentials.
11
+ */
12
+ async function initSession() {
13
+ const region = await input({
14
+ message: 'AWS region',
15
+ default: 'us-east-1',
16
+ validate: (v) => /^[a-z]{2}-[a-z]+-\d+$/.test(v) || 'Expected format: us-east-1',
17
+ });
18
+
19
+ console.error();
20
+ const sts = new STSClient({ region });
21
+ let identity;
22
+ try {
23
+ identity = await sts.send(new GetCallerIdentityCommand({}));
24
+ } catch (err) {
25
+ printError('AWS credentials are not configured or have expired');
26
+ printInfo(err.message);
27
+ printInfo('Run "aws configure" or "aws sso login" to set up credentials, then restart.');
28
+ throw err;
29
+ }
30
+
31
+ printPanel('Session', [
32
+ ['Account', identity.Account],
33
+ ['Region', region],
34
+ ['Identity', theme.muted(identity.Arn)],
35
+ ]);
36
+
37
+ return { region, accountId: identity.Account };
38
+ }
39
+
40
+ /**
41
+ * Start the interactive REPL loop.
42
+ */
43
+ export async function startRepl() {
44
+ printBanner();
45
+
46
+ let session;
47
+ try {
48
+ session = await initSession();
49
+ } catch {
50
+ process.exit(1);
51
+ }
52
+
53
+ console.error();
54
+ printKeyHint([['Enter', 'select'], ['Esc', 'back'], ['Ctrl+C', 'exit']]);
55
+ console.error();
56
+
57
+ while (true) {
58
+ let cmd;
59
+ try {
60
+ cmd = await select({
61
+ message: theme.primary(`osi-pipeline [${session.region}]`),
62
+ choices: COMMAND_CHOICES,
63
+ });
64
+ } catch (err) {
65
+ // Ctrl+C at the menu level → exit
66
+ if (err.name === 'ExitPromptError') break;
67
+ throw err;
68
+ }
69
+
70
+ if (cmd === 'quit') break;
71
+
72
+ try {
73
+ await COMMANDS[cmd](session);
74
+ } catch (err) {
75
+ if (err.name === 'ExitPromptError') {
76
+ // Ctrl+C during a command → return to menu
77
+ console.error();
78
+ printInfo('Cancelled.');
79
+ console.error();
80
+ continue;
81
+ }
82
+ console.error();
83
+ printError(err.message);
84
+ console.error();
85
+ }
86
+ }
87
+
88
+ console.error();
89
+ console.error(` ${theme.muted('Goodbye.')}`);
90
+ console.error();
91
+ }
package/src/ui.mjs ADDED
@@ -0,0 +1,298 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import readline from 'node:readline';
4
+
5
+ // ── Theme colors ─────────────────────────────────────────────────────────────
6
+
7
+ export const theme = {
8
+ primary: chalk.hex('#B07FFF'), // soft purple (like Claude)
9
+ primaryBold: chalk.hex('#B07FFF').bold,
10
+ accent: chalk.hex('#6EC8F5'), // light blue
11
+ accentBold: chalk.hex('#6EC8F5').bold,
12
+ success: chalk.hex('#6BCB77'), // soft green
13
+ error: chalk.hex('#FF6B6B'), // soft red
14
+ warn: chalk.hex('#FFD93D'), // soft yellow
15
+ muted: chalk.dim,
16
+ mutedItalic: chalk.dim.italic,
17
+ label: chalk.bold,
18
+ highlight: chalk.hex('#E0C3FF'), // light purple
19
+ };
20
+
21
+ // ── Symbols ──────────────────────────────────────────────────────────────────
22
+
23
+ export const CHECK = '\u2713';
24
+ export const CROSS = '\u2717';
25
+ export const ARROW = '\u25B6'; // ▶
26
+ export const STAR = '\u2605';
27
+ export const DOT = '\u2022';
28
+ export const DIAMOND = '\u25C6';
29
+
30
+ // Box-drawing characters
31
+ const BOX = {
32
+ tl: '\u256D', tr: '\u256E', bl: '\u2570', br: '\u256F', // rounded corners
33
+ h: '\u2500', v: '\u2502',
34
+ ltee: '\u251C', rtee: '\u2524',
35
+ };
36
+
37
+ // ── Box-drawing primitives ───────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Render a box with rounded corners around lines of text.
41
+ * @param {string[]} lines - Lines to display inside the box
42
+ * @param {Object} [opts]
43
+ * @param {number} [opts.width] - Fixed inner width (auto-detected if omitted)
44
+ * @param {string} [opts.color] - Chalk method to apply to border (e.g. 'dim')
45
+ * @param {string} [opts.title] - Optional title in the top border
46
+ * @param {number} [opts.padding] - Left padding inside the box (default 1)
47
+ */
48
+ export function renderBox(lines, opts = {}) {
49
+ const pad = opts.padding ?? 1;
50
+ const sp = ' '.repeat(pad);
51
+
52
+ // Strip ANSI for width calculation
53
+ const stripAnsi = (s) => s.replace(/\x1B\[[0-9;]*m/g, '');
54
+ const innerWidth = opts.width || Math.max(...lines.map((l) => stripAnsi(l).length)) + pad * 2;
55
+
56
+ const colorFn = opts.color === 'dim' ? chalk.dim
57
+ : opts.color === 'primary' ? theme.primary
58
+ : opts.color === 'accent' ? theme.accent
59
+ : chalk.dim;
60
+
61
+ let topBorder;
62
+ if (opts.title) {
63
+ const titleStr = ` ${opts.title} `;
64
+ const titleLen = stripAnsi(titleStr).length;
65
+ const remaining = innerWidth - titleLen;
66
+ topBorder = colorFn(BOX.tl + BOX.h) + theme.primaryBold(titleStr) + colorFn(BOX.h.repeat(Math.max(0, remaining)) + BOX.tr);
67
+ } else {
68
+ topBorder = colorFn(BOX.tl + BOX.h.repeat(innerWidth) + BOX.tr);
69
+ }
70
+
71
+ const bottomBorder = colorFn(BOX.bl + BOX.h.repeat(innerWidth) + BOX.br);
72
+
73
+ const boxLines = [topBorder];
74
+ for (const line of lines) {
75
+ const visLen = stripAnsi(line).length;
76
+ const rightPad = Math.max(0, innerWidth - pad * 2 - visLen);
77
+ boxLines.push(colorFn(BOX.v) + sp + line + ' '.repeat(rightPad) + sp + colorFn(BOX.v));
78
+ }
79
+ boxLines.push(bottomBorder);
80
+
81
+ return boxLines;
82
+ }
83
+
84
+ /**
85
+ * Print a box to stderr.
86
+ */
87
+ export function printBox(lines, opts = {}) {
88
+ const indent = opts.indent ?? 2;
89
+ const prefix = ' '.repeat(indent);
90
+ for (const l of renderBox(lines, opts)) {
91
+ console.error(prefix + l);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Print a key-value panel inside a box.
97
+ * @param {string} title - Panel title
98
+ * @param {Array<[string, string]>} entries - [label, value] pairs; empty label = blank line
99
+ */
100
+ export function printPanel(title, entries) {
101
+ const lines = [];
102
+ for (const [label, value] of entries) {
103
+ if (!label && !value) {
104
+ lines.push('');
105
+ } else if (!label) {
106
+ lines.push(value);
107
+ } else {
108
+ lines.push(`${theme.muted(label)} ${value}`);
109
+ }
110
+ }
111
+ printBox(lines, { title, color: 'dim', padding: 1 });
112
+ }
113
+
114
+ // ── Print helpers — all write to stderr so stdout stays clean for YAML ───────
115
+
116
+ export function printHeader() {
117
+ console.error();
118
+ const banner = [
119
+ '',
120
+ `${theme.primaryBold('Open Stack CLI')}`,
121
+ `${theme.muted('Create AWS resources and OpenSearch Ingestion pipelines')}`,
122
+ '',
123
+ ];
124
+ printBox(banner, { color: 'primary', padding: 2 });
125
+ console.error();
126
+ }
127
+
128
+ export function printStep(msg) {
129
+ console.error();
130
+ console.error(` ${theme.primary(DIAMOND)} ${theme.primaryBold(msg)}`);
131
+ }
132
+
133
+ export function printSubStep(msg) {
134
+ console.error(` ${theme.muted(DOT)} ${msg}`);
135
+ }
136
+
137
+ export function printSuccess(msg) {
138
+ console.error(` ${theme.success(CHECK)} ${msg}`);
139
+ }
140
+
141
+ export function printError(msg) {
142
+ console.error(` ${theme.error(CROSS)} ${msg}`);
143
+ }
144
+
145
+ export function printWarning(msg) {
146
+ console.error(` ${theme.warn('!')} ${msg}`);
147
+ }
148
+
149
+ export function printInfo(msg) {
150
+ console.error(` ${theme.muted(msg)}`);
151
+ }
152
+
153
+ // Spinner — wraps ora for consistent styling
154
+ export function createSpinner(text) {
155
+ return ora({ text, stream: process.stderr, spinner: 'dots', color: 'magenta' });
156
+ }
157
+
158
+ // ── Key hints ────────────────────────────────────────────────────────────────
159
+
160
+ export function printKeyHint(hints) {
161
+ // hints: array of [key, description] e.g. [['Ctrl+C', 'cancel'], ['Enter', 'select']]
162
+ const parts = hints.map(([key, desc]) =>
163
+ `${theme.muted('[')}${theme.accent(key)}${theme.muted(']')} ${theme.muted(desc)}`
164
+ );
165
+ console.error(` ${parts.join(' ')}`);
166
+ }
167
+
168
+ // ── Pipeline status colorizer ────────────────────────────────────────────────
169
+
170
+ const STATUS_COLORS = {
171
+ ACTIVE: theme.success,
172
+ CREATING: theme.warn,
173
+ UPDATING: theme.warn,
174
+ DELETING: theme.warn,
175
+ CREATE_FAILED: theme.error,
176
+ UPDATE_FAILED: theme.error,
177
+ STARTING: theme.accent,
178
+ STOPPING: theme.accent,
179
+ STOPPED: theme.muted,
180
+ };
181
+
182
+ export function colorStatus(status) {
183
+ const fn = STATUS_COLORS[status] || chalk.white;
184
+ return fn(status);
185
+ }
186
+
187
+ // ── Date formatter ───────────────────────────────────────────────────────────
188
+
189
+ export function formatDate(d) {
190
+ if (!d) return '\u2014';
191
+ return new Date(d).toLocaleString('en-US', {
192
+ month: 'short', day: 'numeric', year: 'numeric',
193
+ hour: '2-digit', minute: '2-digit',
194
+ });
195
+ }
196
+
197
+ // ── REPL banner ──────────────────────────────────────────────────────────────
198
+
199
+ export function printBanner() {
200
+ console.error();
201
+ const banner = [
202
+ '',
203
+ `${theme.primaryBold('Open Stack')}`,
204
+ `${theme.muted('Create, list, describe, and update OpenSearch Ingestion pipelines')}`,
205
+ '',
206
+ ];
207
+ printBox(banner, { color: 'primary', padding: 2 });
208
+ }
209
+
210
+ // ── Horizontal divider ──────────────────────────────────────────────────────
211
+
212
+ export function printDivider() {
213
+ console.error(` ${theme.muted(BOX.h.repeat(60))}`);
214
+ }
215
+
216
+ // ── Escape-to-go-back prompt wrapper ────────────────────────────────────────
217
+
218
+ export const GoBack = Symbol('GoBack');
219
+
220
+ let _keypressInit = false;
221
+
222
+ /**
223
+ * Wrap an @inquirer/prompts function so that pressing Escape cancels
224
+ * the prompt and returns the GoBack sentinel instead of throwing.
225
+ */
226
+ export function withEscape(promptFn) {
227
+ return (...args) => {
228
+ if (!_keypressInit && process.stdin.isTTY) {
229
+ readline.emitKeypressEvents(process.stdin);
230
+ _keypressInit = true;
231
+ }
232
+
233
+ const promise = promptFn(...args);
234
+ let escaped = false;
235
+
236
+ const onKeypress = (_ch, key) => {
237
+ if (key?.name === 'escape') {
238
+ escaped = true;
239
+ promise.cancel();
240
+ }
241
+ };
242
+
243
+ process.stdin.on('keypress', onKeypress);
244
+
245
+ return promise.then(
246
+ (val) => { process.stdin.removeListener('keypress', onKeypress); return val; },
247
+ (err) => {
248
+ process.stdin.removeListener('keypress', onKeypress);
249
+ if (escaped) return GoBack;
250
+ throw err;
251
+ },
252
+ );
253
+ };
254
+ }
255
+
256
+ // ── Table formatter with box borders ─────────────────────────────────────────
257
+
258
+ export function printTable(headers, rows) {
259
+ if (rows.length === 0) return;
260
+
261
+ const stripAnsi = (s) => String(s).replace(/\x1B\[[0-9;]*m/g, '');
262
+
263
+ // Calculate column widths
264
+ const widths = headers.map((h, i) =>
265
+ Math.max(h.length, ...rows.map((r) => stripAnsi(r[i] ?? '').length))
266
+ );
267
+
268
+ const totalWidth = widths.reduce((a, b) => a + b, 0) + (widths.length - 1) * 3;
269
+
270
+ // Top border
271
+ console.error(` ${theme.muted(BOX.tl + BOX.h.repeat(totalWidth + 2) + BOX.tr)}`);
272
+
273
+ // Header
274
+ const headerLine = headers
275
+ .map((h, i) => theme.accentBold(h.padEnd(widths[i])))
276
+ .join(theme.muted(' ' + BOX.v + ' '));
277
+ console.error(` ${theme.muted(BOX.v)} ${headerLine} ${theme.muted(BOX.v)}`);
278
+
279
+ // Separator
280
+ const sep = widths.map((w) => BOX.h.repeat(w)).join(BOX.h + BOX.h + BOX.h);
281
+ console.error(` ${theme.muted(BOX.ltee + sep + BOX.h + BOX.h + BOX.rtee)}`);
282
+
283
+ // Rows
284
+ for (const row of rows) {
285
+ const line = row
286
+ .map((cell, i) => {
287
+ const s = String(cell ?? '');
288
+ const pad = widths[i] - stripAnsi(s).length;
289
+ return s + ' '.repeat(Math.max(0, pad));
290
+ })
291
+ .join(theme.muted(' ' + BOX.v + ' '));
292
+ console.error(` ${theme.muted(BOX.v)} ${line} ${theme.muted(BOX.v)}`);
293
+ }
294
+
295
+ // Bottom border
296
+ console.error(` ${theme.muted(BOX.bl + BOX.h.repeat(totalWidth + 2) + BOX.br)}`);
297
+ console.error();
298
+ }