tsoft-cli 3.7.4 → 3.9.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/CHANGELOG.md CHANGED
@@ -5,6 +5,40 @@ All notable changes to T-Soft CLI will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.9.0] - 2026-05-20
9
+
10
+ ### Added
11
+ - Interactive `tsoft theme section` menu: running `theme section` with no sub-command now opens a four-way picker (add a ready-made section, create an empty section, fork from an existing section, list sections).
12
+ - `tsoft theme section create` — scaffolds an empty section file inside an existing block folder (`section/{block}/{name}.twig`) in the active theme via the new `POST /theme/{uuid}/sections` endpoint, then refreshes local files.
13
+ - `tsoft theme section fork` — forks an existing section into the active theme under a new name.
14
+ - Searchable pickers: block and section selection prompts (`add`, `create`, `fork`) now filter as you type, matching both the display name and the folder slug.
15
+ - `theme dev` now prints a distinct `section updated` line when a file under `section/` is saved, so section changes are visible during live development.
16
+ - `theme dev` interactive `S` keypress opens the section menu without leaving the watch session; the watcher and keypress listener are paused while the menu is open and the local theme is re-synced afterwards.
17
+
18
+ ### Removed
19
+ - Dead `ApiClient.syncTheme()` method — the server now resyncs the section registry automatically on file save, so the explicit sync call is no longer needed.
20
+
21
+ ### Backwards compatibility
22
+ - `theme section add` and `theme section list` remain available as direct sub-commands for scripts and CI.
23
+
24
+ ## [3.8.0] - 2026-04-28
25
+
26
+ ### Added
27
+ - Vitest unit tests for `ApiClient` 401 handling: successful refresh-and-retry path, relogin error after a second 401, relogin error when the refresh token is invalid, relogin error when the refresh call itself fails, and POST request retry parity (`tests/api-client.test.js`).
28
+ - Integration tests for the OAuth callback HTTP server (`tests/server.test.js`): success path returns the expected `{ code, state, store }` payload, OAuth `error` query rejects, missing required params reject, unknown paths return 404 without resolving the promise, and an already-bound port surfaces a clear startup error.
29
+ - Tests for `loginCommand` edge cases (`tests/login.test.js`): refusal in JSON mode, state mismatch (CSRF guard), browser open failure, and token exchange failure — all of which exit with code 1.
30
+ - Tests for `themeInitCommand` early-return paths (`tests/theme-init.test.js`): unknown slug under JSON and human modes, empty themes list, and missing-slug + JSON combination.
31
+ - Tests for `themePushCommand` JSON-mode contract (`tests/theme-push.test.js`): no active theme exits 1, success payload shape (`version`, `bump_type`, `action`, `changes`, `partner_panel_url`), partner URL fallback when `/organization/` segment is missing, `no_op` payload on `no_changes`, and error payload on push failure.
32
+ - Tests for `logoutCommand` safety guarantee (`tests/logout.test.js`): quiet exit when no tokens are stored; **local config is cleared even if the revoke call fails**; happy-path revoke + clear ordering.
33
+ - Tests for `orgSwitchCommand` early-return paths (`tests/org-switch.test.js`): JSON mode refusal (exit 1), no active store, no stored tokens, empty organisation list — none of which should call the select-org endpoint.
34
+ - Tests for `whoamiCommand` (`tests/whoami.test.js`): exit 1 in JSON mode when no tokens, quiet return in human mode, and the structured success payload shape (`user`, `store`, `access_token_valid`, `refresh_token_valid`, `updated_at`).
35
+ - Tests for `themeSectionListCommand` and `themeSectionAddCommand` (`tests/theme-section.test.js`): no active theme warning, empty blocks list, blocks with no available default sections, and graceful cancellation when the user declines the confirmation prompt (no `addSection` call).
36
+ - Tests for `themeDevCommand` control-flow guards (`tests/theme-dev.test.js`): empty themes list during the interactive picker, an unresolvable explicit slug exits 1, missing local theme directory shows an error and returns, and a theme locked by another developer respects user's decline of the override prompt (no `acquire-lock` call).
37
+
38
+ ### Internal
39
+ - `vitest` added as `devDependency` (was previously referenced by the `test` script but not installed by `npm install`).
40
+ - Test suite grew from 64 specs (7 files) to **102 specs (16 files)**; coverage now spans `api-client`, `server`, and every command in `src/commands/` (`loginCommand`, `logoutCommand`, `whoamiCommand`, `orgSwitchCommand`, `themeInitCommand`, `themeDevCommand`, `themePushCommand`, `themeSection*`) in addition to the existing helper-module suites.
41
+
8
42
  ## [3.7.4] - 2026-04-20
