tsoft-cli 2.5.15 → 3.4.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.
package/src/storage.js ADDED
@@ -0,0 +1,488 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import axios from 'axios';
4
+ import * as config from './config.js';
5
+
6
+ /**
7
+ * Store domain'i dizin adına uygun formata çevirir
8
+ * @param {string} domain - Store domain (örn: birtan.1isim.com)
9
+ * @returns {string} Normalized slug (örn: birtan-1isim-com)
10
+ */
11
+ export function normalizeStoreDomain(domain) {
12
+ return domain.replace(/\./g, '-').toLowerCase();
13
+ }
14
+
15
+ /**
16
+ * Store'a özel config dizinini döner (CWD bazlı)
17
+ * @param {string} store - Store domain
18
+ * @returns {string} Config dizin path'i
19
+ */
20
+ export function getStoreConfigDir(store) {
21
+ const storeSlug = normalizeStoreDomain(store);
22
+ return path.join(process.cwd(), '.tsoft', storeSlug);
23
+ }
24
+
25
+ /**
26
+ * Store'a özel config dosya path'ini döner
27
+ * @param {string} store - Store domain
28
+ * @returns {string} Config dosya path'i
29
+ */
30
+ export function getStoreConfigFile(store) {
31
+ return path.join(getStoreConfigDir(store), 'config.json');
32
+ }
33
+
34
+ /**
35
+ * Store'a özel .env dosya path'ini döner
36
+ * @param {string} store - Store domain
37
+ * @returns {string} .env dosya path'i
38
+ */
39
+ export function getStoreEnvFile(store) {
40
+ return path.join(getStoreConfigDir(store), '.env');
41
+ }
42
+
43
+ /**
44
+ * Aktif store'u ayarlar
45
+ * @param {string} store - Store domain
46
+ * @returns {Promise<void>}
47
+ */
48
+ export async function setActiveStore(store) {
49
+ const activeStoreFile = path.join(process.cwd(), '.tsoft', 'active-store.txt');
50
+ await fs.mkdir(path.dirname(activeStoreFile), { recursive: true });
51
+ await fs.writeFile(activeStoreFile, store, 'utf-8');
52
+ }
53
+
54
+ /**
55
+ * Aktif store'u okur
56
+ * @returns {Promise<string|null>} Store domain veya null
57
+ */
58
+ export async function getActiveStore() {
59
+ try {
60
+ const activeStoreFile = path.join(process.cwd(), '.tsoft', 'active-store.txt');
61
+ return (await fs.readFile(activeStoreFile, 'utf-8')).trim();
62
+ } catch (error) {
63
+ if (error.code === 'ENOENT') {
64
+ return null;
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Authorization code'u access token'a çevirir
72
+ * @param {string} code - Authorization code
73
+ * @param {string} codeVerifier - PKCE code verifier
74
+ * @param {string} store - Store domain (callback'ten dönen store parametresi)
75
+ * @param {string} state - State (CSRF protection)
76
+ * @returns {Promise<object>} Token bilgileri
77
+ */
78
+ export async function exchangeToken(code, codeVerifier, store, state) {
79
+ const tokenUrl = config.getTokenUrl(store);
80
+
81
+ try {
82
+ // Laravel Passport application/x-www-form-urlencoded formatında bekler
83
+ const params = new URLSearchParams();
84
+ params.append('grant_type', 'authorization_code');
85
+ params.append('client_id', config.CLIENT_ID);
86
+ params.append('redirect_uri', config.REDIRECT_URI);
87
+ params.append('code', code);
88
+ params.append('code_verifier', codeVerifier);
89
+ params.append('state', state);
90
+
91
+ const response = await axios.post(tokenUrl, params, {
92
+ headers: {
93
+ 'Content-Type': 'application/x-www-form-urlencoded',
94
+ 'Accept': 'application/json',
95
+ },
96
+ });
97
+
98
+ // Backend response: { status, message, data: { token, user } }
99
+ const responseData = response.data.data;
100
+
101
+ return {
102
+ accessToken: responseData.token.accessToken,
103
+ accessTokenExpiresAt: responseData.token.accessTokenExpiresAt,
104
+ refreshToken: responseData.token.refreshToken,
105
+ refreshTokenExpiresAt: responseData.token.refreshTokenExpiresAt,
106
+ tokenType: 'Bearer',
107
+ user: responseData.user,
108
+ store: store,
109
+ };
110
+ } catch (error) {
111
+ if (error.response?.data) {
112
+ console.error('Token exchange hatası:', error.response.data);
113
+ }
114
+ if (error.response) {
115
+ throw new Error(
116
+ `Token exchange hatası: ${error.response.status} - ${
117
+ error.response.data.error_description ||
118
+ error.response.data.message ||
119
+ 'Bilinmeyen hata'
120
+ }`
121
+ );
122
+ }
123
+ throw new Error(`Token exchange hatası: ${error.message}`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Refresh token ile yeni access token alır
129
+ * @param {string} refreshToken - Refresh token
130
+ * @param {string} store - Store domain
131
+ * @returns {Promise<object>} Yeni token bilgileri
132
+ */
133
+ export async function refreshAccessToken(refreshToken, store) {
134
+ const refreshUrl = config.getRefreshUrl(store);
135
+
136
+ try {
137
+ // Laravel Passport application/x-www-form-urlencoded formatında bekler
138
+ const params = new URLSearchParams();
139
+ params.append('grant_type', 'refresh_token');
140
+ params.append('refresh_token', refreshToken);
141
+ params.append('client_id', config.CLIENT_ID);
142
+
143
+ const response = await axios.post(refreshUrl, params, {
144
+ headers: {
145
+ 'Content-Type': 'application/x-www-form-urlencoded',
146
+ 'Accept': 'application/json',
147
+ },
148
+ });
149
+
150
+ // Backend response: { status, message, data: { token, user } }
151
+ const responseData = response.data.data;
152
+
153
+ return {
154
+ accessToken: responseData.token.accessToken,
155
+ accessTokenExpiresAt: responseData.token.accessTokenExpiresAt,
156
+ refreshToken: responseData.token.refreshToken,
157
+ refreshTokenExpiresAt: responseData.token.refreshTokenExpiresAt,
158
+ tokenType: 'Bearer',
159
+ user: responseData.user,
160
+ };
161
+ } catch (error) {
162
+ if (error.response?.data) {
163
+ console.error('Token refresh hatası:', error.response.data);
164
+ }
165
+ if (error.response) {
166
+ throw new Error(
167
+ `Token refresh hatası: ${error.response.status} - ${
168
+ error.response.data.error_description ||
169
+ error.response.data.message ||
170
+ 'Bilinmeyen hata'
171
+ }`
172
+ );
173
+ }
174
+ throw new Error(`Token refresh hatası: ${error.message}`);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Token'ı revoke eder
180
+ * @param {string} token - Access veya refresh token
181
+ * @param {string} store - Store domain
182
+ * @returns {Promise<void>}
183
+ */
184
+ export async function revokeToken(token, store) {
185
+ const revokeUrl = config.getRevokeUrl(store);
186
+
187
+ try {
188
+ // Laravel Passport application/x-www-form-urlencoded formatında bekler
189
+ const params = new URLSearchParams();
190
+ params.append('token', token);
191
+ params.append('client_id', config.CLIENT_ID);
192
+
193
+ await axios.post(revokeUrl, params, {
194
+ headers: {
195
+ 'Content-Type': 'application/x-www-form-urlencoded',
196
+ 'Accept': 'application/json',
197
+ },
198
+ });
199
+ } catch (error) {
200
+ // Revoke hatalarını sessizce yoksay (token zaten geçersiz olabilir)
201
+ console.warn('Token revoke uyarısı:', error.message);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * .env dosyası oluşturur
207
+ * @param {object} tokens - Token ve user bilgileri
208
+ * @param {string} store - Store domain
209
+ * @returns {Promise<void>}
210
+ */
211
+ async function writeEnvFile(tokens, store) {
212
+ const envContent = `# tsoft CLI - Auto-generated credentials
213
+ # Store: ${store}
214
+ # Generated: ${new Date().toISOString()}
215
+
216
+ TSOFT_ACCESS_TOKEN="${tokens.accessToken}"
217
+ TSOFT_REFRESH_TOKEN="${tokens.refreshToken}"
218
+ TSOFT_USER_ID=${tokens.user.id}
219
+ TSOFT_USER_NAME="${tokens.user.name}"
220
+ TSOFT_USER_SURNAME="${tokens.user.surname}"
221
+ TSOFT_USER_EMAIL="${tokens.user.email}"
222
+ TSOFT_STORE_DOMAIN="${store}"
223
+ TSOFT_USER_VERSION="${tokens.user.version}"
224
+ `;
225
+
226
+ const envFile = getStoreEnvFile(store);
227
+ await fs.writeFile(envFile, envContent, 'utf-8');
228
+ await fs.chmod(envFile, 0o600); // rw-------
229
+ }
230
+
231
+ /**
232
+ * Token'ları local config dosyasına kaydeder
233
+ * @param {object} tokens - Token bilgileri
234
+ * @param {string} store - Store domain
235
+ * @returns {Promise<void>}
236
+ */
237
+ export async function saveTokens(tokens, store) {
238
+ const configDir = getStoreConfigDir(store);
239
+ await fs.mkdir(configDir, { recursive: true });
240
+
241
+ const configData = {
242
+ accessToken: tokens.accessToken,
243
+ accessTokenExpiresAt: tokens.accessTokenExpiresAt,
244
+ refreshToken: tokens.refreshToken,
245
+ refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
246
+ tokenType: tokens.tokenType,
247
+ store: store,
248
+ user: {
249
+ id: tokens.user.id,
250
+ name: tokens.user.name,
251
+ surname: tokens.user.surname,
252
+ email: tokens.user.email,
253
+ hasTwoFactor: tokens.user.hasTwoFactor,
254
+ hasRestrictedAccess: tokens.user.hasRestrictedAccess,
255
+ version: tokens.user.version,
256
+ },
257
+ updatedAt: new Date().toISOString(),
258
+ };
259
+
260
+ const configFile = getStoreConfigFile(store);
261
+ await fs.writeFile(configFile, JSON.stringify(configData, null, 2), 'utf-8');
262
+ await fs.chmod(configFile, 0o600);
263
+
264
+ // .env dosyasını da oluştur
265
+ await writeEnvFile(tokens, store);
266
+
267
+ // Aktif store'u ayarla
268
+ await setActiveStore(store);
269
+ }
270
+
271
+ /**
272
+ * Local config dosyasından token'ları yükler
273
+ * @param {string} [store] - Store domain (opsiyonel, belirtilmezse active store kullanılır)
274
+ * @returns {Promise<object|null>} Token bilgileri veya null
275
+ */
276
+ export async function loadTokens(store = null) {
277
+ if (!store) {
278
+ store = await getActiveStore();
279
+ if (!store) {
280
+ return null; // Aktif store yok
281
+ }
282
+ }
283
+
284
+ try {
285
+ const configFile = getStoreConfigFile(store);
286
+ const data = await fs.readFile(configFile, 'utf-8');
287
+ return JSON.parse(data);
288
+ } catch (error) {
289
+ if (error.code === 'ENOENT') {
290
+ return null; // Dosya yok
291
+ }
292
+ throw error;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Token'ların geçerliliğini kontrol eder
298
+ * @param {object} tokens - Token bilgileri
299
+ * @returns {boolean} Token geçerli mi?
300
+ */
301
+ export function isTokenValid(tokens) {
302
+ if (!tokens || !tokens.accessToken || !tokens.accessTokenExpiresAt) {
303
+ return false;
304
+ }
305
+
306
+ const expiresAt = new Date(tokens.accessTokenExpiresAt);
307
+ const now = new Date();
308
+
309
+ // 5 dakika önce expire olacaksa yenile
310
+ const bufferTime = 5 * 60 * 1000; // 5 dakika
311
+ return expiresAt.getTime() - now.getTime() > bufferTime;
312
+ }
313
+
314
+ /**
315
+ * Refresh token'ın geçerliliğini kontrol eder
316
+ * @param {object} tokens - Token bilgileri
317
+ * @returns {boolean} Refresh token geçerli mi?
318
+ */
319
+ export function isRefreshTokenValid(tokens) {
320
+ if (!tokens || !tokens.refreshToken || !tokens.refreshTokenExpiresAt) {
321
+ return false;
322
+ }
323
+
324
+ const expiresAt = new Date(tokens.refreshTokenExpiresAt);
325
+ const now = new Date();
326
+
327
+ return expiresAt.getTime() > now.getTime();
328
+ }
329
+
330
+ /**
331
+ * Access token geçerliliğini kontrol eder, gerekirse refresh eder
332
+ * @param {object} tokens - Token bilgileri
333
+ * @returns {Promise<object>} Güncel token bilgileri
334
+ * @throws {Error} RELOGIN_REQUIRED - Refresh token da expire olduysa
335
+ */
336
+ export async function ensureValidToken(tokens) {
337
+ if (!tokens) {
338
+ throw new Error('RELOGIN_REQUIRED');
339
+ }
340
+
341
+ // Access token geçerliyse direkt dön
342
+ if (isTokenValid(tokens)) {
343
+ return tokens;
344
+ }
345
+
346
+ // Refresh token geçerliyse refresh et
347
+ if (isRefreshTokenValid(tokens)) {
348
+ const newTokens = await refreshAccessToken(tokens.refreshToken, tokens.store);
349
+ await saveTokens(newTokens, tokens.store);
350
+ return await loadTokens(tokens.store);
351
+ }
352
+
353
+ // Her ikisi de geçersizse, otomatik re-login gerekiyor
354
+ throw new Error('RELOGIN_REQUIRED');
355
+ }
356
+
357
+ /**
358
+ * Store'un config dosyalarını siler (logout için)
359
+ * @param {string} [store] - Store domain (opsiyonel, belirtilmezse active store kullanılır)
360
+ * @returns {Promise<void>}
361
+ */
362
+ export async function deleteConfig(store = null) {
363
+ if (!store) {
364
+ store = await getActiveStore();
365
+ if (!store) {
366
+ return; // Aktif store yok, bir şey yapma
367
+ }
368
+ }
369
+
370
+ try {
371
+ const configDir = getStoreConfigDir(store);
372
+ await fs.rm(configDir, { recursive: true, force: true });
373
+
374
+ // Active store dosyasını da sil
375
+ const activeStoreFile = path.join(process.cwd(), '.tsoft', 'active-store.txt');
376
+ await fs.unlink(activeStoreFile).catch(() => {}); // Hata varsa sessizce yoksay
377
+ } catch (error) {
378
+ if (error.code !== 'ENOENT') {
379
+ throw error;
380
+ }
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Token'ın ne kadar süre geçerli olduğunu döner
386
+ * @param {object} tokens - Token bilgileri
387
+ * @returns {string} Kalan süre (human-readable)
388
+ */
389
+ export function getTokenExpiry(tokens) {
390
+ if (!tokens || !tokens.accessTokenExpiresAt) {
391
+ return 'Bilinmiyor';
392
+ }
393
+
394
+ const expiresAt = new Date(tokens.accessTokenExpiresAt);
395
+ const now = new Date();
396
+ const diff = expiresAt.getTime() - now.getTime();
397
+
398
+ if (diff <= 0) {
399
+ return 'Süresi dolmuş';
400
+ }
401
+
402
+ const hours = Math.floor(diff / (1000 * 60 * 60));
403
+ const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
404
+
405
+ if (hours > 0) {
406
+ return `${hours} saat ${minutes} dakika`;
407
+ }
408
+ return `${minutes} dakika`;
409
+ }
410
+
411
+ /**
412
+ * Türkçe karakterleri URL dostu hale getirir
413
+ * @param {string} text - Dönüştürülecek metin
414
+ * @returns {string} Sluglaştırılmış metin
415
+ */
416
+ export function slugify(text) {
417
+ const trMap = {
418
+ 'ç': 'c', 'Ç': 'c',
419
+ 'ğ': 'g', 'Ğ': 'g',
420
+ 'ı': 'i', 'İ': 'i',
421
+ 'ö': 'o', 'Ö': 'o',
422
+ 'ş': 's', 'Ş': 's',
423
+ 'ü': 'u', 'Ü': 'u',
424
+ };
425
+
426
+ return text
427
+ .split('')
428
+ .map(char => trMap[char] || char)
429
+ .join('')
430
+ .toLowerCase()
431
+ .trim()
432
+ .replace(/[^\w\s-]/g, '')
433
+ .replace(/[\s_-]+/g, '-')
434
+ .replace(/^-+|-+$/g, '');
435
+ }
436
+
437
+ /**
438
+ * Aktif temayı ayarlar
439
+ * @param {string} theme - Tema adı
440
+ * @returns {Promise<void>}
441
+ */
442
+ export async function setActiveTheme(theme) {
443
+ const activeThemeFile = path.join(process.cwd(), '.tsoft', 'active-theme.txt');
444
+ await fs.mkdir(path.dirname(activeThemeFile), { recursive: true });
445
+ await fs.writeFile(activeThemeFile, theme, 'utf-8');
446
+ }
447
+
448
+ /**
449
+ * Aktif temayı okur
450
+ * @returns {Promise<string|null>} Tema adı veya null
451
+ */
452
+ export async function getActiveTheme() {
453
+ try {
454
+ const activeThemeFile = path.join(process.cwd(), '.tsoft', 'active-theme.txt');
455
+ return (await fs.readFile(activeThemeFile, 'utf-8')).trim();
456
+ } catch (error) {
457
+ if (error.code === 'ENOENT') {
458
+ return null;
459
+ }
460
+ throw error;
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Tema slug'ından UUID/folder adını bulur
466
+ * @param {string} themeSlug - Tema slug (örn: 'my-theme')
467
+ * @returns {Promise<Object>} {uuid, name, version, active}
468
+ */
469
+ export async function getThemeUuidBySlug(themeSlug) {
470
+ const { createApiClient } = await import('./api-client.js');
471
+ const apiClient = await createApiClient();
472
+ const response = await apiClient.get('/theme');
473
+ const themes = response.data || [];
474
+
475
+ const theme = themes.find(t => slugify(t.name) === themeSlug);
476
+
477
+ if (!theme) {
478
+ throw new Error(`Tema bulunamadı: ${themeSlug}`);
479
+ }
480
+
481
+ return {
482
+ uuid: theme.theme_folder,
483
+ name: theme.name,
484
+ version: theme.version,
485
+ active: theme.active
486
+ };
487
+ }
488
+
@@ -0,0 +1,219 @@
1
+ import chalk from 'chalk';
2
+ import { createRequire } from 'module';
3
+ import { TSOFT_COLORS, createTSoftGradient } from './prompt-wrapper.js';
4
+ import { humanOut, isJsonMode, jsonOut } from '../output-mode.js';
5
+ import { t } from '../i18n.js';
6
+ import { loadTokens, getActiveStore, getActiveTheme } from '../storage.js';
7
+
8
+ const require = createRequire(import.meta.url);
9
+
10
+ /**
11
+ * Read version from package.json
12
+ */
13
+ function getVersion() {
14
+ try {
15
+ const pkg = require('../../package.json');
16
+ return pkg.version || '0.0.0';
17
+ } catch {
18
+ return '0.0.0';
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Hardcoded ASCII art for "TSOFT" — compact 5-line design
24
+ */
25
+ const TSOFT_ASCII_ART = `
26
+ ████████╗███████╗ ██████╗ ███████╗████████╗
27
+ ██╔══╝██╔════╝██╔═══██╗██╔════╝╚══██╔══╝
28
+ ██║ ███████╗██║ ██║█████╗ ██║
29
+ ██║ ╚════██║██║ ██║██╔══╝ ██║
30
+ ██║ ███████║╚██████╔╝██║ ██║
31
+ ╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝
32
+ `.trim();
33
+
34
+ /**
35
+ * Command groups definition — maps group key to command entries.
36
+ * Each entry has a nameKey (locale) and command path for display.
37
+ */
38
+ const COMMAND_GROUPS = [
39
+ {
40
+ groupKey: 'help.group.development',
41
+ commands: [
42
+ { display: 'theme dev', descKey: 'help.cmd.theme_dev' },
43
+ { display: 'theme push', descKey: 'help.cmd.theme_push' },
44
+ { display: 'theme section list', descKey: 'help.cmd.theme_section_list' },
45
+ { display: 'theme section add', descKey: 'help.cmd.theme_section_add' },
46
+ ]
47
+ },
48
+ {
49
+ groupKey: 'help.group.theme_management',
50
+ commands: [
51
+ { display: 'theme list', descKey: 'help.cmd.theme_list' },
52
+ { display: 'theme create', descKey: 'help.cmd.theme_create' },
53
+ { display: 'theme pull', descKey: 'help.cmd.theme_pull' },
54
+ { display: 'theme use', descKey: 'help.cmd.theme_use' },
55
+ { display: 'theme init', descKey: 'help.cmd.theme_init' },
56
+ ]
57
+ },
58
+ {
59
+ groupKey: 'help.group.account',
60
+ commands: [
61
+ { display: 'login', descKey: 'help.cmd.login' },
62
+ { display: 'logout', descKey: 'help.cmd.logout' },
63
+ { display: 'whoami', descKey: 'help.cmd.whoami' },
64
+ { display: 'org switch', descKey: 'help.cmd.org_switch' },
65
+ ]
66
+ }
67
+ ];
68
+
69
+ /**
70
+ * Compute the max display name length across all command groups
71
+ * for consistent column alignment.
72
+ */
73
+ function computeMaxNameWidth() {
74
+ let max = 0;
75
+ for (const group of COMMAND_GROUPS) {
76
+ for (const cmd of group.commands) {
77
+ if (cmd.display.length > max) max = cmd.display.length;
78
+ }
79
+ }
80
+ return max;
81
+ }
82
+
83
+ /**
84
+ * Render a single command group block.
85
+ * @param {object} group
86
+ * @param {number} maxWidth - max command name width for column alignment
87
+ * @returns {string[]} lines
88
+ */
89
+ function renderGroup(group, maxWidth) {
90
+ const lines = [];
91
+ const groupName = t(group.groupKey);
92
+ lines.push(' ' + chalk.bold.white(groupName));
93
+ lines.push('');
94
+ for (const cmd of group.commands) {
95
+ const padding = ' '.repeat(maxWidth - cmd.display.length + 2);
96
+ const namePart = chalk.hex(TSOFT_COLORS.primary)('tsoft ' + cmd.display);
97
+ const descPart = chalk.dim(t(cmd.descKey));
98
+ lines.push(' ' + namePart + padding + descPart);
99
+ }
100
+ return lines;
101
+ }
102
+
103
+ /**
104
+ * Read contextual status data (logged-in user, store, active theme).
105
+ * Returns all nulls gracefully on any error.
106
+ * @returns {Promise<{store: string|null, tokens: object|null, activeTheme: string|null}>}
107
+ */
108
+ async function loadStatusData() {
109
+ try {
110
+ const store = await getActiveStore();
111
+ const tokens = store ? await loadTokens(store) : null;
112
+ const activeTheme = await getActiveTheme();
113
+ return { store, tokens, activeTheme };
114
+ } catch {
115
+ return { store: null, tokens: null, activeTheme: null };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Build status block lines from context data.
121
+ * @param {{store: string|null, tokens: object|null, activeTheme: string|null}} status
122
+ * @returns {string[]} lines to print
123
+ */
124
+ function buildStatusLines(status) {
125
+ const { store, tokens, activeTheme } = status;
126
+ const lines = [];
127
+ const orangeBullet = chalk.hex(TSOFT_COLORS.primary)('●');
128
+
129
+ // User line
130
+ if (tokens?.user?.email) {
131
+ const userText = t('help.status.logged_in', { email: tokens.user.email, store: store || '' });
132
+ lines.push(' ' + orangeBullet + ' ' + userText);
133
+ } else {
134
+ lines.push(' ' + chalk.dim('○ ' + t('help.status.not_logged_in')));
135
+ }
136
+
137
+ // Organization line — only shown when available
138
+ const orgName = tokens?.user?.organizationName;
139
+ if (orgName) {
140
+ lines.push(' ' + orangeBullet + ' ' + orgName);
141
+ }
142
+
143
+ // Theme line
144
+ if (activeTheme) {
145
+ const themeText = t('help.status.active_theme', { theme: activeTheme });
146
+ lines.push(' ' + orangeBullet + ' ' + themeText);
147
+ } else {
148
+ lines.push(' ' + chalk.dim('○ ' + t('help.status.no_theme')));
149
+ }
150
+
151
+ return lines;
152
+ }
153
+
154
+ /**
155
+ * Render the branded help to stdout.
156
+ * Wraps all human-readable output in humanOut().
157
+ * In JSON mode, outputs structured data instead.
158
+ *
159
+ * @param {import('commander').Command} _program — kept for future use / extensibility
160
+ */
161
+ export async function renderBrandedHelp(_program) {
162
+ const status = await loadStatusData();
163
+
164
+ if (isJsonMode()) {
165
+ const groups = COMMAND_GROUPS.map(g => ({
166
+ group: t(g.groupKey),
167
+ commands: g.commands.map(c => ({
168
+ command: 'tsoft ' + c.display,
169
+ description: t(c.descKey)
170
+ }))
171
+ }));
172
+
173
+ const commands = groups.flatMap(g => g.commands);
174
+ jsonOut({
175
+ user: status.tokens?.user || null,
176
+ store: status.store,
177
+ activeTheme: status.activeTheme,
178
+ commands,
179
+ groups,
180
+ });
181
+ return;
182
+ }
183
+
184
+ humanOut(() => {
185
+ const version = getVersion();
186
+ const maxWidth = computeMaxNameWidth();
187
+
188
+ // Header — ASCII art with orange gradient
189
+ console.log('');
190
+ console.log(createTSoftGradient(TSOFT_ASCII_ART));
191
+ console.log('');
192
+
193
+ // Tagline + version
194
+ const tagline = chalk.dim(t('help.tagline'));
195
+ const versionBadge = chalk.dim('v' + version);
196
+ console.log(' ' + tagline + ' ' + versionBadge);
197
+
198
+ // Contextual status block
199
+ console.log('');
200
+ const statusLines = buildStatusLines(status);
201
+ for (const line of statusLines) {
202
+ console.log(line);
203
+ }
204
+ console.log('');
205
+
206
+ // Command groups
207
+ for (const group of COMMAND_GROUPS) {
208
+ const groupLines = renderGroup(group, maxWidth);
209
+ for (const line of groupLines) {
210
+ console.log(line);
211
+ }
212
+ console.log('');
213
+ }
214
+
215
+ // Footer hint
216
+ console.log(' ' + chalk.dim(t('help.footer')));
217
+ console.log('');
218
+ });
219
+ }