generate-ui-cli 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,17 +10,25 @@ const load_openapi_1 = require("../openapi/load-openapi");
10
10
  const screen_generator_1 = require("../generators/screen.generator");
11
11
  const screen_merge_1 = require("../generators/screen.merge");
12
12
  const permissions_1 = require("../license/permissions");
13
- const device_1 = require("../license/device");
14
13
  const telemetry_1 = require("../telemetry");
15
14
  const user_config_1 = require("../runtime/user-config");
16
15
  const logger_1 = require("../runtime/logger");
16
+ const project_config_1 = require("../runtime/project-config");
17
17
  async function generate(options) {
18
18
  void (0, telemetry_1.trackCommand)('generate', options.telemetryEnabled);
19
+ const projectConfig = (0, project_config_1.findProjectConfig)(process.cwd());
20
+ const configuredOpenApi = (0, project_config_1.pickConfiguredPath)(projectConfig.config, 'openapi');
21
+ const configuredOutput = (0, project_config_1.pickConfiguredPath)(projectConfig.config, 'output') ??
22
+ (0, project_config_1.pickConfiguredPath)(projectConfig.config, 'schemas');
23
+ const openApiPath = (0, project_config_1.resolveOptionalPath)(options.openapi, configuredOpenApi, projectConfig.configPath);
24
+ if (!openApiPath) {
25
+ throw new Error('Missing OpenAPI file.\n' +
26
+ 'Use --openapi <path> or set "openapi" (or "paths.openapi") in generateui-config.json.');
27
+ }
19
28
  /**
20
29
  * Caminho absoluto do OpenAPI (YAML)
21
30
  * Ex: /Users/.../generateui-playground/realWorldOpenApi.yaml
22
31
  */
23
- const openApiPath = path_1.default.resolve(process.cwd(), options.openapi);
24
32
  (0, logger_1.logStep)(`OpenAPI: ${openApiPath}`);
25
33
  /**
26
34
  * Raiz do playground (onde está o YAML)
@@ -30,7 +38,7 @@ async function generate(options) {
30
38
  /**
31
39
  * Onde o Angular consome os arquivos
32
40
  */
33
- const generateUiRoot = resolveGenerateUiRoot(projectRoot, options.output);
41
+ const generateUiRoot = resolveGenerateUiRoot(projectRoot, (0, project_config_1.resolveOptionalPath)(options.output, configuredOutput, projectConfig.configPath) ?? undefined);
34
42
  (0, logger_1.logStep)(`Schemas output: ${generateUiRoot}`);
35
43
  const generatedDir = path_1.default.join(generateUiRoot, 'generated');
36
44
  const overlaysDir = path_1.default.join(generateUiRoot, 'overlays');
@@ -47,15 +55,14 @@ async function generate(options) {
47
55
  */
48
56
  const routes = [];
49
57
  const usedOperationIds = new Set();
58
+ const resourceMap = new Map();
59
+ const screenByOpId = new Map();
60
+ const viewDefaults = [];
50
61
  const permissions = await (0, permissions_1.getPermissions)();
51
- const device = (0, device_1.loadDeviceIdentity)();
52
- (0, logger_1.logDebug)(`License: maxGenerations=${permissions.features.maxGenerations}, overrides=${permissions.features.uiOverrides}, safeRegen=${permissions.features.safeRegeneration}`);
53
- if (permissions.features.maxGenerations > -1 &&
54
- device.freeGenerationsUsed >= permissions.features.maxGenerations) {
55
- throw new Error('🔒 Você já utilizou sua geração gratuita.\n' +
56
- 'O plano Dev libera gerações ilimitadas, regeneração segura e UI inteligente.\n' +
57
- '👉 Execute `generate-ui login` para continuar.\n' +
58
- 'Se você já fez login e ainda vê esta mensagem, tente novamente com a mesma versão do CLI e verifique a conexão com a API.');
62
+ const subscriptionReason = normalizeSubscriptionReason(permissions.subscription.reason);
63
+ (0, logger_1.logDebug)(`License: status=${permissions.subscription.status}, overrides=${permissions.features.uiOverrides}, safeRegen=${permissions.features.safeRegeneration}, intelligent=${permissions.features.intelligentGeneration}`);
64
+ if (subscriptionReason) {
65
+ console.log(`ℹ Subscription: ${subscriptionReason}`);
59
66
  }
60
67
  /**
61
68
  * Parse do OpenAPI (já com $refs resolvidos)
@@ -67,6 +74,7 @@ async function generate(options) {
67
74
  * Itera por todos os endpoints
68
75
  */
69
76
  const operationIds = new Set();
77
+ const operationFileNames = new Set();
70
78
  for (const [pathKey, pathItem] of Object.entries(paths)) {
71
79
  for (const [method, rawOp] of Object.entries(pathItem)) {
72
80
  const op = rawOp;
@@ -75,6 +83,8 @@ async function generate(options) {
75
83
  const operationId = op.operationId ||
76
84
  buildOperationId(method.toLowerCase(), pathKey, usedOperationIds);
77
85
  operationIds.add(operationId);
86
+ operationFileNames.add(toSafeFileName(operationId));
87
+ recordResourceOp(resourceMap, pathKey, method.toLowerCase(), operationId, op);
78
88
  const endpoint = {
79
89
  operationId,
80
90
  path: pathKey,
@@ -86,7 +96,8 @@ async function generate(options) {
86
96
  * Gera o ScreenSchema completo
87
97
  */
88
98
  const screenSchema = (0, screen_generator_1.generateScreen)(endpoint, api);
89
- const fileName = `${operationId}.screen.json`;
99
+ screenByOpId.set(operationId, screenSchema);
100
+ const fileName = `${toSafeFileName(operationId)}.screen.json`;
90
101
  /**
91
102
  * 1️⃣ generated → SEMPRE sobrescrito (base técnica)
92
103
  */
@@ -132,9 +143,57 @@ async function generate(options) {
132
143
  : operationId),
133
144
  group: inferRouteGroup(op, pathKey)
134
145
  });
146
+ viewDefaults.push({
147
+ key: operationId,
148
+ view: 'list'
149
+ });
135
150
  console.log(`✔ Generated ${operationId}`);
136
151
  }
137
152
  }
153
+ const canOverride = permissions.features.uiOverrides;
154
+ const canRegenerateSafely = permissions.features.safeRegeneration;
155
+ const intelligentEnabled = Boolean(permissions.features.intelligentGeneration);
156
+ if (intelligentEnabled) {
157
+ const adminSchemas = buildAdminSchemas(resourceMap, usedOperationIds, screenByOpId);
158
+ for (const admin of adminSchemas) {
159
+ const fileName = `${toSafeFileName(admin.api.operationId)}.screen.json`;
160
+ const generatedPath = path_1.default.join(generatedDir, fileName);
161
+ fs_1.default.writeFileSync(generatedPath, JSON.stringify(admin, null, 2));
162
+ const overlayPath = path_1.default.join(overlaysDir, fileName);
163
+ if (canOverride && canRegenerateSafely) {
164
+ const overlay = fs_1.default.existsSync(overlayPath)
165
+ ? JSON.parse(fs_1.default.readFileSync(overlayPath, 'utf-8'))
166
+ : null;
167
+ const merged = (0, screen_merge_1.mergeScreen)(admin, overlay, null, {
168
+ openapiVersion: api?.info?.version || 'unknown',
169
+ debug: options.debug
170
+ });
171
+ fs_1.default.writeFileSync(overlayPath, JSON.stringify(merged.screen, null, 2));
172
+ }
173
+ else if (!fs_1.default.existsSync(overlayPath)) {
174
+ fs_1.default.writeFileSync(overlayPath, JSON.stringify(admin, null, 2));
175
+ }
176
+ routes.push({
177
+ path: admin.api.operationId,
178
+ operationId: admin.api.operationId,
179
+ label: admin.meta?.intelligent?.label,
180
+ group: admin.meta?.intelligent?.group ?? null
181
+ });
182
+ viewDefaults.push({
183
+ key: admin.api.operationId,
184
+ view: 'cards'
185
+ });
186
+ if (admin.meta?.intelligent?.listOperationId) {
187
+ viewDefaults.push({
188
+ key: admin.meta.intelligent.listOperationId,
189
+ view: 'list'
190
+ });
191
+ }
192
+ operationIds.add(admin.api.operationId);
193
+ operationFileNames.add(toSafeFileName(admin.api.operationId));
194
+ console.log(`✨ Generated ${admin.api.operationId}`);
195
+ }
196
+ }
138
197
  (0, logger_1.logStep)(`Screens generated: ${routes.length}`);
139
198
  /**
140
199
  * 4️⃣ Gera arquivo de rotas
@@ -146,20 +205,38 @@ async function generate(options) {
146
205
  * 4.3️⃣ Gera generateui-config.json (não sobrescreve)
147
206
  */
148
207
  const configPath = path_1.default.join(generateUiRoot, '..', '..', 'generateui-config.json');
208
+ let configPayload = null;
149
209
  if (!fs_1.default.existsSync(configPath)) {
150
- const defaultConfig = {
210
+ configPayload = {
151
211
  appTitle: 'Generate UI',
152
212
  defaultRoute: '',
153
213
  menu: {
154
214
  autoInject: true
155
- }
215
+ },
216
+ views: {}
156
217
  };
157
- fs_1.default.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
218
+ fs_1.default.writeFileSync(configPath, JSON.stringify(configPayload, null, 2));
158
219
  (0, logger_1.logDebug)(`Config created: ${configPath}`);
159
220
  }
160
221
  else {
222
+ try {
223
+ configPayload = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
224
+ }
225
+ catch {
226
+ configPayload = null;
227
+ }
161
228
  (0, logger_1.logDebug)(`Config found: ${configPath}`);
162
229
  }
230
+ if (configPayload && viewDefaults.length) {
231
+ configPayload.views = configPayload.views || {};
232
+ for (const entry of viewDefaults) {
233
+ if (!configPayload.views[entry.key]) {
234
+ configPayload.views[entry.key] = entry.view;
235
+ }
236
+ }
237
+ fs_1.default.writeFileSync(configPath, JSON.stringify(configPayload, null, 2));
238
+ (0, logger_1.logDebug)(`Config views updated: ${configPath}`);
239
+ }
163
240
  /**
164
241
  * 4.1️⃣ Gera menu inicial (override possível via menu.overrides.json)
165
242
  */
@@ -186,39 +263,44 @@ async function generate(options) {
186
263
  .filter(file => file.endsWith('.screen.json'));
187
264
  for (const file of overlayFiles) {
188
265
  const opId = file.replace(/\.screen\.json$/, '');
189
- if (!operationIds.has(opId)) {
266
+ if (!operationFileNames.has(opId)) {
190
267
  fs_1.default.rmSync(path_1.default.join(overlaysDir, file));
191
268
  if (options.debug) {
192
269
  console.log(`✖ Removed overlay ${opId}`);
193
270
  }
194
271
  }
195
272
  }
196
- if (permissions.features.maxGenerations > -1) {
197
- (0, device_1.incrementFreeGeneration)();
198
- }
199
273
  console.log('✔ Routes generated');
200
274
  console.log('');
201
275
  console.log('🎉 Next steps');
202
- console.log(' 1) Generate Angular code:');
203
- console.log(' generate-ui angular --schemas <your-generate-ui> --features <your-app>/src/app/features');
204
- console.log(' 2) Customize screens in generate-ui/overlays/');
205
- console.log(' 3) Customize menu in generate-ui/menu.overrides.json (created once, never overwritten)');
206
- console.log(' 4) Edit generateui-config.json to set appTitle/defaultRoute/menu.autoInject');
276
+ console.log(' 1) Default full flow:');
277
+ console.log(' generate-ui generate');
278
+ console.log(' 2) Advanced (schemas only):');
279
+ console.log(' generate-ui schema');
280
+ console.log(' 3) Advanced (Angular only):');
281
+ console.log(' generate-ui angular');
282
+ console.log(' 4) Customize screens in generate-ui/overlays/');
283
+ console.log(' 5) Customize menu in generate-ui/menu.overrides.json (created once, never overwritten)');
284
+ console.log(' 6) Edit generateui-config.json to set appTitle/defaultRoute/menu.autoInject/views');
207
285
  console.log('');
208
286
  (0, logger_1.logTip)('Run with --dev to see detailed logs and file paths.');
209
287
  }
288
+ function normalizeSubscriptionReason(reason) {
289
+ if (typeof reason !== 'string')
290
+ return '';
291
+ const trimmed = reason.trim();
292
+ return trimmed;
293
+ }
210
294
  function resolveGenerateUiRoot(projectRoot, output) {
211
295
  if (output) {
212
- return path_1.default.resolve(process.cwd(), output);
296
+ return path_1.default.isAbsolute(output)
297
+ ? output
298
+ : path_1.default.resolve(process.cwd(), output);
213
299
  }
214
300
  const srcRoot = path_1.default.join(projectRoot, 'src');
215
301
  if (fs_1.default.existsSync(srcRoot)) {
216
302
  return path_1.default.join(srcRoot, 'generate-ui');
217
303
  }
218
- const frontendSrcRoot = path_1.default.join(projectRoot, 'frontend', 'src');
219
- if (fs_1.default.existsSync(frontendSrcRoot)) {
220
- return path_1.default.join(frontendSrcRoot, 'generate-ui');
221
- }
222
304
  return path_1.default.join(projectRoot, 'generate-ui');
223
305
  }
224
306
  function mergeParameters(pathParams, opParams) {
@@ -272,6 +354,119 @@ function inferRouteGroup(op, pathKey) {
272
354
  .find(part => !part.startsWith('{') && !part.endsWith('}'));
273
355
  return segment ? segment : null;
274
356
  }
357
+ function recordResourceOp(map, pathKey, method, operationId, op) {
358
+ const isItemPath = /\/{[^}]+}$/.test(pathKey);
359
+ const basePath = isItemPath
360
+ ? pathKey.replace(/\/{[^}]+}$/, '')
361
+ : pathKey;
362
+ const paramMatch = isItemPath
363
+ ? pathKey.match(/\/{([^}]+)}$/)
364
+ : null;
365
+ const idParam = paramMatch ? paramMatch[1] : null;
366
+ const tag = Array.isArray(op?.tags) && op.tags.length
367
+ ? String(op.tags[0]).trim()
368
+ : undefined;
369
+ const entry = map.get(basePath) || {
370
+ basePath
371
+ };
372
+ if (tag && !entry.tag)
373
+ entry.tag = tag;
374
+ if (idParam && !entry.idParam)
375
+ entry.idParam = idParam;
376
+ const payload = {
377
+ operationId,
378
+ method,
379
+ path: pathKey,
380
+ tag
381
+ };
382
+ if (!isItemPath && method === 'get')
383
+ entry.list = payload;
384
+ if (isItemPath && method === 'get')
385
+ entry.detail = payload;
386
+ if (isItemPath && (method === 'put' || method === 'patch')) {
387
+ entry.update = payload;
388
+ }
389
+ if (isItemPath && method === 'delete')
390
+ entry.remove = payload;
391
+ map.set(basePath, entry);
392
+ }
393
+ function buildAdminSchemas(resources, usedOperationIds, screenByOpId) {
394
+ const adminSchemas = [];
395
+ for (const resource of resources.values()) {
396
+ if (!resource.list)
397
+ continue;
398
+ const entity = inferEntityName(resource.basePath);
399
+ const baseId = toPascalCase(entity);
400
+ const baseOpId = `${baseId}Admin`;
401
+ let operationId = baseOpId;
402
+ let index = 2;
403
+ while (usedOperationIds.has(operationId)) {
404
+ operationId = `${baseOpId}${index}`;
405
+ index += 1;
406
+ }
407
+ usedOperationIds.add(operationId);
408
+ const label = `${toLabel(entity)} Admin`;
409
+ const group = resource.tag ? resource.tag : inferGroup(resource.basePath);
410
+ const listSchema = screenByOpId.get(resource.list.operationId);
411
+ const columns = listSchema?.data?.table?.columns &&
412
+ Array.isArray(listSchema.data.table.columns)
413
+ ? listSchema.data.table.columns
414
+ : [];
415
+ const responseFormat = listSchema?.response?.format === 'cards' ||
416
+ listSchema?.response?.format === 'raw' ||
417
+ listSchema?.response?.format === 'table'
418
+ ? listSchema.response.format
419
+ : columns.length > 0
420
+ ? 'table'
421
+ : null;
422
+ adminSchemas.push({
423
+ meta: {
424
+ intelligent: {
425
+ kind: 'adminList',
426
+ label,
427
+ group,
428
+ listOperationId: resource.list.operationId,
429
+ detailOperationId: resource.detail?.operationId ?? null,
430
+ updateOperationId: resource.update?.operationId ?? null,
431
+ deleteOperationId: resource.remove?.operationId ?? null,
432
+ idParam: resource.idParam ?? null
433
+ }
434
+ },
435
+ entity: toLabel(entity),
436
+ description: 'Smart admin list generated from collection endpoints.',
437
+ data: {
438
+ table: {
439
+ columns
440
+ }
441
+ },
442
+ response: responseFormat
443
+ ? { format: responseFormat }
444
+ : undefined,
445
+ api: {
446
+ operationId,
447
+ method: 'get',
448
+ endpoint: resource.list.path
449
+ }
450
+ });
451
+ }
452
+ return adminSchemas;
453
+ }
454
+ function inferEntityName(pathKey) {
455
+ const segments = String(pathKey || '')
456
+ .split('/')
457
+ .filter(Boolean);
458
+ if (!segments.length)
459
+ return 'Resource';
460
+ const last = segments[segments.length - 1];
461
+ return last.replace(/[^a-zA-Z0-9]+/g, ' ');
462
+ }
463
+ function inferGroup(pathKey) {
464
+ const segment = String(pathKey || '')
465
+ .split('/')
466
+ .map(part => part.trim())
467
+ .filter(Boolean)[0];
468
+ return segment ? segment : null;
469
+ }
275
470
  function buildMenuFromRoutes(routes) {
276
471
  const groups = [];
277
472
  const ungrouped = [];
@@ -312,11 +507,28 @@ function toKebab(value) {
312
507
  .toLowerCase();
313
508
  }
314
509
  function toLabel(value) {
315
- return String(value)
510
+ return stripDiacritics(String(value))
316
511
  .replace(/[_-]/g, ' ')
317
512
  .replace(/([a-z])([A-Z])/g, '$1 $2')
318
513
  .replace(/\b\w/g, char => char.toUpperCase());
319
514
  }
515
+ function toSafeFileName(value) {
516
+ return String(value)
517
+ .replace(/[\\/]/g, '-')
518
+ .replace(/\s+/g, '-');
519
+ }
520
+ function stripDiacritics(value) {
521
+ return value
522
+ .normalize('NFD')
523
+ .replace(/[\u0300-\u036f]/g, '');
524
+ }
525
+ function toPascalCase(value) {
526
+ return String(value)
527
+ .split(/[^a-zA-Z0-9]+/)
528
+ .filter(Boolean)
529
+ .map(part => part[0].toUpperCase() + part.slice(1))
530
+ .join('');
531
+ }
320
532
  function httpVerbToPrefix(verb) {
321
533
  switch (verb) {
322
534
  case 'get':
@@ -22,9 +22,11 @@ async function login(options) {
22
22
  (0, token_1.saveToken)(token);
23
23
  (0, logger_1.logDebug)('Token saved');
24
24
  let permissionsLoaded = false;
25
+ let subscriptionReason = '';
25
26
  try {
26
- await (0, permissions_1.fetchPermissions)();
27
+ const permissions = await (0, permissions_1.fetchPermissions)();
27
28
  permissionsLoaded = true;
29
+ subscriptionReason = String(permissions.subscription.reason ?? '').trim();
28
30
  }
29
31
  catch {
30
32
  console.warn('⚠ Não foi possível validar a licença agora. Verifique sua conexão e rode o comando novamente se necessário.');
@@ -40,6 +42,9 @@ async function login(options) {
40
42
  console.log(permissionsLoaded
41
43
  ? '✔ Login completo'
42
44
  : '✔ Login completo (verificação pendente)');
45
+ if (permissionsLoaded && subscriptionReason) {
46
+ console.log(`ℹ Subscription: ${subscriptionReason}`);
47
+ }
43
48
  }
44
49
  function resolveLoginEmail() {
45
50
  const envEmail = process.env.GIT_AUTHOR_EMAIL ||
@@ -63,6 +68,7 @@ function resolveLoginEmail() {
63
68
  async function waitForLogin() {
64
69
  return new Promise((resolve, reject) => {
65
70
  let loginUrl = '';
71
+ let settled = false;
66
72
  const server = http_1.default.createServer((req, res) => {
67
73
  const requestUrl = req.url || '/';
68
74
  if (!requestUrl.startsWith('/callback')) {
@@ -89,72 +95,128 @@ async function waitForLogin() {
89
95
  <title>GenerateUI</title>
90
96
  <style>
91
97
  :root {
92
- --bg: #f3e8ff;
98
+ --bg-1: #f9f5ea;
99
+ --bg-2: #eef7f4;
93
100
  --card: #ffffff;
94
- --text: #2a1b3d;
95
- --muted: #6b5b7a;
96
- --primary: #7c3aed;
97
- --glow: rgba(124, 58, 237, 0.22);
101
+ --text: #39455f;
102
+ --muted: #76819a;
103
+ --accent: #6fd3c0;
104
+ --accent-2: #9fd8ff;
105
+ --border: #e1e7f2;
106
+ --shadow: rgba(76, 88, 120, 0.14);
98
107
  }
99
108
  * {
100
109
  box-sizing: border-box;
101
- font-family: "IBM Plex Serif", "Georgia", serif;
110
+ font-family: "Manrope", "Segoe UI", sans-serif;
102
111
  }
103
112
  body {
104
113
  margin: 0;
105
114
  min-height: 100vh;
106
115
  display: grid;
107
116
  place-items: center;
108
- background: radial-gradient(circle at top, #f5ebff, #e9d5ff);
117
+ background:
118
+ radial-gradient(circle at 15% 0%, #fff3c8 0%, var(--bg-1) 36%, transparent 60%),
119
+ radial-gradient(circle at 85% 0%, #e6f7ff 0%, var(--bg-2) 40%, transparent 70%),
120
+ linear-gradient(135deg, #fdfbf6, #f3f7fb);
109
121
  color: var(--text);
110
122
  }
111
123
  main {
112
- background: var(--card);
113
- padding: 52px 48px;
114
- border-radius: 24px;
115
- box-shadow: 0 24px 70px var(--glow);
116
- width: min(460px, 92vw);
117
- text-align: center;
124
+ background: linear-gradient(180deg, rgba(255,255,255,0.94), rgba(255,255,255,0.98));
125
+ padding: 44px;
126
+ border-radius: 28px;
127
+ border: 1px solid var(--border);
128
+ box-shadow: 0 24px 60px var(--shadow);
129
+ width: min(520px, 92vw);
130
+ text-align: left;
131
+ position: relative;
132
+ overflow: hidden;
133
+ }
134
+ main::before {
135
+ content: "";
136
+ position: absolute;
137
+ inset: -40% 25% auto auto;
138
+ width: 280px;
139
+ height: 280px;
140
+ background: radial-gradient(circle, rgba(111,211,192,0.35), transparent 70%);
141
+ pointer-events: none;
142
+ }
143
+ .label {
144
+ letter-spacing: 0.22em;
145
+ font-size: 12px;
146
+ color: var(--muted);
147
+ text-transform: uppercase;
118
148
  }
119
149
  h1 {
120
- margin: 0 0 12px;
150
+ margin: 10px 0 12px;
121
151
  font-size: 30px;
152
+ letter-spacing: 0.02em;
122
153
  }
123
154
  p {
124
155
  margin: 0 0 24px;
125
156
  color: var(--muted);
126
- font-size: 16px;
157
+ line-height: 1.5;
127
158
  }
128
159
  .pill {
129
- display: inline-block;
130
- background: #f5e9ff;
131
- color: var(--primary);
132
- padding: 8px 14px;
160
+ padding: 4px 10px;
133
161
  border-radius: 999px;
134
- font-weight: 600;
135
- letter-spacing: 0.2px;
162
+ background: rgba(255,255,255,0.65);
163
+ border: 1px solid var(--border);
164
+ font-size: 12px;
165
+ color: var(--muted);
166
+ }
167
+ .footer {
168
+ margin-top: 12px;
169
+ font-size: 13px;
170
+ color: var(--muted);
171
+ }
172
+ @media (max-width: 520px) {
173
+ main {
174
+ padding: 32px 24px;
175
+ text-align: center;
176
+ }
136
177
  }
137
178
  </style>
138
179
  </head>
139
180
  <body>
140
181
  <main>
141
- <h1>Let's Generate UI</h1>
142
- <p>Login completed successfully.</p>
143
- <span class="pill">You can close this window</span>
182
+ <div class="label">Generated UI</div>
183
+ <h1>Login completed</h1>
184
+ <p>You can now return to the terminal.</p>
185
+ <div class="footer">
186
+ <span class="pill">You can close this window</span>
187
+ </div>
144
188
  </main>
145
189
  </body>
146
190
  </html>`);
147
191
  clearTimeout(timeout);
148
192
  server.close();
193
+ settled = true;
194
+ process.off('SIGINT', handleSigint);
195
+ process.off('SIGTERM', handleSigint);
149
196
  resolve({ accessToken, expiresAt });
150
197
  });
198
+ const handleSigint = () => {
199
+ if (settled)
200
+ return;
201
+ settled = true;
202
+ clearTimeout(timeout);
203
+ server.close();
204
+ reject(new Error('Login canceled by user (SIGINT).'));
205
+ };
151
206
  const timeout = setTimeout(() => {
207
+ if (settled)
208
+ return;
209
+ settled = true;
152
210
  server.close();
211
+ process.off('SIGINT', handleSigint);
212
+ process.off('SIGTERM', handleSigint);
153
213
  const help = loginUrl
154
214
  ? ` Ensure the login page is reachable and try again: ${loginUrl}`
155
215
  : ` Ensure ${(0, config_1.getWebAuthUrl)()} and ${(0, config_1.getApiBaseUrl)()} are reachable.`;
156
216
  reject(new Error(`Login timed out.${help}`));
157
217
  }, LOGIN_TIMEOUT_MS);
218
+ process.on('SIGINT', handleSigint);
219
+ process.on('SIGTERM', handleSigint);
158
220
  server.listen(0, () => {
159
221
  const address = server.address();
160
222
  if (!address || typeof address === 'string') {