@joshualiamzn/open-stack 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.
@@ -1,17 +1,12 @@
1
- import { select, input, confirm } from '@inquirer/prompts';
2
1
  import {
3
- printHeader, printStep, printInfo, printSubStep, printKeyHint,
4
- createSpinner, theme, GoBack, withEscape,
2
+ printHeader, printStep, printInfo, printSubStep,
3
+ createSpinner, theme, GoBack, eSelect, eInput,
5
4
  } from './ui.mjs';
6
5
  import { createDefaultConfig, DEFAULTS } from './config.mjs';
7
- import { listDomains, listRoles, listWorkspaces } from './aws.mjs';
6
+ import { listDomains, listWorkspaces, listApplications } from './aws.mjs';
8
7
 
9
8
  const CUSTOM_INPUT = Symbol('custom');
10
9
 
11
- const eSelect = withEscape(select);
12
- const eInput = withEscape(input);
13
- const eConfirm = withEscape(confirm);
14
-
15
10
  /**
16
11
  * Fetch resources with a spinner, returning [] on failure.
17
12
  */
@@ -37,7 +32,7 @@ async function stepMode(cfg) {
37
32
  const mode = await eSelect({
38
33
  message: 'Mode',
39
34
  choices: [
40
- { name: `Simple ${theme.muted('\u2014 just name + region; creates all resources with defaults')}`, value: 'simple' },
35
+ { name: `Simple ${theme.muted('\u2014 creates all resources with defaults')}`, value: 'simple' },
41
36
  { name: `Advanced ${theme.muted('\u2014 create new or reuse existing resources; tune pipeline settings')}`, value: 'advanced' },
42
37
  ],
43
38
  default: cfg.mode || 'simple',
@@ -82,215 +77,331 @@ async function stepCore(cfg, session) {
82
77
  cfg.iamRoleName = `${cfg.pipelineName}-osi-role`;
83
78
  cfg.apsAction = 'create';
84
79
  cfg.apsWorkspaceAlias = cfg.pipelineName;
80
+ cfg.dashboardsAction = 'create';
81
+ cfg.dqsRoleName = `${cfg.pipelineName}-dqs-prometheus-role`;
82
+ cfg.dqsDataSourceName = `${cfg.pipelineName.replace(/-/g, '_')}_prometheus`;
83
+ cfg.appName = cfg.pipelineName;
85
84
  console.error();
86
- printSubStep(
87
- `Will create: OpenSearch Serverless collection '${theme.accent(cfg.osDomainName)}', ` +
88
- `IAM role '${theme.accent(cfg.iamRoleName)}', APS workspace '${theme.accent(cfg.apsWorkspaceAlias)}'`
89
- );
85
+ printInfo(`Will create:`);
86
+ printSubStep(`OpenSearch Serverless collection: ${theme.accent(cfg.osDomainName)}`);
87
+ printSubStep(`IAM role: ${theme.accent(cfg.iamRoleName)}`);
88
+ printSubStep(`APS workspace: ${theme.accent(cfg.apsWorkspaceAlias)}`);
89
+ printSubStep(`DQS role: ${theme.accent(cfg.dqsRoleName)}`);
90
+ printSubStep(`DQS data source: ${theme.accent(cfg.dqsDataSourceName)}`);
91
+ printSubStep(`OpenSearch Application: ${theme.accent(cfg.appName)}`);
90
92
  }
91
93
  }
92
94
 
93
95
  async function stepOpenSearch(cfg) {
94
96
  if (cfg.mode !== 'advanced') return 'skip';
95
97
 
96
- printStep('OpenSearch domain');
98
+ printStep('OpenSearch');
97
99
  console.error();
98
100
 
99
- const osChoice = await eSelect({
100
- message: 'Create new or reuse existing?',
101
- choices: [
102
- { name: 'Create new', value: 'create' },
103
- { name: 'Reuse existing', value: 'reuse' },
104
- ],
105
- default: cfg.osAction || 'create',
106
- });
107
- if (osChoice === GoBack) return GoBack;
108
-
109
- if (osChoice === 'reuse') {
110
- cfg.osAction = 'reuse';
111
-
112
- const domains = await fetchWithSpinner(
113
- 'Loading OpenSearch domains & collections',
114
- () => listDomains(cfg.region),
115
- );
116
-
117
- if (domains.length > 0) {
118
- const choices = domains.map((d) => ({
119
- name: d.endpoint
120
- ? `${d.name} ${theme.muted(`\u2014 ${d.endpoint} (${d.engineVersion})`)}`
121
- : `${d.name} ${theme.muted(`\u2014 provisioning... (${d.engineVersion})`)}`,
122
- value: { endpoint: d.endpoint, serverless: d.serverless },
123
- disabled: !d.endpoint ? '(no endpoint yet)' : false,
124
- }));
125
- choices.push({ name: theme.accent('Enter manually...'), value: CUSTOM_INPUT });
126
-
127
- const selected = await eSelect({ message: 'Select domain or collection', choices });
128
- if (selected === GoBack) return GoBack;
129
- if (selected === CUSTOM_INPUT) {
101
+ while (true) {
102
+ const osChoice = await eSelect({
103
+ message: 'Create new or reuse existing?',
104
+ choices: [
105
+ { name: 'Create new', value: 'create' },
106
+ { name: 'Reuse existing', value: 'reuse' },
107
+ ],
108
+ default: cfg.osAction || 'create',
109
+ });
110
+ if (osChoice === GoBack) return GoBack;
111
+
112
+ if (osChoice === 'reuse') {
113
+ cfg.osAction = 'reuse';
114
+
115
+ const domains = await fetchWithSpinner(
116
+ 'Loading OpenSearch domains & collections',
117
+ () => listDomains(cfg.region),
118
+ );
119
+
120
+ if (domains.length > 0) {
121
+ const choices = domains.map((d) => ({
122
+ name: d.endpoint
123
+ ? `${d.name} ${theme.muted(`\u2014 ${d.endpoint} (${d.engineVersion})`)}`
124
+ : `${d.name} ${theme.muted(`\u2014 provisioning... (${d.engineVersion})`)}`,
125
+ value: { endpoint: d.endpoint, serverless: d.serverless },
126
+ disabled: !d.endpoint ? '(no endpoint yet)' : false,
127
+ }));
128
+ choices.push({ name: theme.accent('Enter manually...'), value: CUSTOM_INPUT });
129
+
130
+ const selected = await eSelect({ message: 'Select domain or collection', choices });
131
+ if (selected === GoBack) continue;
132
+ if (selected === CUSTOM_INPUT) {
133
+ const ep = await promptEndpoint();
134
+ if (ep === GoBack) continue;
135
+ cfg.opensearchEndpoint = ep;
136
+ cfg.serverless = isServerlessEndpoint(ep);
137
+ } else {
138
+ cfg.opensearchEndpoint = selected.endpoint;
139
+ cfg.serverless = selected.serverless;
140
+ }
141
+ } else {
142
+ printInfo('No domains or collections found \u2014 enter endpoint manually');
130
143
  const ep = await promptEndpoint();
131
- if (ep === GoBack) return GoBack;
144
+ if (ep === GoBack) continue;
132
145
  cfg.opensearchEndpoint = ep;
133
146
  cfg.serverless = isServerlessEndpoint(ep);
134
- } else {
135
- cfg.opensearchEndpoint = selected.endpoint;
136
- cfg.serverless = selected.serverless;
137
147
  }
148
+
149
+ printSubStep(`Detected type: ${cfg.serverless ? theme.accent('OpenSearch Serverless') : theme.accent('Managed OpenSearch domain')}`);
138
150
  } else {
139
- printInfo('No domains or collections found \u2014 enter endpoint manually');
140
- const ep = await promptEndpoint();
141
- if (ep === GoBack) return GoBack;
142
- cfg.opensearchEndpoint = ep;
143
- cfg.serverless = isServerlessEndpoint(ep);
151
+ cfg.osAction = 'create';
152
+
153
+ const osType = await eSelect({
154
+ message: 'OpenSearch type',
155
+ choices: [
156
+ { name: `Serverless ${theme.muted('\u2014 fully managed, auto-scales')}`, value: 'serverless' },
157
+ { name: `Managed domain ${theme.muted('\u2014 configure instance type, count, and storage')}`, value: 'managed' },
158
+ ],
159
+ default: cfg.serverless === true ? 'serverless' : cfg.serverless === false ? 'managed' : 'serverless',
160
+ });
161
+ if (osType === GoBack) continue;
162
+ cfg.serverless = osType === 'serverless';
163
+
164
+ const nameMsg = cfg.serverless ? 'Collection name' : 'Domain name';
165
+ const domainName = await eInput({ message: nameMsg, default: cfg.osDomainName || cfg.pipelineName });
166
+ if (domainName === GoBack) continue;
167
+ cfg.osDomainName = domainName;
168
+
169
+ if (!cfg.serverless) {
170
+ const instType = await eInput({ message: 'Instance type', default: cfg.osInstanceType || DEFAULTS.osInstanceType });
171
+ if (instType === GoBack) continue;
172
+ cfg.osInstanceType = instType;
173
+
174
+ const instCount = await eInput({
175
+ message: 'Instance count',
176
+ default: String(cfg.osInstanceCount || DEFAULTS.osInstanceCount),
177
+ validate: (v) => /^\d+$/.test(v.trim()) && Number(v) >= 1 || 'Must be a positive integer',
178
+ });
179
+ if (instCount === GoBack) continue;
180
+ cfg.osInstanceCount = Number(instCount);
181
+
182
+ const volSize = await eInput({
183
+ message: 'EBS volume size (GB)',
184
+ default: String(cfg.osVolumeSize || DEFAULTS.osVolumeSize),
185
+ validate: (v) => /^\d+$/.test(v.trim()) && Number(v) >= 10 || 'Must be at least 10 GB',
186
+ });
187
+ if (volSize === GoBack) continue;
188
+ cfg.osVolumeSize = Number(volSize);
189
+
190
+ const engineVer = await eInput({ message: 'Engine version', default: cfg.osEngineVersion || DEFAULTS.osEngineVersion });
191
+ if (engineVer === GoBack) continue;
192
+ cfg.osEngineVersion = engineVer;
193
+ }
144
194
  }
195
+ return;
196
+ }
197
+ }
145
198
 
146
- printSubStep(`Detected type: ${cfg.serverless ? theme.accent('OpenSearch Serverless') : theme.accent('Managed OpenSearch domain')}`);
147
- } else {
148
- cfg.osAction = 'create';
149
-
150
- const domainName = await eInput({ message: 'Domain name', default: cfg.osDomainName || cfg.pipelineName });
151
- if (domainName === GoBack) return GoBack;
152
- cfg.osDomainName = domainName;
153
-
154
- const instType = await eInput({ message: 'Instance type', default: cfg.osInstanceType || DEFAULTS.osInstanceType });
155
- if (instType === GoBack) return GoBack;
156
- cfg.osInstanceType = instType;
199
+ async function stepIam(cfg) {
200
+ if (cfg.mode !== 'advanced') return 'skip';
157
201
 
158
- const instCount = await eInput({
159
- message: 'Instance count',
160
- default: String(cfg.osInstanceCount || DEFAULTS.osInstanceCount),
161
- validate: (v) => /^\d+$/.test(v.trim()) && Number(v) >= 1 || 'Must be a positive integer',
162
- });
163
- if (instCount === GoBack) return GoBack;
164
- cfg.osInstanceCount = Number(instCount);
202
+ printStep('IAM role for OSI pipeline');
203
+ printInfo('This role allows the ingestion pipeline to write to OpenSearch and Prometheus');
204
+ console.error();
165
205
 
166
- const volSize = await eInput({
167
- message: 'EBS volume size (GB)',
168
- default: String(cfg.osVolumeSize || DEFAULTS.osVolumeSize),
169
- validate: (v) => /^\d+$/.test(v.trim()) && Number(v) >= 10 || 'Must be at least 10 GB',
206
+ while (true) {
207
+ const iamChoice = await eSelect({
208
+ message: 'Create new or reuse existing?',
209
+ choices: [
210
+ { name: 'Create new', value: 'create' },
211
+ { name: 'Reuse existing', value: 'reuse' },
212
+ ],
213
+ default: cfg.iamAction || 'create',
170
214
  });
171
- if (volSize === GoBack) return GoBack;
172
- cfg.osVolumeSize = Number(volSize);
215
+ if (iamChoice === GoBack) return GoBack;
173
216
 
174
- const engineVer = await eInput({ message: 'Engine version', default: cfg.osEngineVersion || DEFAULTS.osEngineVersion });
175
- if (engineVer === GoBack) return GoBack;
176
- cfg.osEngineVersion = engineVer;
217
+ if (iamChoice === 'reuse') {
218
+ cfg.iamAction = 'reuse';
219
+ const arn = await promptArn('IAM role ARN');
220
+ if (arn === GoBack) continue;
221
+ cfg.iamRoleArn = arn;
222
+ } else {
223
+ cfg.iamAction = 'create';
224
+ const roleName = await eInput({ message: 'Role name', default: cfg.iamRoleName || `${cfg.pipelineName}-osi-role` });
225
+ if (roleName === GoBack) continue;
226
+ cfg.iamRoleName = roleName;
227
+ }
228
+ return;
177
229
  }
178
230
  }
179
231
 
180
- async function stepIam(cfg) {
232
+ async function stepAps(cfg) {
181
233
  if (cfg.mode !== 'advanced') return 'skip';
182
234
 
183
- printStep('IAM role for OSI pipeline');
235
+ printStep('Amazon Managed Prometheus (APS) workspace');
184
236
  console.error();
185
237
 
186
- const iamChoice = await eSelect({
187
- message: 'Create new or reuse existing?',
188
- choices: [
189
- { name: 'Create new', value: 'create' },
190
- { name: 'Reuse existing', value: 'reuse' },
191
- ],
192
- default: cfg.iamAction || 'create',
193
- });
194
- if (iamChoice === GoBack) return GoBack;
238
+ while (true) {
239
+ const apsChoice = await eSelect({
240
+ message: 'Create new or reuse existing?',
241
+ choices: [
242
+ { name: 'Create new', value: 'create' },
243
+ { name: 'Reuse existing', value: 'reuse' },
244
+ ],
245
+ default: cfg.apsAction || 'create',
246
+ });
247
+ if (apsChoice === GoBack) return GoBack;
248
+
249
+ if (apsChoice === 'reuse') {
250
+ cfg.apsAction = 'reuse';
251
+
252
+ const workspaces = await fetchWithSpinner(
253
+ 'Loading APS workspaces',
254
+ () => listWorkspaces(cfg.region),
255
+ );
256
+
257
+ if (workspaces.length > 0) {
258
+ const choices = workspaces.map((w) => ({
259
+ name: w.alias
260
+ ? `${w.alias} ${theme.muted(`\u2014 ${w.id}`)}`
261
+ : `${w.id} ${theme.muted('(no alias)')}`,
262
+ value: w.url,
263
+ }));
264
+ choices.push({ name: theme.accent('Enter URL manually...'), value: CUSTOM_INPUT });
265
+
266
+ const selected = await eSelect({ message: 'Select workspace', choices });
267
+ if (selected === GoBack) continue;
268
+ if (selected === CUSTOM_INPUT) {
269
+ const url = await promptUrl('Prometheus remote-write URL');
270
+ if (url === GoBack) continue;
271
+ cfg.prometheusUrl = url;
272
+ } else {
273
+ cfg.prometheusUrl = selected;
274
+ }
275
+ } else {
276
+ printInfo('No workspaces found \u2014 enter URL manually');
277
+ const url = await promptUrl('Prometheus remote-write URL');
278
+ if (url === GoBack) continue;
279
+ cfg.prometheusUrl = url;
280
+ }
281
+ } else {
282
+ cfg.apsAction = 'create';
283
+ const alias = await eInput({ message: 'Workspace alias', default: cfg.apsWorkspaceAlias || cfg.pipelineName });
284
+ if (alias === GoBack) continue;
285
+ cfg.apsWorkspaceAlias = alias;
286
+ }
287
+ return;
288
+ }
289
+ }
195
290
 
196
- if (iamChoice === 'reuse') {
197
- cfg.iamAction = 'reuse';
291
+ async function stepDqsRole(cfg) {
292
+ if (cfg.mode !== 'advanced') return 'skip';
198
293
 
199
- const roles = await fetchWithSpinner(
200
- 'Loading IAM roles',
201
- () => listRoles(cfg.region),
202
- );
294
+ printStep('Direct Query IAM role');
295
+ printInfo('This role allows OpenSearch to query Prometheus metrics via Direct Query Service');
296
+ console.error();
203
297
 
204
- if (roles.length > 0) {
205
- const osiRoles = [];
206
- const otherRoles = [];
207
- for (const r of roles) {
208
- (/osi|pipeline|ingestion/i.test(r.name) ? osiRoles : otherRoles).push(r);
209
- }
210
- const sorted = [...osiRoles, ...otherRoles];
211
-
212
- const choices = sorted.map((r) => ({
213
- name: `${r.name} ${theme.muted(`\u2014 ${r.arn}`)}`,
214
- value: r.arn,
215
- }));
216
- choices.push({ name: theme.accent('Enter ARN manually...'), value: CUSTOM_INPUT });
217
-
218
- const selected = await eSelect({ message: 'Select role', choices });
219
- if (selected === GoBack) return GoBack;
220
- if (selected === CUSTOM_INPUT) {
221
- const arn = await promptArn();
222
- if (arn === GoBack) return GoBack;
223
- cfg.iamRoleArn = arn;
224
- } else {
225
- cfg.iamRoleArn = selected;
226
- }
298
+ while (true) {
299
+ const choice = await eSelect({
300
+ message: 'Create new or reuse existing?',
301
+ choices: [
302
+ { name: 'Create new', value: 'create' },
303
+ { name: 'Reuse existing', value: 'reuse' },
304
+ ],
305
+ default: 'create',
306
+ });
307
+ if (choice === GoBack) return GoBack;
308
+
309
+ if (choice === 'reuse') {
310
+ const arn = await promptArn('DQS role ARN');
311
+ if (arn === GoBack) continue;
312
+ cfg.dqsRoleArn = arn;
313
+ cfg.dqsRoleName = '';
227
314
  } else {
228
- printInfo('No roles found \u2014 enter ARN manually');
229
- const arn = await promptArn();
230
- if (arn === GoBack) return GoBack;
231
- cfg.iamRoleArn = arn;
315
+ const roleName = await eInput({
316
+ message: 'DQS role name',
317
+ default: cfg.dqsRoleName || `${cfg.pipelineName}-dqs-prometheus-role`,
318
+ });
319
+ if (roleName === GoBack) continue;
320
+ cfg.dqsRoleName = roleName;
232
321
  }
233
- } else {
234
- cfg.iamAction = 'create';
235
- const roleName = await eInput({ message: 'Role name', default: cfg.iamRoleName || `${cfg.pipelineName}-osi-role` });
236
- if (roleName === GoBack) return GoBack;
237
- cfg.iamRoleName = roleName;
322
+ return;
238
323
  }
239
324
  }
240
325
 
241
- async function stepAps(cfg) {
326
+ async function stepDqsDataSource(cfg) {
242
327
  if (cfg.mode !== 'advanced') return 'skip';
328
+ // Skip if no DQS role was configured
329
+ if (!cfg.dqsRoleName && !cfg.dqsRoleArn) return 'skip';
243
330
 
244
- printStep('Amazon Managed Prometheus (APS) workspace');
331
+ printStep('Direct Query data source');
332
+ printInfo('Connects OpenSearch to Prometheus so you can query metrics from OpenSearch UI');
245
333
  console.error();
246
334
 
247
- const apsChoice = await eSelect({
248
- message: 'Create new or reuse existing?',
249
- choices: [
250
- { name: 'Create new', value: 'create' },
251
- { name: 'Reuse existing', value: 'reuse' },
252
- ],
253
- default: cfg.apsAction || 'create',
335
+ const dsName = await eInput({
336
+ message: 'Data source name',
337
+ default: cfg.dqsDataSourceName || `${cfg.pipelineName.replace(/-/g, '_')}_prometheus`,
338
+ validate: (v) => /^[a-z][a-z0-9_]+$/.test(v.trim()) || 'Must match [a-z][a-z0-9_]+ (lowercase, underscores only)',
254
339
  });
255
- if (apsChoice === GoBack) return GoBack;
256
-
257
- if (apsChoice === 'reuse') {
258
- cfg.apsAction = 'reuse';
259
-
260
- const workspaces = await fetchWithSpinner(
261
- 'Loading APS workspaces',
262
- () => listWorkspaces(cfg.region),
263
- );
264
-
265
- if (workspaces.length > 0) {
266
- const choices = workspaces.map((w) => ({
267
- name: w.alias
268
- ? `${w.alias} ${theme.muted(`\u2014 ${w.id}`)}`
269
- : `${w.id} ${theme.muted('(no alias)')}`,
270
- value: w.url,
271
- }));
272
- choices.push({ name: theme.accent('Enter URL manually...'), value: CUSTOM_INPUT });
273
-
274
- const selected = await eSelect({ message: 'Select workspace', choices });
275
- if (selected === GoBack) return GoBack;
276
- if (selected === CUSTOM_INPUT) {
277
- const url = await promptUrl('Prometheus remote-write URL');
278
- if (url === GoBack) return GoBack;
279
- cfg.prometheusUrl = url;
340
+ if (dsName === GoBack) return GoBack;
341
+ cfg.dqsDataSourceName = dsName;
342
+ }
343
+
344
+ async function stepApp(cfg) {
345
+ if (cfg.mode !== 'advanced') return 'skip';
346
+
347
+ printStep('OpenSearch UI');
348
+ printInfo('The OpenSearch Application provides a unified dashboard for your observability data');
349
+ console.error();
350
+
351
+ while (true) {
352
+ const choice = await eSelect({
353
+ message: 'Create new or reuse existing?',
354
+ choices: [
355
+ { name: `Create new ${theme.muted('\u2014 creates an OpenSearch Application with data sources')}`, value: 'create' },
356
+ { name: 'Reuse existing', value: 'reuse' },
357
+ ],
358
+ default: cfg.dashboardsAction || 'create',
359
+ });
360
+ if (choice === GoBack) return GoBack;
361
+
362
+ if (choice === 'reuse') {
363
+ cfg.dashboardsAction = 'reuse';
364
+
365
+ const apps = await fetchWithSpinner(
366
+ 'Loading OpenSearch Applications',
367
+ () => listApplications(cfg.region),
368
+ );
369
+
370
+ if (apps.length > 0) {
371
+ const choices = apps.map((a) => ({
372
+ name: a.endpoint
373
+ ? `${a.name} ${theme.muted(`\u2014 ${a.endpoint}`)}`
374
+ : `${a.name} ${theme.muted(`(${a.id})`)}`,
375
+ value: a.endpoint || a.id,
376
+ }));
377
+ choices.push({ name: theme.accent('Enter URL manually...'), value: CUSTOM_INPUT });
378
+
379
+ const selected = await eSelect({ message: 'Select application', choices });
380
+ if (selected === GoBack) continue;
381
+ if (selected === CUSTOM_INPUT) {
382
+ const url = await promptUrl('OpenSearch UI URL');
383
+ if (url === GoBack) continue;
384
+ cfg.dashboardsUrl = url;
385
+ } else {
386
+ cfg.dashboardsUrl = selected;
387
+ }
280
388
  } else {
281
- cfg.prometheusUrl = selected;
389
+ printInfo('No applications found \u2014 enter URL manually');
390
+ const url = await promptUrl('OpenSearch UI URL');
391
+ if (url === GoBack) continue;
392
+ cfg.dashboardsUrl = url;
282
393
  }
394
+ cfg.appName = '';
283
395
  } else {
284
- printInfo('No workspaces found \u2014 enter URL manually');
285
- const url = await promptUrl('Prometheus remote-write URL');
286
- if (url === GoBack) return GoBack;
287
- cfg.prometheusUrl = url;
396
+ cfg.dashboardsAction = 'create';
397
+ const appName = await eInput({
398
+ message: 'Application name',
399
+ default: cfg.appName || cfg.pipelineName,
400
+ });
401
+ if (appName === GoBack) continue;
402
+ cfg.appName = appName;
288
403
  }
289
- } else {
290
- cfg.apsAction = 'create';
291
- const alias = await eInput({ message: 'Workspace alias', default: cfg.apsWorkspaceAlias || cfg.pipelineName });
292
- if (alias === GoBack) return GoBack;
293
- cfg.apsWorkspaceAlias = alias;
404
+ return;
294
405
  }
295
406
  }
296
407
 
@@ -321,22 +432,6 @@ async function stepTuning(cfg) {
321
432
  cfg.serviceMapWindow = window;
322
433
  }
323
434
 
324
- async function stepOutput(cfg) {
325
- printStep('Output');
326
- console.error();
327
-
328
- const outputFile = await eInput({ message: 'Output file for pipeline YAML (leave empty for stdout)', default: cfg.outputFile || '' });
329
- if (outputFile === GoBack) return GoBack;
330
- cfg.outputFile = outputFile;
331
-
332
- const dryRun = await eConfirm({
333
- message: 'Dry run only (generate config, skip resource creation)?',
334
- default: cfg.dryRun ?? true,
335
- });
336
- if (dryRun === GoBack) return GoBack;
337
- cfg.dryRun = dryRun;
338
- }
339
-
340
435
  // ── Main wizard ──────────────────────────────────────────────────────────────
341
436
 
342
437
  /**
@@ -348,10 +443,8 @@ export async function runCreateWizard(session = null) {
348
443
  const cfg = createDefaultConfig();
349
444
 
350
445
  if (!session) printHeader();
351
- printKeyHint([['Esc', 'back'], ['Ctrl+C', 'cancel']]);
352
- console.error();
353
446
 
354
- const steps = [stepMode, stepCore, stepOpenSearch, stepIam, stepAps, stepTuning, stepOutput];
447
+ const steps = [stepMode, stepCore, stepOpenSearch, stepIam, stepAps, stepDqsRole, stepDqsDataSource, stepApp, stepTuning];
355
448
  const visited = [];
356
449
  let i = 0;
357
450
 
@@ -360,8 +453,8 @@ export async function runCreateWizard(session = null) {
360
453
 
361
454
  if (result === GoBack) {
362
455
  if (visited.length === 0) {
363
- // Escape at first step → cancel wizard (same as Ctrl+C)
364
- throw Object.assign(new Error('Cancelled'), { name: 'ExitPromptError' });
456
+ // Escape at first step → return to menu
457
+ return GoBack;
365
458
  }
366
459
  i = visited.pop();
367
460
  } else if (result === 'skip') {
@@ -372,7 +465,6 @@ export async function runCreateWizard(session = null) {
372
465
  }
373
466
  }
374
467
 
375
- console.error();
376
468
  return cfg;
377
469
  }
378
470
 
@@ -389,9 +481,9 @@ function promptEndpoint() {
389
481
  });
390
482
  }
391
483
 
392
- function promptArn() {
484
+ function promptArn(message) {
393
485
  return eInput({
394
- message: 'IAM role ARN',
486
+ message,
395
487
  validate: (v) => {
396
488
  if (!v.trim()) return 'ARN is required';
397
489
  if (!v.startsWith('arn:aws:iam:')) return 'Must start with arn:aws:iam:';