9
43
 
10
44
  ### Fixed
package/locales/en.json CHANGED
@@ -266,6 +266,29 @@
266
266
  "section.add.download_error": "⚠️ Error while updating theme files:",
267
267
  "section.add.manual_tip": "💡 Try downloading the theme manually:",
268
268
  "section.add.manual_command": "tsoft theme pull",
269
+ "section.menu.title": "🧩 Section Operations",
270
+ "section.menu.prompt": "What would you like to do?",
271
+ "section.menu.add": "Add a ready-made section",
272
+ "section.menu.create": "Create an empty section",
273
+ "section.menu.fork": "Fork from a section",
274
+ "section.menu.list": "List sections",
275
+ "section.create.title": "✨ Create Empty Section",
276
+ "section.create.no_blocks": "No block available to add a section to.",
277
+ "section.create.block_prompt": "Which block should the section be added to?",
278
+ "section.create.name_prompt": "Section name (.twig is added automatically):",
279
+ "section.create.name_required": "Section name cannot be empty.",
280
+ "section.create.name_invalid": "Section name may only contain lowercase letters, digits and hyphens.",
281
+ "section.create.name_no_extension": "Do not type an extension — \".twig\" is added automatically.",
282
+ "section.create.confirm_prompt": "File \"{{file}}\" will be created in theme \"{{theme}}\".\n\nDo you want to continue?",
283
+ "section.create.created": "\"{{file}}\" created.",
284
+ "section.create.failed": "Failed to create section: {{error}}",
285
+ "section.fork.title": "🌱 Fork From Section",
286
+ "section.fork.no_sources": "No source section found to fork from.",
287
+ "section.fork.source_prompt": "Which section would you like to fork from?",
288
+ "section.fork.name_prompt": "New section name (.twig is added automatically):",
289
+ "section.fork.confirm_prompt": "\"{{file}}\" will be forked from \"{{source}}\" and added to theme \"{{theme}}\".\n\nDo you want to continue?",
290
+ "section.fork.created": "\"{{file}}\" created.",
291
+ "section.fork.next_step": "Open \"{{file}}\" to edit its content.",
269
292
  "org.switch.json_error.message": "This command requires interactive mode.",
270
293
  "org.switch.json_error.hint": "org switch is not supported in JSON mode.",
271
294
  "org.switch.no_store": "No active store found. Please run \"tsoft login\" first.",
@@ -307,5 +330,9 @@
307
330
  "dev.status_watching": "Watching",
308
331
  "dev.status_preview": "preview",
309
332
  "dev.status_exit": "exit",
310
- "dev.preview_open_failed": "Couldn't open browser: {{error}}"
333
+ "dev.preview_open_failed": "Couldn't open browser: {{error}}",
334
+ "dev.section_synced": "🧩 section updated: {{folder}}",
335
+ "dev.status_section": "section",
336
+ "dev.section_menu_opening": "Opening section menu...",
337
+ "dev.section_menu_resumed": "Resuming watch."
311
338
  }
package/locales/tr.json CHANGED
@@ -266,6 +266,29 @@
266
266
  "section.add.download_error": "⚠️ Tema dosyaları güncellenirken hata oluştu:",
267
267
  "section.add.manual_tip": "💡 Manuel olarak temayı tekrar indirmeyi deneyin:",
268
268
  "section.add.manual_command": "tsoft theme pull",
269
+ "section.menu.title": "🧩 Section İşlemleri",
270
+ "section.menu.prompt": "Ne yapmak istiyorsunuz?",
271
+ "section.menu.add": "Hazır section ekle",
272
+ "section.menu.create": "Boş section oluştur",
273
+ "section.menu.fork": "Section'dan türet",
274
+ "section.menu.list": "Section'ları listele",
275
+ "section.create.title": "✨ Boş Section Oluştur",
276
+ "section.create.no_blocks": "Section eklenebilecek block bulunamadı.",
277
+ "section.create.block_prompt": "Hangi block içine eklemek istiyorsunuz?",
278
+ "section.create.name_prompt": "Section adı (.twig otomatik eklenir):",
279
+ "section.create.name_required": "Section adı boş olamaz.",
280
+ "section.create.name_invalid": "Section adı yalnızca küçük harf, rakam ve tire içerebilir.",
281
+ "section.create.name_no_extension": "Uzantı yazmayın — \".twig\" otomatik eklenir.",
282
+ "section.create.confirm_prompt": "\"{{file}}\" dosyası \"{{theme}}\" temasında oluşturulacak.\n\nDevam etmek istiyor musunuz?",
283
+ "section.create.created": "\"{{file}}\" oluşturuldu.",
284
+ "section.create.failed": "Section oluşturulamadı: {{error}}",
285
+ "section.fork.title": "🌱 Section'dan Türet",
286
+ "section.fork.no_sources": "Türetilecek kaynak section bulunamadı.",
287
+ "section.fork.source_prompt": "Hangi section'dan türetmek istiyorsunuz?",
288
+ "section.fork.name_prompt": "Yeni section adı (.twig otomatik eklenir):",
289
+ "section.fork.confirm_prompt": "\"{{source}}\" section'ından \"{{file}}\" türetilecek ve \"{{theme}}\" temasına eklenecek.\n\nDevam etmek istiyor musunuz?",
290
+ "section.fork.created": "\"{{file}}\" oluşturuldu.",
291
+ "section.fork.next_step": "İçeriği düzenlemek için \"{{file}}\" dosyasını açın.",
269
292
  "org.switch.json_error.message": "Bu komut interaktif mod gerektiriyor.",
