smoonb 1.0.11 → 1.0.13

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,427 +1,427 @@
1
- const https = require('https');
2
- const inquirer = require('inquirer');
3
- const path = require('path');
4
- const { readEnvFile, writeEnvFile } = require('../../../utils/env');
5
- const { APP_CONFIG } = require('../../../config/appConfig');
6
- const { t } = require('../../../i18n');
7
- const ui = require('../../../utils/cliUi');
8
- const { getOrCreateInstallationId, maskInstallationId } = require('../../../utils/installationId');
9
- const {
10
- createCorrelationId,
11
- maskSecret,
12
- safeStringifyHeaders,
13
- truncateBody,
14
- sanitizeResponseParsedForBundle,
15
- classifyRequestError,
16
- buildSupportBundle,
17
- formatBundleForTerminal,
18
- BODY_TRUNCATE_BYTES_BUNDLE
19
- } = require('../../../utils/supportDiagnostics');
20
-
21
- const LICENSE_KEY_ENV = 'SMOONB_LICENSE_KEY';
22
- const REQUEST_TIMEOUT_MS = 15000;
23
- const ENDPOINT_PATH = '/v1/license/validate';
24
-
25
- function maskLicense(key) {
26
- if (!key || typeof key !== 'string') return '***';
27
- const s = key.trim();
28
- if (s.length <= 10) return '***';
29
- return `${s.slice(0, 6)}...${s.slice(-4)}`;
30
- }
31
-
32
- /**
33
- * POST com https; retorna statusCode, headers, body (parsed), rawBody (string).
34
- */
35
- function httpsPost(url, body, headers, timeoutMs) {
36
- const u = new URL(url);
37
- const bodyStr = JSON.stringify(body);
38
- const opts = {
39
- hostname: u.hostname,
40
- port: u.port || 443,
41
- path: u.pathname + u.search,
42
- method: 'POST',
43
- headers: {
44
- 'Content-Type': 'application/json',
45
- 'Content-Length': Buffer.byteLength(bodyStr),
46
- ...headers
47
- }
48
- };
49
- return new Promise((resolve, reject) => {
50
- const req = https.request(opts, (res) => {
51
- const chunks = [];
52
- res.on('data', (chunk) => { chunks.push(chunk); });
53
- res.on('end', () => {
54
- const rawBody = Buffer.concat(chunks).toString('utf8');
55
- let bodyParsed = {};
56
- try {
57
- bodyParsed = rawBody ? JSON.parse(rawBody) : {};
58
- } catch {
59
- bodyParsed = {};
60
- }
61
- resolve({
62
- statusCode: res.statusCode,
63
- statusMessage: res.statusMessage,
64
- headers: res.headers,
65
- body: bodyParsed,
66
- rawBody
67
- });
68
- });
69
- });
70
- req.on('error', reject);
71
- req.setTimeout(timeoutMs, () => {
72
- req.destroy();
73
- const e = new Error('Request timeout');
74
- e.code = 'ETIMEDOUT';
75
- reject(e);
76
- });
77
- req.write(bodyStr);
78
- req.end();
79
- });
80
- }
81
-
82
- /**
83
- * Exibe erro estruturado + instruções de suporte + bundle (pronto para colar).
84
- * Usa ui (sem gray/dim) para legibilidade em Windows e copy/paste.
85
- * @param {Function} getT
86
- * @param {string} appUrl - APP_CONFIG.appUrl
87
- * @param {object} opts
88
- */
89
- function printLicenseValidationError(getT, appUrl, opts) {
90
- const {
91
- correlationId,
92
- kind,
93
- httpStatus,
94
- httpStatusText,
95
- endpoint,
96
- fullUrl,
97
- timeoutMs,
98
- licenseMasked,
99
- version,
100
- responseHeaders,
101
- responseBodyTruncated,
102
- err
103
- } = opts;
104
-
105
- const bundleParams = {
106
- correlationId,
107
- product: 'smoonb CLI',
108
- version,
109
- timestamp: new Date().toISOString(),
110
- os: process.platform,
111
- arch: process.arch,
112
- nodeVersion: process.version,
113
- command: `smoonb ${opts.command || 'backup'}`,
114
- licenseMasked: licenseMasked || '***',
115
- apiBaseUrl: fullUrl ? fullUrl.replace(/\/[^/]*$/, '') : APP_CONFIG.apiBaseUrl,
116
- endpoint: endpoint || ENDPOINT_PATH,
117
- timeoutMs,
118
- telemetryEnabled: (process.env.SMOONB_TELEMETRY_ENABLED !== 'false').toString(),
119
- installationIdMasked: opts.installationIdMasked
120
- };
121
-
122
- if (httpStatus != null) {
123
- bundleParams.httpStatus = httpStatus;
124
- bundleParams.httpStatusText = httpStatusText || '';
125
- bundleParams.responseHeaders = responseHeaders;
126
- bundleParams.responseBodyRaw = responseBodyTruncated || undefined;
127
- }
128
- if (err) {
129
- bundleParams.errorName = err.name;
130
- bundleParams.errorMessage = (err.message || '').replace(/\r?\n/g, ' ');
131
- bundleParams.errorCode = err.code;
132
- bundleParams.errno = err.errno;
133
- if (process.env.SMOONB_DEBUG === 'true') {
134
- bundleParams.stack = err.stack;
135
- }
136
- }
137
-
138
- const bundleText = buildSupportBundle(bundleParams);
139
- const typeKey = kind === 'http' ? 'license.error.typeHttp'
140
- : kind === 'tls' ? 'license.error.typeTls'
141
- : kind === 'parse' ? 'license.error.typeParse'
142
- : 'license.error.typeNetwork';
143
-
144
- ui.errorBold(`\n❌ ${getT('license.error.title')}\n`);
145
- ui.info(` ${getT('license.error.whatHappened')}`);
146
- ui.info(` ${getT(typeKey)}`);
147
- if (kind === 'network' || kind === 'tls') {
148
- ui.hint(` ${getT('license.error.testConnectivity', { url: APP_CONFIG.healthCheckUrl || appUrl + '/api/health' })}`);
149
- }
150
- if (httpStatus != null) {
151
- ui.info(` ${getT('license.error.status', { status: `${httpStatus} ${(httpStatusText || '').trim()}` })}`);
152
- }
153
- if (endpoint || fullUrl) {
154
- ui.info(` ${getT('license.error.endpoint', { endpoint: fullUrl || endpoint })}`);
155
- }
156
- ui.info(` ${getT('license.error.correlationId', { id: correlationId })}\n`);
157
-
158
- ui.link(` ${getT('license.error.howToHelp')}`);
159
- ui.info(` ${getT('license.error.supportStep1', { url: appUrl })}`);
160
- ui.info(` ${getT('license.error.supportStep2')}`);
161
- ui.info(` ${getT('license.error.supportStep3')}`);
162
- ui.info(` ${getT('license.error.supportStep4')}`);
163
- ui.info(` ${getT('license.error.supportStep5')}\n`);
164
-
165
- ui.block(formatBundleForTerminal(bundleText));
166
- ui.info('');
167
- ui.link(` ${getT('license.error.visit', { url: appUrl })}\n`);
168
- ui.hint(` ${getT('license.error.subjectSuggestion', { id: correlationId })}`);
169
- ui.hint(` ${getT('license.error.messageSuggestion')}\n`);
170
- }
171
-
172
- /**
173
- * Exibe falha quando servidor retorna allow === false (reason literal + CTA + bundle).
174
- * Se reason/reasonCode === LICENSE_ALREADY_BOUND, exibe mensagem específica de revogar e gerar nova.
175
- */
176
- function printLicenseDeniedByServer(getT, appUrl, opts) {
177
- const {
178
- correlationId,
179
- correlationIdEchoed,
180
- reason,
181
- reasonCode,
182
- fullUrl,
183
- rawBody,
184
- resBody,
185
- licenseMasked,
186
- version,
187
- command,
188
- installationIdMasked
189
- } = opts;
190
- const displayId = correlationIdEchoed || correlationId;
191
- const responseBodyRaw = rawBody ? truncateBody(rawBody, BODY_TRUNCATE_BYTES_BUNDLE) : '';
192
- const responseParsed = sanitizeResponseParsedForBundle(resBody);
193
- const isAlreadyBound = (reasonCode && String(reasonCode).toUpperCase() === 'LICENSE_ALREADY_BOUND') ||
194
- (reason && String(reason).toUpperCase() === 'LICENSE_ALREADY_BOUND');
195
-
196
- ui.errorBold(`\n❌ ${getT('license.denied.title')}\n`);
197
- if (isAlreadyBound) {
198
- ui.info(` ${getT('license.denied.alreadyBound')}`);
199
- ui.info(` ${getT('license.denied.alreadyBoundAction', { url: appUrl })}`);
200
- } else {
201
- ui.info(` ${getT('license.denied.reason', { reason: reason || 'No reason provided' })}`);
202
- }
203
- ui.info(` ${getT('license.denied.correlationId', { id: displayId })}\n`);
204
- ui.link(` ${getT('license.error.howToHelp')}`);
205
- ui.info(` ${getT('license.error.supportStep1', { url: appUrl })}`);
206
- ui.info(` ${getT('license.error.supportStep2')}`);
207
- ui.info(` ${getT('license.error.supportStep3')}`);
208
- ui.info(` ${getT('license.error.supportStep4')}`);
209
- ui.info(` ${getT('license.error.supportStep5')}\n`);
210
-
211
- const bundleParams = {
212
- correlationId: displayId,
213
- product: 'smoonb CLI',
214
- version,
215
- timestamp: new Date().toISOString(),
216
- os: process.platform,
217
- arch: process.arch,
218
- nodeVersion: process.version,
219
- command: `smoonb ${command || 'backup'}`,
220
- licenseMasked: licenseMasked || '***',
221
- apiBaseUrl: fullUrl ? fullUrl.replace(/\/[^/]*$/, '') : APP_CONFIG.apiBaseUrl,
222
- endpoint: ENDPOINT_PATH,
223
- timeoutMs: REQUEST_TIMEOUT_MS,
224
- telemetryEnabled: (process.env.SMOONB_TELEMETRY_ENABLED !== 'false').toString(),
225
- httpStatus: 200,
226
- httpStatusText: 'OK',
227
- responseBodyRaw: responseBodyRaw || undefined,
228
- responseParsed: responseParsed !== '(none)' ? responseParsed : undefined,
229
- correlationIdEchoed: correlationIdEchoed && correlationIdEchoed !== correlationId ? correlationIdEchoed : undefined,
230
- installationIdMasked: installationIdMasked || undefined,
231
- reason: reason != null ? String(reason) : (reasonCode != null ? String(reasonCode) : undefined)
232
- };
233
- const bundleText = buildSupportBundle(bundleParams);
234
- ui.block(formatBundleForTerminal(bundleText));
235
- ui.info('');
236
- ui.link(` ${getT('license.error.visit', { url: appUrl })}\n`);
237
- ui.hint(` ${getT('license.error.subjectSuggestion', { id: displayId })}`);
238
- ui.hint(` ${getT('license.error.messageSuggestion')}\n`);
239
- }
240
-
241
- /**
242
- * Step 00: Validação de licença via backend.
243
- * Roda antes de todos os outros steps.
244
- * Resolve SMOONB_LICENSE_KEY: env > .env.local > prompt. Persiste se prompt.
245
- * Sem cache local; falha de rede/servidor = abortar.
246
- * @param {{ envPath: string, command: string }} options
247
- * @returns {Promise<{ license: object, smoonbToken: string }>}
248
- */
249
- module.exports = async (options) => {
250
- const getT = global.smoonbI18n?.t || t;
251
- const envPath = options.envPath || path.join(process.cwd(), '.env.local');
252
- const command = options.command || 'backup';
253
- const appUrl = APP_CONFIG.appUrl;
254
- const fullUrl = `${APP_CONFIG.apiBaseUrl}${ENDPOINT_PATH}`;
255
-
256
- let licenseKey = (process.env[LICENSE_KEY_ENV] || '').toString().trim();
257
- if (!licenseKey) {
258
- const currentEnv = await readEnvFile(envPath);
259
- licenseKey = (currentEnv[LICENSE_KEY_ENV] || '').toString().trim();
260
- }
261
- if (!licenseKey) {
262
- const { licenseKey: prompted } = await inquirer.prompt([{
263
- type: 'input',
264
- name: 'licenseKey',
265
- message: getT('license.promptPaste'),
266
- prefix: ''
267
- }]);
268
- licenseKey = (prompted || '').toString().trim();
269
- if (!licenseKey) {
270
- ui.error(`❌ ${getT('license.required')}`);
271
- ui.link(` ${appUrl}`);
272
- process.exit(1);
273
- }
274
- const currentEnv = await readEnvFile(envPath);
275
- await writeEnvFile(envPath, { ...currentEnv, [LICENSE_KEY_ENV]: licenseKey });
276
- }
277
-
278
- let cliVersion = '0.0.0';
279
- try {
280
- cliVersion = require('../../../../package.json').version;
281
- } catch {
282
- // ignore
283
- }
284
-
285
- let installationId;
286
- try {
287
- installationId = await getOrCreateInstallationId();
288
- } catch (err) {
289
- ui.error(`❌ ${getT('license.error.title')}`);
290
- ui.info(` ${getT('license.error.whatHappened')}`);
291
- ui.info(` Could not create or read installation ID: ${(err && err.message) || err}`);
292
- ui.link(` ${getT('license.error.visit', { url: appUrl })}`);
293
- process.exit(1);
294
- }
295
-
296
- const correlationId = createCorrelationId();
297
- const body = {
298
- licenseKey,
299
- installationId,
300
- correlationId,
301
- cliVersion,
302
- command,
303
- platform: process.platform,
304
- arch: process.arch,
305
- nodeVersion: process.version
306
- };
307
- const requestHeaders = {
308
- 'User-Agent': `smoonb-cli/${cliVersion}`,
309
- 'x-correlation-id': correlationId
310
- };
311
-
312
- let response;
313
- try {
314
- response = await httpsPost(
315
- fullUrl,
316
- body,
317
- requestHeaders,
318
- REQUEST_TIMEOUT_MS
319
- );
320
- } catch (err) {
321
- const classification = classifyRequestError(err);
322
- if (classification.hintUser) {
323
- ui.warn(` ${classification.hintUser}`);
324
- }
325
- printLicenseValidationError(getT, appUrl, {
326
- correlationId,
327
- kind: classification.kind,
328
- endpoint: ENDPOINT_PATH,
329
- fullUrl,
330
- timeoutMs: REQUEST_TIMEOUT_MS,
331
- licenseMasked: maskSecret(licenseKey),
332
- version: cliVersion,
333
- command,
334
- installationIdMasked: installationId ? maskInstallationId(installationId) : undefined,
335
- err
336
- });
337
- process.exit(1);
338
- }
339
-
340
- const { statusCode, statusMessage, headers, body: resBody, rawBody } = response;
341
-
342
- // Resposta 200 mas corpo inesperado (ex.: HTML em vez de JSON)
343
- const looksLikeJson = rawBody && rawBody.trim().startsWith('{');
344
- if (statusCode === 200 && !looksLikeJson && rawBody) {
345
- const displayCorrelationId = resBody.correlationId || correlationId;
346
- const responseHeadersStr = safeStringifyHeaders(headers);
347
- const responseBodyTruncated = truncateBody(rawBody);
348
- printLicenseValidationError(getT, appUrl, {
349
- correlationId: displayCorrelationId,
350
- kind: 'parse',
351
- endpoint: ENDPOINT_PATH,
352
- fullUrl,
353
- timeoutMs: REQUEST_TIMEOUT_MS,
354
- licenseMasked: maskSecret(licenseKey),
355
- version: cliVersion,
356
- command,
357
- installationIdMasked: installationId ? maskInstallationId(installationId) : undefined,
358
- responseHeaders: responseHeadersStr,
359
- responseBodyTruncated
360
- });
361
- process.exit(1);
362
- }
363
-
364
- if (statusCode !== 200) {
365
- const displayCorrelationId = resBody.correlationId || correlationId;
366
- const responseHeadersStr = safeStringifyHeaders(headers);
367
- const responseBodyRaw = rawBody ? truncateBody(rawBody, BODY_TRUNCATE_BYTES_BUNDLE) : '';
368
- printLicenseValidationError(getT, appUrl, {
369
- correlationId: displayCorrelationId,
370
- kind: 'http',
371
- httpStatus: statusCode,
372
- httpStatusText: statusMessage,
373
- endpoint: ENDPOINT_PATH,
374
- fullUrl,
375
- timeoutMs: REQUEST_TIMEOUT_MS,
376
- licenseMasked: maskSecret(licenseKey),
377
- version: cliVersion,
378
- command,
379
- installationIdMasked: installationId ? maskInstallationId(installationId) : undefined,
380
- responseHeaders: responseHeadersStr,
381
- responseBodyTruncated: responseBodyRaw
382
- });
383
- process.exit(1);
384
- }
385
-
386
- // allow como boolean robusto: true ou "true" -> true; resto -> false
387
- const allowRaw = resBody.allow;
388
- const allow = allowRaw === true || (typeof allowRaw === 'string' && allowRaw.toLowerCase() === 'true');
389
-
390
- if (allow === true) {
391
- // Nunca negar quando allow === true
392
- const status = resBody.status || '';
393
- if (status === 'trial') {
394
- ui.warn(` ⚠️ ${getT('license.trialNotice')}`);
395
- }
396
- const license = {
397
- status: resBody.status,
398
- plan: resBody.plan,
399
- expiresAt: resBody.expiresAt,
400
- accountId: resBody.accountId,
401
- workspaceId: resBody.workspaceId
402
- };
403
- const smoonbToken = resBody.token || '';
404
- return { license, smoonbToken };
405
- }
406
-
407
- // allow === false: exibir reason do servidor literalmente + CTA + bundle
408
- const serverReason = resBody.reason != null ? String(resBody.reason) : 'No reason provided';
409
- const serverReasonCode = resBody.reasonCode != null ? String(resBody.reasonCode) : null;
410
- const correlationIdEchoed = resBody.correlationId || null;
411
- printLicenseDeniedByServer(getT, appUrl, {
412
- correlationId,
413
- correlationIdEchoed,
414
- reason: serverReason,
415
- reasonCode: serverReasonCode,
416
- fullUrl,
417
- rawBody,
418
- resBody,
419
- licenseMasked: maskSecret(licenseKey),
420
- version: cliVersion,
421
- command,
422
- installationIdMasked: installationId ? maskInstallationId(installationId) : undefined
423
- });
424
- process.exit(1);
425
- };
426
-
427
- module.exports.maskLicense = maskLicense;
1
+ const https = require('https');
2
+ const inquirer = require('inquirer');
3
+ const path = require('path');
4
+ const { readEnvFile } = require('../../../utils/env');
5
+ const { APP_CONFIG } = require('../../../config/appConfig');
6
+ const { t } = require('../../../i18n');
7
+ const ui = require('../../../utils/cliUi');
8
+ const { getOrCreateInstallationId, maskInstallationId } = require('../../../utils/installationId');
9
+ const {
10
+ createCorrelationId,
11
+ maskSecret,
12
+ safeStringifyHeaders,
13
+ truncateBody,
14
+ sanitizeResponseParsedForBundle,
15
+ classifyRequestError,
16
+ buildSupportBundle,
17
+ formatBundleForTerminal,
18
+ BODY_TRUNCATE_BYTES_BUNDLE
19
+ } = require('../../../utils/supportDiagnostics');
20
+
21
+ const LICENSE_KEY_ENV = 'SMOONB_LICENSE_KEY';
22
+ const REQUEST_TIMEOUT_MS = 15000;
23
+ const ENDPOINT_PATH = '/v1/license/validate';
24
+
25
+ function maskLicense(key) {
26
+ if (!key || typeof key !== 'string') return '***';
27
+ const s = key.trim();
28
+ if (s.length <= 10) return '***';
29
+ return `${s.slice(0, 6)}...${s.slice(-4)}`;
30
+ }
31
+
32
+ /**
33
+ * POST com https; retorna statusCode, headers, body (parsed), rawBody (string).
34
+ */
35
+ function httpsPost(url, body, headers, timeoutMs) {
36
+ const u = new URL(url);
37
+ const bodyStr = JSON.stringify(body);
38
+ const opts = {
39
+ hostname: u.hostname,
40
+ port: u.port || 443,
41
+ path: u.pathname + u.search,
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'Content-Length': Buffer.byteLength(bodyStr),
46
+ ...headers
47
+ }
48
+ };
49
+ return new Promise((resolve, reject) => {
50
+ const req = https.request(opts, (res) => {
51
+ const chunks = [];
52
+ res.on('data', (chunk) => { chunks.push(chunk); });
53
+ res.on('end', () => {
54
+ const rawBody = Buffer.concat(chunks).toString('utf8');
55
+ let bodyParsed = {};
56
+ try {
57
+ bodyParsed = rawBody ? JSON.parse(rawBody) : {};
58
+ } catch {
59
+ bodyParsed = {};
60
+ }
61
+ resolve({
62
+ statusCode: res.statusCode,
63
+ statusMessage: res.statusMessage,
64
+ headers: res.headers,
65
+ body: bodyParsed,
66
+ rawBody
67
+ });
68
+ });
69
+ });
70
+ req.on('error', reject);
71
+ req.setTimeout(timeoutMs, () => {
72
+ req.destroy();
73
+ const e = new Error('Request timeout');
74
+ e.code = 'ETIMEDOUT';
75
+ reject(e);
76
+ });
77
+ req.write(bodyStr);
78
+ req.end();
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Exibe erro estruturado + instruções de suporte + bundle (pronto para colar).
84
+ * Usa ui (sem gray/dim) para legibilidade em Windows e copy/paste.
85
+ * @param {Function} getT
86
+ * @param {string} appUrl - APP_CONFIG.appUrl
87
+ * @param {object} opts
88
+ */
89
+ function printLicenseValidationError(getT, appUrl, opts) {
90
+ const {
91
+ correlationId,
92
+ kind,
93
+ httpStatus,
94
+ httpStatusText,
95
+ endpoint,
96
+ fullUrl,
97
+ timeoutMs,
98
+ licenseMasked,
99
+ version,
100
+ responseHeaders,
101
+ responseBodyTruncated,
102
+ err
103
+ } = opts;
104
+
105
+ const bundleParams = {
106
+ correlationId,
107
+ product: 'smoonb CLI',
108
+ version,
109
+ timestamp: new Date().toISOString(),
110
+ os: process.platform,
111
+ arch: process.arch,
112
+ nodeVersion: process.version,
113
+ command: `smoonb ${opts.command || 'backup'}`,
114
+ licenseMasked: licenseMasked || '***',
115
+ apiBaseUrl: fullUrl ? fullUrl.replace(/\/[^/]*$/, '') : APP_CONFIG.apiBaseUrl,
116
+ endpoint: endpoint || ENDPOINT_PATH,
117
+ timeoutMs,
118
+ telemetryEnabled: (process.env.SMOONB_TELEMETRY_ENABLED !== 'false').toString(),
119
+ installationIdMasked: opts.installationIdMasked
120
+ };
121
+
122
+ if (httpStatus != null) {
123
+ bundleParams.httpStatus = httpStatus;
124
+ bundleParams.httpStatusText = httpStatusText || '';
125
+ bundleParams.responseHeaders = responseHeaders;
126
+ bundleParams.responseBodyRaw = responseBodyTruncated || undefined;
127
+ }
128
+ if (err) {
129
+ bundleParams.errorName = err.name;
130
+ bundleParams.errorMessage = (err.message || '').replace(/\r?\n/g, ' ');
131
+ bundleParams.errorCode = err.code;
132
+ bundleParams.errno = err.errno;
133
+ if (process.env.SMOONB_DEBUG === 'true') {
134
+ bundleParams.stack = err.stack;
135
+ }
136
+ }
137
+
138
+ const bundleText = buildSupportBundle(bundleParams);
139
+ const typeKey = kind === 'http' ? 'license.error.typeHttp'
140
+ : kind === 'tls' ? 'license.error.typeTls'
141
+ : kind === 'parse' ? 'license.error.typeParse'
142
+ : 'license.error.typeNetwork';
143
+
144
+ ui.errorBold(`\n❌ ${getT('license.error.title')}\n`);
145
+ ui.info(` ${getT('license.error.whatHappened')}`);
146
+ ui.info(` ${getT(typeKey)}`);
147
+ if (kind === 'network' || kind === 'tls') {
148
+ ui.hint(` ${getT('license.error.testConnectivity', { url: APP_CONFIG.healthCheckUrl || appUrl + '/api/health' })}`);
149
+ }
150
+ if (httpStatus != null) {
151
+ ui.info(` ${getT('license.error.status', { status: `${httpStatus} ${(httpStatusText || '').trim()}` })}`);
152
+ }
153
+ if (endpoint || fullUrl) {
154
+ ui.info(` ${getT('license.error.endpoint', { endpoint: fullUrl || endpoint })}`);
155
+ }
156
+ ui.info(` ${getT('license.error.correlationId', { id: correlationId })}\n`);
157
+
158
+ ui.link(` ${getT('license.error.howToHelp')}`);
159
+ ui.info(` ${getT('license.error.supportStep1', { url: appUrl })}`);
160
+ ui.info(` ${getT('license.error.supportStep2')}`);
161
+ ui.info(` ${getT('license.error.supportStep3')}`);
162
+ ui.info(` ${getT('license.error.supportStep4')}`);
163
+ ui.info(` ${getT('license.error.supportStep5')}\n`);
164
+
165
+ ui.block(formatBundleForTerminal(bundleText));
166
+ ui.info('');
167
+ ui.link(` ${getT('license.error.visit', { url: appUrl })}\n`);
168
+ ui.hint(` ${getT('license.error.subjectSuggestion', { id: correlationId })}`);
169
+ ui.hint(` ${getT('license.error.messageSuggestion')}\n`);
170
+ }
171
+
172
+ /**
173
+ * Exibe falha quando servidor retorna allow === false (reason literal + CTA + bundle).
174
+ * Se reason/reasonCode === LICENSE_ALREADY_BOUND, exibe mensagem específica de revogar e gerar nova.
175
+ */
176
+ function printLicenseDeniedByServer(getT, appUrl, opts) {
177
+ const {
178
+ correlationId,
179
+ correlationIdEchoed,
180
+ reason,
181
+ reasonCode,
182
+ fullUrl,
183
+ rawBody,
184
+ resBody,
185
+ licenseMasked,
186
+ version,
187
+ command,
188
+ installationIdMasked
189
+ } = opts;
190
+ const displayId = correlationIdEchoed || correlationId;
191
+ const responseBodyRaw = rawBody ? truncateBody(rawBody, BODY_TRUNCATE_BYTES_BUNDLE) : '';
192
+ const responseParsed = sanitizeResponseParsedForBundle(resBody);
193
+ const isAlreadyBound = (reasonCode && String(reasonCode).toUpperCase() === 'LICENSE_ALREADY_BOUND') ||
194
+ (reason && String(reason).toUpperCase() === 'LICENSE_ALREADY_BOUND');
195
+
196
+ ui.errorBold(`\n❌ ${getT('license.denied.title')}\n`);
197
+ if (isAlreadyBound) {
198
+ ui.info(` ${getT('license.denied.alreadyBound')}`);
199
+ ui.info(` ${getT('license.denied.alreadyBoundAction', { url: appUrl })}`);
200
+ } else {
201
+ ui.info(` ${getT('license.denied.reason', { reason: reason || 'No reason provided' })}`);
202
+ }
203
+ ui.info(` ${getT('license.denied.correlationId', { id: displayId })}\n`);
204
+ ui.link(` ${getT('license.error.howToHelp')}`);
205
+ ui.info(` ${getT('license.error.supportStep1', { url: appUrl })}`);
206
+ ui.info(` ${getT('license.error.supportStep2')}`);
207
+ ui.info(` ${getT('license.error.supportStep3')}`);
208
+ ui.info(` ${getT('license.error.supportStep4')}`);
209
+ ui.info(` ${getT('license.error.supportStep5')}\n`);
210
+
211
+ const bundleParams = {
212
+ correlationId: displayId,
213
+ product: 'smoonb CLI',
214
+ version,
215
+ timestamp: new Date().toISOString(),
216
+ os: process.platform,
217
+ arch: process.arch,
218
+ nodeVersion: process.version,
219
+ command: `smoonb ${command || 'backup'}`,
220
+ licenseMasked: licenseMasked || '***',
221
+ apiBaseUrl: fullUrl ? fullUrl.replace(/\/[^/]*$/, '') : APP_CONFIG.apiBaseUrl,
222
+ endpoint: ENDPOINT_PATH,
223
+ timeoutMs: REQUEST_TIMEOUT_MS,
224
+ telemetryEnabled: (process.env.SMOONB_TELEMETRY_ENABLED !== 'false').toString(),
225
+ httpStatus: 200,
226
+ httpStatusText: 'OK',
227
+ responseBodyRaw: responseBodyRaw || undefined,
228
+ responseParsed: responseParsed !== '(none)' ? responseParsed : undefined,
229
+ correlationIdEchoed: correlationIdEchoed && correlationIdEchoed !== correlationId ? correlationIdEchoed : undefined,
230
+ installationIdMasked: installationIdMasked || undefined,
231
+ reason: reason != null ? String(reason) : (reasonCode != null ? String(reasonCode) : undefined)
232
+ };
233
+ const bundleText = buildSupportBundle(bundleParams);
234
+ ui.block(formatBundleForTerminal(bundleText));
235
+ ui.info('');
236
+ ui.link(` ${getT('license.error.visit', { url: appUrl })}\n`);
237
+ ui.hint(` ${getT('license.error.subjectSuggestion', { id: displayId })}`);
238
+ ui.hint(` ${getT('license.error.messageSuggestion')}\n`);
239
+ }
240
+
241
+ /**
242
+ * Step 00: Validação de licença via backend.
243
+ * Roda antes de todos os outros steps.
244
+ * Resolve SMOONB_LICENSE_KEY: env > .env.local > prompt. Persiste se prompt.
245
+ * Sem cache local; falha de rede/servidor = abortar.
246
+ * @param {{ envPath: string, command: string }} options
247
+ * @returns {Promise<{ license: object, smoonbToken: string }>}
248
+ */
249
+ module.exports = async (options) => {
250
+ const getT = global.smoonbI18n?.t || t;
251
+ const envPath = options.envPath || path.join(process.cwd(), '.env.local');
252
+ const command = options.command || 'backup';
253
+ const appUrl = APP_CONFIG.appUrl;
254
+ const fullUrl = `${APP_CONFIG.apiBaseUrl}${ENDPOINT_PATH}`;
255
+
256
+ let licenseKey = (process.env[LICENSE_KEY_ENV] || '').toString().trim();
257
+ if (!licenseKey) {
258
+ const currentEnv = await readEnvFile(envPath);
259
+ licenseKey = (currentEnv[LICENSE_KEY_ENV] || '').toString().trim();
260
+ }
261
+ if (!licenseKey) {
262
+ const { licenseKey: prompted } = await inquirer.prompt([{
263
+ type: 'input',
264
+ name: 'licenseKey',
265
+ message: getT('license.promptPaste'),
266
+ prefix: ''
267
+ }]);
268
+ licenseKey = (prompted || '').toString().trim();
269
+ if (!licenseKey) {
270
+ ui.error(`❌ ${getT('license.required')}`);
271
+ ui.link(` ${appUrl}`);
272
+ process.exit(1);
273
+ }
274
+ // Keep interactive credentials in memory; preserve the project's environment file.
275
+ process.env[LICENSE_KEY_ENV] = licenseKey;
276
+ }
277
+
278
+ let cliVersion = '0.0.0';
279
+ try {
280
+ cliVersion = require('../../../../package.json').version;
281
+ } catch {
282
+ // ignore
283
+ }
284
+
285
+ let installationId;
286
+ try {
287
+ installationId = await getOrCreateInstallationId();
288
+ } catch (err) {
289
+ ui.error(`❌ ${getT('license.error.title')}`);
290
+ ui.info(` ${getT('license.error.whatHappened')}`);
291
+ ui.info(` Could not create or read installation ID: ${(err && err.message) || err}`);
292
+ ui.link(` ${getT('license.error.visit', { url: appUrl })}`);
293
+ process.exit(1);
294
+ }
295
+
296
+ const correlationId = createCorrelationId();
297
+ const body = {
298
+ licenseKey,
299
+ installationId,
300
+ correlationId,
301
+ cliVersion,
302
+ command,
303
+ platform: process.platform,
304
+ arch: process.arch,
305
+ nodeVersion: process.version
306
+ };
307
+ const requestHeaders = {
308
+ 'User-Agent': `smoonb-cli/${cliVersion}`,
309
+ 'x-correlation-id': correlationId
310
+ };
311
+
312
+ let response;
313
+ try {
314
+ response = await httpsPost(
315
+ fullUrl,
316
+ body,
317
+ requestHeaders,
318
+ REQUEST_TIMEOUT_MS
319
+ );
320
+ } catch (err) {
321
+ const classification = classifyRequestError(err);
322
+ if (classification.hintUser) {
323
+ ui.warn(` ${classification.hintUser}`);
324
+ }
325
+ printLicenseValidationError(getT, appUrl, {
326
+ correlationId,
327
+ kind: classification.kind,
328
+ endpoint: ENDPOINT_PATH,
329
+ fullUrl,
330
+ timeoutMs: REQUEST_TIMEOUT_MS,
331
+ licenseMasked: maskSecret(licenseKey),
332
+ version: cliVersion,
333
+ command,
334
+ installationIdMasked: installationId ? maskInstallationId(installationId) : undefined,
335
+ err
336
+ });
337
+ process.exit(1);
338
+ }
339
+
340
+ const { statusCode, statusMessage, headers, body: resBody, rawBody } = response;
341
+
342
+ // Resposta 200 mas corpo inesperado (ex.: HTML em vez de JSON)
343
+ const looksLikeJson = rawBody && rawBody.trim().startsWith('{');
344
+ if (statusCode === 200 && !looksLikeJson && rawBody) {
345
+ const displayCorrelationId = resBody.correlationId || correlationId;
346
+ const responseHeadersStr = safeStringifyHeaders(headers);
347
+ const responseBodyTruncated = truncateBody(rawBody);
348
+ printLicenseValidationError(getT, appUrl, {
349
+ correlationId: displayCorrelationId,
350
+ kind: 'parse',
351
+ endpoint: ENDPOINT_PATH,
352
+ fullUrl,
353
+ timeoutMs: REQUEST_TIMEOUT_MS,
354
+ licenseMasked: maskSecret(licenseKey),
355
+ version: cliVersion,
356
+ command,
357
+ installationIdMasked: installationId ? maskInstallationId(installationId) : undefined,
358
+ responseHeaders: responseHeadersStr,
359
+ responseBodyTruncated
360
+ });
361
+ process.exit(1);
362
+ }
363
+
364
+ if (statusCode !== 200) {
365
+ const displayCorrelationId = resBody.correlationId || correlationId;
366
+ const responseHeadersStr = safeStringifyHeaders(headers);
367
+ const responseBodyRaw = rawBody ? truncateBody(rawBody, BODY_TRUNCATE_BYTES_BUNDLE) : '';
368
+ printLicenseValidationError(getT, appUrl, {
369
+ correlationId: displayCorrelationId,
370
+ kind: 'http',
371
+ httpStatus: statusCode,
372
+ httpStatusText: statusMessage,
373
+ endpoint: ENDPOINT_PATH,
374
+ fullUrl,
375
+ timeoutMs: REQUEST_TIMEOUT_MS,
376
+ licenseMasked: maskSecret(licenseKey),
377
+ version: cliVersion,
378
+ command,
379
+ installationIdMasked: installationId ? maskInstallationId(installationId) : undefined,
380
+ responseHeaders: responseHeadersStr,
381
+ responseBodyTruncated: responseBodyRaw
382
+ });
383
+ process.exit(1);
384
+ }
385
+
386
+ // allow como boolean robusto: true ou "true" -> true; resto -> false
387
+ const allowRaw = resBody.allow;
388
+ const allow = allowRaw === true || (typeof allowRaw === 'string' && allowRaw.toLowerCase() === 'true');
389
+
390
+ if (allow === true) {
391
+ // Nunca negar quando allow === true
392
+ const status = resBody.status || '';
393
+ if (status === 'trial') {
394
+ ui.warn(` ⚠️ ${getT('license.trialNotice')}`);
395
+ }
396
+ const license = {
397
+ status: resBody.status,
398
+ plan: resBody.plan,
399
+ expiresAt: resBody.expiresAt,
400
+ accountId: resBody.accountId,
401
+ workspaceId: resBody.workspaceId
402
+ };
403
+ const smoonbToken = resBody.token || '';
404
+ return { license, smoonbToken };
405
+ }
406
+
407
+ // allow === false: exibir reason do servidor literalmente + CTA + bundle
408
+ const serverReason = resBody.reason != null ? String(resBody.reason) : 'No reason provided';
409
+ const serverReasonCode = resBody.reasonCode != null ? String(resBody.reasonCode) : null;
410
+ const correlationIdEchoed = resBody.correlationId || null;
411
+ printLicenseDeniedByServer(getT, appUrl, {
412
+ correlationId,
413
+ correlationIdEchoed,
414
+ reason: serverReason,
415
+ reasonCode: serverReasonCode,
416
+ fullUrl,
417
+ rawBody,
418
+ resBody,
419
+ licenseMasked: maskSecret(licenseKey),
420
+ version: cliVersion,
421
+ command,
422
+ installationIdMasked: installationId ? maskInstallationId(installationId) : undefined
423
+ });
424
+ process.exit(1);
425
+ };
426
+
427
+ module.exports.maskLicense = maskLicense;