270
293
  "org.switch.json_error.hint": "JSON modunda org switch desteklenmiyor.",
271
294
  "org.switch.no_store": "Aktif mağaza bulunamadı. Lütfen önce \"tsoft login\" komutunu çalıştırın.",
@@ -307,5 +330,9 @@
307
330
  "dev.status_watching": "İzleniyor",
308
331
  "dev.status_preview": "önizleme",
309
332
  "dev.status_exit": "çıkış",
310
- "dev.preview_open_failed": "Tarayıcı açılamadı: {{error}}"
333
+ "dev.preview_open_failed": "Tarayıcı açılamadı: {{error}}",
334
+ "dev.section_synced": "🧩 section güncellendi: {{folder}}",
335
+ "dev.status_section": "section",
336
+ "dev.section_menu_opening": "Section menüsü açılıyor...",
337
+ "dev.section_menu_resumed": "İzlemeye devam ediliyor."
311
338
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsoft-cli",
3
- "version": "3.7.4",
3
+ "version": "3.9.0",
4
4
  "description": "Command-line tool for tsoft360 theme development and management",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -52,5 +52,8 @@
52
52
  "repository": {
53
53
  "type": "git",
54
54
  "url": "git+https://github.com/tekrom/tsoft-cli.git"
55
+ },
56
+ "devDependencies": {
57
+ "vitest": "^4.1.5"
55
58
  }
56
59
  }
package/src/api-client.js CHANGED
@@ -131,6 +131,17 @@ export class ApiClient {
131
131
  return await this.post(`/theme/${themeUuid}/sections/${blockId}`, payload);
132
132
  }
133
133
 
134
+ /**
135
+ * Var olan bir block içinde boş section dosyası oluşturur
136
+ * @param {string} themeUuid - Tema UUID/folder
137
+ * @param {string} blockFolder - Hedef block klasörü (ör. slider, banner)
138
+ * @param {string} name - Section dosya adı (slug, .twig'siz)
139
+ * @returns {Promise<Object>} Oluşturulan section bilgisi
140
+ */
141
+ async createSection(themeUuid, blockFolder, name) {
142
+ return await this.post(`/theme/${themeUuid}/sections`, { block_folder: blockFolder, name });
143
+ }
144
+
134
145
  /**
135
146
  * Tema dev status'ünü getir
136
147
  * @param {string} themeUuid - Tema UUID/folder
@@ -161,15 +172,6 @@ export class ApiClient {
161
172
  return await this.post(`/theme/${themeUuid}/publish`, data);
162
173
  }
163
174
 
164
- /**
165
- * Temayı sync eder (build işlemi tetiklenir)
166
- * @param {string} themeUuid - Tema UUID/folder
167
- * @returns {Promise<Object>} Sync sonucu
168
- */
169
- async syncTheme(themeUuid) {
170
- return await this.post(`/theme/${themeUuid}/sync`);
171
- }
172
-
173
175
  /**
174
176
  * Temayı incelemeye gönder (admin onayı için)
175
177
  * @param {string} themeUuid - Tema UUID/folder
@@ -8,8 +8,26 @@ import {createApiClient} from '../api-client.js';
8
8
  import open from 'open';
9
9
  import {normalizeStoreDomain, slugify, setActiveTheme, getActiveTheme, getActiveStore} from '../storage.js';
10
10
  import {brandedConfirm, brandedSelect, showWarning, showError, showInfo} from '../ui/prompt-wrapper.js';
11
+ import {themeSectionMenuCommand} from './theme-section.js';
11
12
  import { t } from '../i18n.js';
12
13
 
14
+ function resolveSectionFolder(relativePath) {
15
+ if (!relativePath) {
16
+ return null;
17
+ }
18
+
19
+ const segments = relativePath.split(/[\\/]+/).filter(Boolean);
20
+ const sectionIndex = segments.indexOf('section');
21
+
22
+ if (sectionIndex === -1 || !segments[sectionIndex + 1]) {
23
+ return null;
24
+ }
25
+
26
+ const folder = segments[sectionIndex + 1];
27
+
28
+ return folder.includes('.') ? null : folder;
29
+ }
30
+
13
31
  async function fetchAllThemes() {
14
32
  const apiClient = await createApiClient();
15
33
  const all = [];
@@ -117,6 +135,11 @@ async function handleFileChange(themeUuid, themePath, filePath) {
117
135
  const duration = Date.now() - startTime;
118
136
  console.log(chalk.green(`[${new Date().toLocaleTimeString()}] ` + t('dev.file_saved', { path: relativePath, duration })));
119
137
 
138
+ const sectionFolder = resolveSectionFolder(relativePath);
139
+ if (sectionFolder) {
140
+ console.log(chalk.cyan(' ' + t('dev.section_synced', { folder: sectionFolder })));
141
+ }
142
+
120
143
  try {
121
144
  await apiClient.post(`/theme/${themeUuid}/mark-synced`, {});
122
145
  } catch (ackError) {
@@ -185,6 +208,15 @@ async function handleFileDelete(themeUuid, themePath, filePath) {
185
208
  }
186
209
  }
187
210
 
211
+ function printWatchStatus(previewUrl) {
212
+ const sep = chalk.dim(' · ');
213
+ const parts = [chalk.cyan('👀 ' + t('dev.status_watching'))];
214
+ if (previewUrl) parts.push(chalk.bold.green('P') + chalk.gray(' ' + t('dev.status_preview')));
215
+ parts.push(chalk.bold.green('S') + chalk.gray(' ' + t('dev.status_section')));
216
+ parts.push(chalk.gray('Ctrl+C ' + t('dev.status_exit')));
217
+ console.log('\n' + parts.join(sep) + '\n');
218
+ }
219
+
188
220
  async function startFileWatcher(themeUuid, themePath) {
189
221
  // Preview URL — sessizce cek, baglanamazsa status line'da P ipucunu gosterme
190
222
  let previewUrl = null;
@@ -196,15 +228,24 @@ async function startFileWatcher(themeUuid, themePath) {
196
228
  // sessiz — preview opsiyonel
197
229
  }
198
230
 
199
- // Tek satirlik compact status — watching + P preview (varsa) + Ctrl+C exit
200
- const sep = chalk.dim(' · ');
201
- const parts = [chalk.cyan('👀 ' + t('dev.status_watching'))];
202
- if (previewUrl) parts.push(chalk.bold.green('P') + chalk.gray(' ' + t('dev.status_preview')));
203
- parts.push(chalk.gray('Ctrl+C ' + t('dev.status_exit')));
204
- console.log('\n' + parts.join(sep) + '\n');
231
+ printWatchStatus(previewUrl);
232
+
233
+ // Menu acikken hem watcher event'leri hem keypress dinleyici askiya alinir
234
+ // (inquirer ile stdin cakismasini onler).
235
+ let paused = false;
205
236
 
206
- // Klavye dinleyici 'P' tarayıcida preview URL'ini sessizce acar, Ctrl+C cikis
207
- if (previewUrl && process.stdin.isTTY) {
237
+ const setRawMode = (enabled) => {
238
+ if (process.stdin.isTTY) {
239
+ try {
240
+ process.stdin.setRawMode(enabled);
241
+ } catch {
242
+ // raw mode bazi terminallerde calismaz — sessizce gec
243
+ }
244
+ }
245
+ };
246
+
247
+ // Klavye dinleyici — 'P' tarayıcida preview URL'ini sessizce acar, 'S' section menusu, Ctrl+C cikis
248
+ if (process.stdin.isTTY) {
208
249
  try {
209
250
  process.stdin.setRawMode(true);
210
251
  process.stdin.resume();
@@ -213,37 +254,83 @@ async function startFileWatcher(themeUuid, themePath) {
213
254
  if (key === '\u0003') { // Ctrl+C
214
255
  process.exit(0);
215
256
  }
216
- if (key === 'p' || key === 'P') {
257
+ if (paused) {
258
+ return;
259
+ }
260
+ if (previewUrl && (key === 'p' || key === 'P')) {
217
261
  try {
218
262
  await open(previewUrl);
219
263
  } catch (e) {
220
264
  console.log(chalk.red(' ' + t('dev.preview_open_failed', { error: e.message })));
221
265
  }
222
266
  }
267
+ if (key === 's' || key === 'S') {
268
+ await openSectionMenu();
269
+ }
223
270
  });
224
271
  } catch {
225
272
  // raw mode bazi terminallerde calismaz — sessizce gec
226
273
  }
227
274
  }
228
275
 
229
- const watcher = chokidar.watch(themePath, {
276
+ const watcherOptions = {
230
277
  ignored: /(^|[\/\\])\../, persistent: true, ignoreInitial: true, awaitWriteFinish: {
231
278
  stabilityThreshold: 300, pollInterval: 100
232
279
  }
233
- });
280
+ };
234
281
 
235
- watcher
236
- .on('change', async (filePath) => {
237
- await handleFileChange(themeUuid, themePath, filePath);
238
- })
239
- .on('add', async (filePath) => {
240
- await handleFileChange(themeUuid, themePath, filePath);
241
- })
242
- .on('unlink', async (filePath) => {
243
- await handleFileDelete(themeUuid, themePath, filePath);
244
- });
282
+ const attachWatcherEvents = (instance) => {
283
+ instance
284
+ .on('change', async (filePath) => {
285
+ if (paused) return;
286
+ await handleFileChange(themeUuid, themePath, filePath);
287
+ })
288
+ .on('add', async (filePath) => {
289
+ if (paused) return;
290
+ await handleFileChange(themeUuid, themePath, filePath);
291
+ })
292
+ .on('unlink', async (filePath) => {
293
+ if (paused) return;
294
+ await handleFileDelete(themeUuid, themePath, filePath);
295
+ });
296
+ };
297
+
298
+ let watcher = chokidar.watch(themePath, watcherOptions);
299
+ attachWatcherEvents(watcher);
245
300
 
246
- return watcher;
301
+ async function openSectionMenu() {
302
+ paused = true;
303
+ setRawMode(false);
304
+
305
+ // Menu dallari (create/fork/add) yeni section dosyalarini zip ile
306
+ // indirip extract eder; bu extract eski watcher'da gecikmeli 'change'
307
+ // olaylari uretir. Watcher'i tamamen kapatip menu+extract bittikten
308
+ // sonra yeniden kuruyoruz — ignoreInitial sayesinde yeni watcher
309
+ // extract edilmis dosyalari gormez, sadece sonraki gercek
310
+ // degisiklikleri yakalar.
311
+ await watcher.close();
312
+
313
+ console.log(chalk.cyan('\n' + t('dev.section_menu_opening') + '\n'));
314
+
315
+ try {
316
+ await themeSectionMenuCommand();
317
+ } catch (error) {
318
+ console.log(chalk.red(' ' + error.message));
319
+ }
320
+
321
+ watcher = chokidar.watch(themePath, watcherOptions);
322
+ attachWatcherEvents(watcher);
323
+
324
+ setRawMode(true);
325
+ process.stdin.resume();
326
+ console.log(chalk.green('\n' + t('dev.section_menu_resumed')));
327
+ printWatchStatus(previewUrl);
328
+ paused = false;
329
+ }
330
+
331
+ return {
332
+ close: () => watcher.close(),
333
+ };
247
334
  }
248
335
 
249
336
  function startKeepAlive(themeUuid) {
@@ -6,9 +6,25 @@ import path from 'path';
6
6
  import AdmZip from 'adm-zip';
7
7
  import {createApiClient} from '../api-client.js';
8
8
  import {getActiveTheme, getThemeUuidBySlug, normalizeStoreDomain, getActiveStore} from '../storage.js';
9
- import {brandedConfirm, brandedSelect, showWarning} from '../ui/prompt-wrapper.js';
9
+ import {brandedConfirm, brandedSelect, brandedSearch, brandedInput, showWarning, showSuccess, showInfo} from '../ui/prompt-wrapper.js';
10
10
  import { t } from '../i18n.js';
11
11
 
12
+ const SECTION_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
13
+
14
+ function validateSectionName(value) {
15
+ const trimmed = (value || '').trim();
16
+ if (!trimmed) {
17
+ return t('section.create.name_required');
18
+ }
19
+ if (/\.[a-z0-9]+$/i.test(trimmed)) {
20
+ return t('section.create.name_no_extension');
21
+ }
22
+ if (!SECTION_NAME_PATTERN.test(trimmed)) {
23
+ return t('section.create.name_invalid');
24
+ }
25
+ return true;
26
+ }
27
+
12
28
  export async function themeSectionListCommand() {
13
29
  try {
14
30
  // Aktif temayı al
@@ -109,12 +125,12 @@ export async function themeSectionAddCommand() {
109
125
 
110
126
  // Block seçimi
111
127
  const blockChoices = availableBlocks.map(block => ({
112
- name: `${block.name} ${chalk.gray(`(${block.default_sections.length} section)`)}`,
128
+ name: `${block.name} ${chalk.gray(`(${block.folder}, ${block.default_sections.length} section)`)}`,
113
129
  value: block,
114
- description: `${t('section.list.folder_label')} ${block.folder}`
130
+ keywords: [block.name, block.folder],
115
131
  }));
116
132
 
117
- const selectedBlock = await brandedSelect({
133
+ const selectedBlock = await brandedSearch({
118
134
  message: t('section.add.block_select_prompt'),
119
135
  choices: blockChoices,
120
136
  }, t('section.add.block_select_title'));
@@ -315,3 +331,214 @@ export async function themeSectionAddCommand() {
315
331
  process.exit(1);
316
332
  }
317
333
  }
334
+
335
+ async function downloadThemeFiles(apiClient, themeUuid, themePath) {
336
+ const downloadResponse = await apiClient.download(`/theme/${themeUuid}/download`, {});
337
+
338
+ const zipPath = path.join(themePath, 'theme-update.zip');
339
+ const writer = createWriteStream(zipPath);
340
+
341
+ downloadResponse.data.pipe(writer);
342
+
343
+ await new Promise((resolve, reject) => {
344
+ writer.on('finish', resolve);
345
+ writer.on('error', reject);
346
+ });
347
+
348
+ const zip = new AdmZip(zipPath);
349
+ zip.extractAllTo(themePath, true);
350
+
351
+ await fs.unlink(zipPath);
352
+ }
353
+
354
+ export async function themeSectionCreateCommand() {
355
+ try {
356
+ const activeTheme = await getActiveTheme();
357
+ if (!activeTheme) {
358
+ showWarning(t('section.add.no_active_theme'));
359
+ return;
360
+ }
361
+
362
+ const themeInfo = await getThemeUuidBySlug(activeTheme);
363
+
364
+ const apiClient = await createApiClient();
365
+ const response = await apiClient.getSections(themeInfo.uuid);
366
+ const blocks = response.data || [];
367
+
368
+ if (blocks.length === 0) {
369
+ showWarning(t('section.create.no_blocks'));
370
+ return;
371
+ }
372
+
373
+ const selectedBlock = await brandedSearch({
374
+ message: t('section.create.block_prompt'),
375
+ choices: blocks.map((block) => ({
376
+ name: `${block.name} ${chalk.gray(`(${block.folder})`)}`,
377
+ value: block,
378
+ keywords: [block.name, block.folder],
379
+ })),
380
+ }, t('section.create.title'));
381
+
382
+ const name = await brandedInput({
383
+ message: t('section.create.name_prompt'),
384
+ validate: validateSectionName,
385
+ }, t('section.create.title'));
386
+
387
+ const sectionName = name.trim();
388
+ const sectionFile = `${sectionName}.twig`;
389
+ const targetPath = `section/${selectedBlock.folder}/${sectionFile}`;
390
+
391
+ const confirm = await brandedConfirm({
392
+ message: t('section.create.confirm_prompt', { file: targetPath, theme: themeInfo.name }),
393
+ default: true,
394
+ }, t('section.add.confirm_title'));
395
+
396
+ if (!confirm) {
397
+ showWarning(t('section.add.cancelled'));
398
+ return;
399
+ }
400
+
401
+ try {
402
+ await apiClient.createSection(themeInfo.uuid, selectedBlock.folder, sectionName);
403
+ } catch (error) {
404
+ if (error.message.startsWith('VALIDATION_ERROR:')) {
405
+ showWarning(t('section.create.failed', { error: error.message.replace('VALIDATION_ERROR:', '') }));
406
+ return;
407
+ }
408
+ throw error;
409
+ }
410
+
411
+ showSuccess(t('section.create.created', { file: targetPath }));
412
+
413
+ const store = await getActiveStore();
414
+ const storeSlug = normalizeStoreDomain(store);
415
+ const themePath = path.join(process.cwd(), storeSlug, activeTheme);
416
+
417
+ try {
418
+ await downloadThemeFiles(apiClient, themeInfo.uuid, themePath);
419
+ console.log(chalk.green(t('section.add.files_updated')));
420
+ console.log(chalk.gray(' ' + t('section.add.location', { path: `${storeSlug}/${activeTheme}/` }) + '\n'));
421
+ } catch (downloadError) {
422
+ console.error(chalk.red(t('section.add.download_error')), downloadError.message);
423
+ console.log(chalk.yellow('\n' + t('section.add.manual_tip')));
424
+ console.log(chalk.white(` ${t('section.add.manual_command')}\n`));
425
+ }
426
+ } catch (error) {
427
+ console.error(chalk.red('\n❌ Hata: ') + error.message);
428
+ process.exit(1);
429
+ }
430
+ }
431
+
432
+ export async function themeSectionForkCommand() {
433
+ try {
434
+ const activeTheme = await getActiveTheme();
435
+ if (!activeTheme) {
436
+ showWarning(t('section.add.no_active_theme'));
437
+ return;
438
+ }
439
+
440
+ const themeInfo = await getThemeUuidBySlug(activeTheme);
441
+
442
+ const apiClient = await createApiClient();
443
+ const response = await apiClient.getSections(themeInfo.uuid);
444
+ const blocks = response.data || [];
445
+
446
+ const sourceSections = [];
447
+ blocks.forEach((block) => {
448
+ (block.default_sections || []).forEach((section) => {
449
+ sourceSections.push({ block, section });
450
+ });
451
+ });
452
+
453
+ if (sourceSections.length === 0) {
454
+ showWarning(t('section.fork.no_sources'));
455
+ return;
456
+ }
457
+
458
+ const selected = await brandedSearch({
459
+ message: t('section.fork.source_prompt'),
460
+ choices: sourceSections.map(({ block, section }) => ({
461
+ name: `${section.name} ${chalk.gray(`(${block.name}, v${section.version})`)}`,
462
+ value: { block, section },
463
+ keywords: [section.name, block.name, block.folder],
464
+ })),
465
+ }, t('section.fork.title'));
466
+
467
+ const newName = await brandedInput({
468
+ message: t('section.fork.name_prompt'),
469
+ validate: validateSectionName,
470
+ }, t('section.fork.title'));
471
+
472
+ const sectionName = newName.trim();
473
+ const sectionFile = `${sectionName}.twig`;
474
+ const blockFolder = selected.block.folder;
475
+ const targetPath = `section/${blockFolder}/${sectionFile}`;
476
+
477
+ const confirm = await brandedConfirm({
478
+ message: t('section.fork.confirm_prompt', {
479
+ source: selected.section.name,
480
+ file: targetPath,
481
+ theme: themeInfo.name,
482
+ }),
483
+ default: true,
484
+ }, t('section.add.confirm_title'));
485
+
486
+ if (!confirm) {
487
+ showWarning(t('section.add.cancelled'));
488
+ return;
489
+ }
490
+
491
+ try {
492
+ await apiClient.createSection(themeInfo.uuid, blockFolder, sectionName);
493
+ } catch (error) {
494
+ if (error.message.startsWith('VALIDATION_ERROR:')) {
495
+ showWarning(t('section.create.failed', { error: error.message.replace('VALIDATION_ERROR:', '') }));
496
+ return;
497
+ }
498
+ throw error;
499
+ }
500
+
501
+ showSuccess(t('section.fork.created', { file: targetPath }));
502
+ showInfo(t('section.fork.next_step', { file: targetPath }));
503
+
504
+ const store = await getActiveStore();
505
+ const storeSlug = normalizeStoreDomain(store);
506
+ const themePath = path.join(process.cwd(), storeSlug, activeTheme);
507
+
508
+ try {
509
+ await downloadThemeFiles(apiClient, themeInfo.uuid, themePath);
510
+ console.log(chalk.green(t('section.add.files_updated')));
511
+ console.log(chalk.gray(' ' + t('section.add.location', { path: `${storeSlug}/${activeTheme}/` }) + '\n'));
512
+ } catch (downloadError) {
513
+ console.error(chalk.red(t('section.add.download_error')), downloadError.message);
514
+ console.log(chalk.yellow('\n' + t('section.add.manual_tip')));
515
+ console.log(chalk.white(` ${t('section.add.manual_command')}\n`));
516
+ }
517
+ } catch (error) {
518
+ console.error(chalk.red('\n❌ Hata: ') + error.message);
519
+ process.exit(1);
520
+ }
521
+ }
522
+
523
+ export async function themeSectionMenuCommand() {
524
+ const action = await brandedSelect({
525
+ message: t('section.menu.prompt'),
526
+ choices: [
527
+ { name: t('section.menu.add'), value: 'add' },
528
+ { name: t('section.menu.create'), value: 'create' },
529
+ { name: t('section.menu.fork'), value: 'fork' },
530
+ { name: t('section.menu.list'), value: 'list' },
531
+ ],
532
+ }, t('section.menu.title'));
533
+
534
+ switch (action) {
535
+ case 'add':
536
+ return themeSectionAddCommand();
537
+ case 'create':
538
+ return themeSectionCreateCommand();
539
+ case 'fork':
540
+ return themeSectionForkCommand();
541
+ case 'list':
542
+ return themeSectionListCommand();
543
+ }
544
+ }
package/src/index.js CHANGED
@@ -11,7 +11,7 @@ import { logoutCommand } from './commands/logout.js';
11
11
  import { whoamiCommand } from './commands/whoami.js';
12
12
  import { themeListCommand, themeCreateCommand, themePullCommand, themeUseCommand } from './commands/theme.js';
13
13
  import { themeDevCommand } from './commands/theme-dev.js';
14
- import { themeSectionListCommand, themeSectionAddCommand } from './commands/theme-section.js';
14
+ import { themeSectionListCommand, themeSectionAddCommand, themeSectionCreateCommand, themeSectionForkCommand, themeSectionMenuCommand } from './commands/theme-section.js';
15
15
  import { themePublishCommand } from './commands/theme-publish.js';
16
16
  import { themePushCommand } from './commands/theme-push.js';
17
17
  import { themeSubmitCommand } from './commands/theme-submit.js';
@@ -167,7 +167,10 @@ org
167
167
  // section commands (under theme)
168
168
  const section = theme
169
169
  .command('section')
170
- .description('Section management commands');
170
+ .description('Section management commands')
171
+ .action(async () => {
172
+ await themeSectionMenuCommand();
173
+ });
171
174
 
172
175
  section
173
176
  .command('list')
@@ -183,5 +186,19 @@ section
183
186
  await themeSectionAddCommand();
184
187
  });
185
188
 
189
+ section
190
+ .command('create')
191
+ .description('Create an empty section in the active theme')
192
+ .action(async () => {
193
+ await themeSectionCreateCommand();
194
+ });
195
+
196
+ section
197
+ .command('fork')
198
+ .description('Fork an existing section into the active theme')
199
+ .action(async () => {
200
+ await themeSectionForkCommand();
201
+ });
202
+
186
203
  // Parse arguments
187
204
  program.parse(process.argv);
@@ -1,7 +1,7 @@
1
1
  import boxen from 'boxen';
2
2
  import gradient from 'gradient-string';
3
3
  import chalk from 'chalk';
4
- import {input as inquirerInput, confirm as inquirerConfirm, select as inquirerSelect} from '@inquirer/prompts';
4
+ import {input as inquirerInput, confirm as inquirerConfirm, select as inquirerSelect, search as inquirerSearch} from '@inquirer/prompts';
5
5
  import { t } from '../i18n.js';
6
6
 
7
7
  /**
@@ -108,6 +108,40 @@ export async function brandedSelect(options, boxTitle = t('ui.label.select')) {
108
108
  });
109
109
  }
110
110
 
111
+ /**
112
+ * Aranabilir select prompt — kullanıcı yazdıkça choices filtrelenir.
113
+ * @param {object} options - { message, choices, filter? }
114
+ * choices: [{ name, value, keywords? }] — keywords ek arama terimleri
115
+ * filter: opsiyonel özel filtre fn(choice, term) => boolean
116
+ * @param {string} boxTitle - Box başlığı
117
+ */
118
+ export async function brandedSearch(options, boxTitle = t('ui.label.select')) {
119
+ const {message, choices, filter} = options;
120
+
121
+ if (message) {
122
+ showBrandedBox(message, boxTitle);
123
+ }
124
+
125
+ const defaultFilter = (choice, term) => {
126
+ const haystack = [choice.name, choice.value, ...(choice.keywords || [])]
127
+ .filter(Boolean)
128
+ .join(' ')
129
+ .toLocaleLowerCase('tr');
130
+ return haystack.includes(term.toLocaleLowerCase('tr'));
131
+ };
132
+ const matches = filter || defaultFilter;
133
+
134
+ return await inquirerSearch({
135
+ message: '',
136
+ source: async (term) => {
137
+ if (!term) {
138
+ return choices;
139
+ }
140
+ return choices.filter((choice) => matches(choice, term));
141
+ },
142
+ });
143
+ }
144
+
111
145
  /**
112
146
  * Özelleştirilmiş input prompt
113
147
  * @param {object} options - Prompt seçenekleri