tsoft-cli 3.7.4 → 3.9.1

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,46 @@ 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.1] - 2026-05-20
9
+
10
+ ### Fixed
11
+ - `theme section add` no longer fails with `ENOENT: theme-update.zip` when the local theme directory does not exist yet (e.g. running `section add` before `theme dev`/`pull`). The target directory is now created before the theme archive is written.
12
+ - `theme dev` no longer corrupts binary assets: image files (`png`, `jpg`, `jpeg`, `gif`, `webp`) were read as UTF-8 text, which mangled invalid byte sequences. They are now read as a buffer and sent base64-encoded for the server to decode.
13
+
14
+ ## [3.9.0] - 2026-05-20
15
+
16
+ ### Added
17
+ - 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).
18
+ - `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.
19
+ - `tsoft theme section fork` — forks an existing section into the active theme under a new name.
20
+ - Searchable pickers: block and section selection prompts (`add`, `create`, `fork`) now filter as you type, matching both the display name and the folder slug.
21
+ - `theme dev` now prints a distinct `section updated` line when a file under `section/` is saved, so section changes are visible during live development.
22
+ - `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.
23
+
24
+ ### Removed
25
+ - Dead `ApiClient.syncTheme()` method — the server now resyncs the section registry automatically on file save, so the explicit sync call is no longer needed.
26
+
27
+ ### Backwards compatibility
28
+ - `theme section add` and `theme section list` remain available as direct sub-commands for scripts and CI.
29
+
30
+ ## [3.8.0] - 2026-04-28
31
+
32
+ ### Added
33
+ - 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`).
34
+ - 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.
35
+ - 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.
36
+ - 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.
37
+ - 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.
38
+ - 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.
39
+ - 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.
40
+ - 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`).
41
+ - 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).
42
+ - 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).
43
+
44
+ ### Internal
45
+ - `vitest` added as `devDependency` (was previously referenced by the `test` script but not installed by `npm install`).
46
+ - 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.
47
+
8
48
  ## [3.7.4] - 2026-04-20
9
49
 
10
50
  ### 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.1",
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 = [];
@@ -94,10 +112,12 @@ async function pullAndSync(themeUuid, themePath) {
94
112
  }
95
113
  }
96
114
 
115
+ const BINARY_EXTENSIONS = ['jpeg', 'jpg', 'png', 'gif', 'webp'];
116
+
97
117
  async function handleFileChange(themeUuid, themePath, filePath) {
98
118
  try {
99
119
  const relativePath = path.relative(themePath, filePath);
100
- const extension = path.extname(filePath).slice(1);
120
+ const extension = path.extname(filePath).slice(1).toLowerCase();
101
121
 
102
122
  const allowedExtensions = ['theme', 'twig', 'css', 'js', 'md', 'json', 'svg', 'jpeg', 'jpg', 'png', 'gif', 'webp'];
103
123
 
@@ -105,18 +125,26 @@ async function handleFileChange(themeUuid, themePath, filePath) {
105
125
  return;
106
126
  }
107
127
 
108
- const content = await fs.readFile(filePath, 'utf-8');
109
128
  const apiClient = await createApiClient();
110
-
111
129
  const startTime = Date.now();
112
130
 
113
- await apiClient.post(`/theme/${themeUuid}/file-manager?q=save`, {
114
- path: relativePath, content: content
115
- });
131
+ // Binary dosyalar (resim) utf-8 string olarak okunursa geçersiz
132
+ // byte'lar bozulur; base64 ile gönderilir, server decode eder.
133
+ const isBinary = BINARY_EXTENSIONS.includes(extension);
134
+ const payload = isBinary
135
+ ? { path: relativePath, content: (await fs.readFile(filePath)).toString('base64'), content_encoding: 'base64' }
136
+ : { path: relativePath, content: await fs.readFile(filePath, 'utf-8') };
137
+
138
+ await apiClient.post(`/theme/${themeUuid}/file-manager?q=save`, payload);
116
139
 
117
140
  const duration = Date.now() - startTime;
118
141
  console.log(chalk.green(`[${new Date().toLocaleTimeString()}] ` + t('dev.file_saved', { path: relativePath, duration })));
119
142
 
143
+ const sectionFolder = resolveSectionFolder(relativePath);
144
+ if (sectionFolder) {
145
+ console.log(chalk.cyan(' ' + t('dev.section_synced', { folder: sectionFolder })));
146
+ }
147
+
120
148
  try {
121
149
  await apiClient.post(`/theme/${themeUuid}/mark-synced`, {});
122
150
  } catch (ackError) {
@@ -185,6 +213,15 @@ async function handleFileDelete(themeUuid, themePath, filePath) {
185
213
  }
186
214
  }
187
215
 
216
+ function printWatchStatus(previewUrl) {
217
+ const sep = chalk.dim(' · ');
218
+ const parts = [chalk.cyan('👀 ' + t('dev.status_watching'))];
219
+ if (previewUrl) parts.push(chalk.bold.green('P') + chalk.gray(' ' + t('dev.status_preview')));
220
+ parts.push(chalk.bold.green('S') + chalk.gray(' ' + t('dev.status_section')));
221
+ parts.push(chalk.gray('Ctrl+C ' + t('dev.status_exit')));
222
+ console.log('\n' + parts.join(sep) + '\n');
223
+ }
224
+
188
225
  async function startFileWatcher(themeUuid, themePath) {
189
226
  // Preview URL — sessizce cek, baglanamazsa status line'da P ipucunu gosterme
190
227
  let previewUrl = null;
@@ -196,15 +233,24 @@ async function startFileWatcher(themeUuid, themePath) {
196
233
  // sessiz — preview opsiyonel
197
234
  }
198
235
 
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');
236
+ printWatchStatus(previewUrl);
237
+
238
+ // Menu acikken hem watcher event'leri hem keypress dinleyici askiya alinir
239
+ // (inquirer ile stdin cakismasini onler).
240
+ let paused = false;
205
241
 
206
- // Klavye dinleyici 'P' tarayıcida preview URL'ini sessizce acar, Ctrl+C cikis
207
- if (previewUrl && process.stdin.isTTY) {
242
+ const setRawMode = (enabled) => {
243
+ if (process.stdin.isTTY) {
244
+ try {
245
+ process.stdin.setRawMode(enabled);
246
+ } catch {
247
+ // raw mode bazi terminallerde calismaz — sessizce gec
248
+ }
249
+ }
250
+ };
251
+
252
+ // Klavye dinleyici — 'P' tarayıcida preview URL'ini sessizce acar, 'S' section menusu, Ctrl+C cikis
253
+ if (process.stdin.isTTY) {
208
254
  try {
209
255
  process.stdin.setRawMode(true);
210
256
  process.stdin.resume();
@@ -213,37 +259,83 @@ async function startFileWatcher(themeUuid, themePath) {
213
259
  if (key === '\u0003') { // Ctrl+C
214
260
  process.exit(0);
215
261
  }
216
- if (key === 'p' || key === 'P') {
262
+ if (paused) {
263
+ return;
264
+ }
265
+ if (previewUrl && (key === 'p' || key === 'P')) {
217
266
  try {
218
267
  await open(previewUrl);
219
268
  } catch (e) {
220
269
  console.log(chalk.red(' ' + t('dev.preview_open_failed', { error: e.message })));
221
270
  }
222
271
  }
272
+ if (key === 's' || key === 'S') {
273
+ await openSectionMenu();
274
+ }
223
275
  });
224
276
  } catch {
225
277
  // raw mode bazi terminallerde calismaz — sessizce gec
226
278
  }
227
279
  }
228
280
 
229
- const watcher = chokidar.watch(themePath, {
281
+ const watcherOptions = {
230
282
  ignored: /(^|[\/\\])\../, persistent: true, ignoreInitial: true, awaitWriteFinish: {
231
283
  stabilityThreshold: 300, pollInterval: 100
232
284
  }
233
- });
285
+ };
234
286
 
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
- });
287
+ const attachWatcherEvents = (instance) => {
288
+ instance
289
+ .on('change', async (filePath) => {
290
+ if (paused) return;
291
+ await handleFileChange(themeUuid, themePath, filePath);
292
+ })
293
+ .on('add', async (filePath) => {
294
+ if (paused) return;
295
+ await handleFileChange(themeUuid, themePath, filePath);
296
+ })
297
+ .on('unlink', async (filePath) => {
298
+ if (paused) return;
299
+ await handleFileDelete(themeUuid, themePath, filePath);
300
+ });
301
+ };
245
302
 
246
- return watcher;
303
+ let watcher = chokidar.watch(themePath, watcherOptions);
304
+ attachWatcherEvents(watcher);
305
+
306
+ async function openSectionMenu() {
307
+ paused = true;
308
+ setRawMode(false);
309
+
310
+ // Menu dallari (create/fork/add) yeni section dosyalarini zip ile
311
+ // indirip extract eder; bu extract eski watcher'da gecikmeli 'change'
312
+ // olaylari uretir. Watcher'i tamamen kapatip menu+extract bittikten
313
+ // sonra yeniden kuruyoruz — ignoreInitial sayesinde yeni watcher
314
+ // extract edilmis dosyalari gormez, sadece sonraki gercek
315
+ // degisiklikleri yakalar.
316
+ await watcher.close();
317
+
318
+ console.log(chalk.cyan('\n' + t('dev.section_menu_opening') + '\n'));
319
+
320
+ try {
321
+ await themeSectionMenuCommand();
322
+ } catch (error) {
323
+ console.log(chalk.red(' ' + error.message));
324
+ }
325
+
326
+ watcher = chokidar.watch(themePath, watcherOptions);
327
+ attachWatcherEvents(watcher);
328
+
329
+ setRawMode(true);
330
+ process.stdin.resume();
331
+ console.log(chalk.green('\n' + t('dev.section_menu_resumed')));
332
+ printWatchStatus(previewUrl);
333
+ paused = false;
334
+ }
335
+
336
+ return {
337
+ close: () => watcher.close(),
338
+ };
247
339
  }
248
340
 
249
341
  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'));
@@ -278,6 +294,10 @@ export async function themeSectionAddCommand() {
278
294
  const themePath = path.join(process.cwd(), storeSlug, activeTheme);
279
295
 
280
296
  try {
297
+ // Tema klasörü henüz lokalde yoksa (theme dev/pull yapılmamışsa)
298
+ // createWriteStream ENOENT verir — önce dizini garantiye al.
299
+ await fs.mkdir(themePath, { recursive: true });
300
+
281
301
  // Temayı indir
282
302
  const downloadResponse = await apiClient.download(`/theme/${themeInfo.uuid}/download`, {});
283
303
 
@@ -315,3 +335,216 @@ export async function themeSectionAddCommand() {
315
335
  process.exit(1);
316
336
  }
317
337
  }
338
+
339
+ async function downloadThemeFiles(apiClient, themeUuid, themePath) {
340
+ await fs.mkdir(themePath, { recursive: true });
341
+
342
+ const downloadResponse = await apiClient.download(`/theme/${themeUuid}/download`, {});
343
+
344
+ const zipPath = path.join(themePath, 'theme-update.zip');
345
+ const writer = createWriteStream(zipPath);
346
+
347
+ downloadResponse.data.pipe(writer);
348
+
349
+ await new Promise((resolve, reject) => {
350
+ writer.on('finish', resolve);
351
+ writer.on('error', reject);
352
+ });
353
+
354
+ const zip = new AdmZip(zipPath);
355
+ zip.extractAllTo(themePath, true);
356
+
357
+ await fs.unlink(zipPath);
358
+ }
359
+
360
+ export async function themeSectionCreateCommand() {
361
+ try {
362
+ const activeTheme = await getActiveTheme();
363
+ if (!activeTheme) {
364
+ showWarning(t('section.add.no_active_theme'));
365
+ return;
366
+ }
367
+
368
+ const themeInfo = await getThemeUuidBySlug(activeTheme);
369
+
370
+ const apiClient = await createApiClient();
371
+ const response = await apiClient.getSections(themeInfo.uuid);
372
+ const blocks = response.data || [];
373
+
374
+ if (blocks.length === 0) {
375
+ showWarning(t('section.create.no_blocks'));
376
+ return;
377
+ }
378
+
379
+ const selectedBlock = await brandedSearch({
380
+ message: t('section.create.block_prompt'),
381
+ choices: blocks.map((block) => ({
382
+ name: `${block.name} ${chalk.gray(`(${block.folder})`)}`,
383
+ value: block,
384
+ keywords: [block.name, block.folder],
385
+ })),
386
+ }, t('section.create.title'));
387
+
388
+ const name = await brandedInput({
389
+ message: t('section.create.name_prompt'),
390
+ validate: validateSectionName,
391
+ }, t('section.create.title'));
392
+
393
+ const sectionName = name.trim();
394
+ const sectionFile = `${sectionName}.twig`;
395
+ const targetPath = `section/${selectedBlock.folder}/${sectionFile}`;
396
+
397
+ const confirm = await brandedConfirm({
398
+ message: t('section.create.confirm_prompt', { file: targetPath, theme: themeInfo.name }),
399
+ default: true,
400
+ }, t('section.add.confirm_title'));
401
+
402
+ if (!confirm) {
403
+ showWarning(t('section.add.cancelled'));
404
+ return;
405
+ }
406
+
407
+ try {
408
+ await apiClient.createSection(themeInfo.uuid, selectedBlock.folder, sectionName);
409
+ } catch (error) {
410
+ if (error.message.startsWith('VALIDATION_ERROR:')) {
411
+ showWarning(t('section.create.failed', { error: error.message.replace('VALIDATION_ERROR:', '') }));
412
+ return;
413
+ }
414
+ throw error;
415
+ }
416
+
417
+ showSuccess(t('section.create.created', { file: targetPath }));
418
+
419
+ const store = await getActiveStore();
420
+ const storeSlug = normalizeStoreDomain(store);
421
+ const themePath = path.join(process.cwd(), storeSlug, activeTheme);
422
+
423
+ try {
424
+ await downloadThemeFiles(apiClient, themeInfo.uuid, themePath);
425
+ console.log(chalk.green(t('section.add.files_updated')));
426
+ console.log(chalk.gray(' ' + t('section.add.location', { path: `${storeSlug}/${activeTheme}/` }) + '\n'));
427
+ } catch (downloadError) {
428
+ console.error(chalk.red(t('section.add.download_error')), downloadError.message);
429
+ console.log(chalk.yellow('\n' + t('section.add.manual_tip')));
430
+ console.log(chalk.white(` ${t('section.add.manual_command')}\n`));
431
+ }
432
+ } catch (error) {
433
+ console.error(chalk.red('\n❌ Hata: ') + error.message);
434
+ process.exit(1);
435
+ }
436
+ }
437
+
438
+ export async function themeSectionForkCommand() {
439
+ try {
440
+ const activeTheme = await getActiveTheme();
441
+ if (!activeTheme) {
442
+ showWarning(t('section.add.no_active_theme'));
443
+ return;
444
+ }
445
+
446
+ const themeInfo = await getThemeUuidBySlug(activeTheme);
447
+
448
+ const apiClient = await createApiClient();
449
+ const response = await apiClient.getSections(themeInfo.uuid);
450
+ const blocks = response.data || [];
451
+
452
+ const sourceSections = [];
453
+ blocks.forEach((block) => {
454
+ (block.default_sections || []).forEach((section) => {
455
+ sourceSections.push({ block, section });
456
+ });
457
+ });
458
+
459
+ if (sourceSections.length === 0) {
460
+ showWarning(t('section.fork.no_sources'));
461
+ return;
462
+ }
463
+
464
+ const selected = await brandedSearch({
465
+ message: t('section.fork.source_prompt'),
466
+ choices: sourceSections.map(({ block, section }) => ({
467
+ name: `${section.name} ${chalk.gray(`(${block.name}, v${section.version})`)}`,
468
+ value: { block, section },
469
+ keywords: [section.name, block.name, block.folder],
470
+ })),
471
+ }, t('section.fork.title'));
472
+
473
+ const newName = await brandedInput({
474
+ message: t('section.fork.name_prompt'),
475
+ validate: validateSectionName,
476
+ }, t('section.fork.title'));
477
+
478
+ const sectionName = newName.trim();
479
+ const sectionFile = `${sectionName}.twig`;
480
+ const blockFolder = selected.block.folder;
481
+ const targetPath = `section/${blockFolder}/${sectionFile}`;
482
+
483
+ const confirm = await brandedConfirm({
484
+ message: t('section.fork.confirm_prompt', {
485
+ source: selected.section.name,
486
+ file: targetPath,
487
+ theme: themeInfo.name,
488
+ }),
489
+ default: true,
490
+ }, t('section.add.confirm_title'));
491
+
492
+ if (!confirm) {
493
+ showWarning(t('section.add.cancelled'));
494
+ return;
495
+ }
496
+
497
+ try {
498
+ await apiClient.createSection(themeInfo.uuid, blockFolder, sectionName);
499
+ } catch (error) {
500
+ if (error.message.startsWith('VALIDATION_ERROR:')) {
501
+ showWarning(t('section.create.failed', { error: error.message.replace('VALIDATION_ERROR:', '') }));
502
+ return;
503
+ }
504
+ throw error;
505
+ }
506
+
507
+ showSuccess(t('section.fork.created', { file: targetPath }));
508
+ showInfo(t('section.fork.next_step', { file: targetPath }));
509
+
510
+ const store = await getActiveStore();
511
+ const storeSlug = normalizeStoreDomain(store);
512
+ const themePath = path.join(process.cwd(), storeSlug, activeTheme);
513
+
514
+ try {
515
+ await downloadThemeFiles(apiClient, themeInfo.uuid, themePath);
516
+ console.log(chalk.green(t('section.add.files_updated')));
517
+ console.log(chalk.gray(' ' + t('section.add.location', { path: `${storeSlug}/${activeTheme}/` }) + '\n'));
518
+ } catch (downloadError) {
519
+ console.error(chalk.red(t('section.add.download_error')), downloadError.message);
520
+ console.log(chalk.yellow('\n' + t('section.add.manual_tip')));
521
+ console.log(chalk.white(` ${t('section.add.manual_command')}\n`));
522
+ }
523
+ } catch (error) {
524
+ console.error(chalk.red('\n❌ Hata: ') + error.message);
525
+ process.exit(1);
526
+ }
527
+ }
528
+
529
+ export async function themeSectionMenuCommand() {
530
+ const action = await brandedSelect({
531
+ message: t('section.menu.prompt'),
532
+ choices: [
533
+ { name: t('section.menu.add'), value: 'add' },
534
+ { name: t('section.menu.create'), value: 'create' },
535
+ { name: t('section.menu.fork'), value: 'fork' },
536
+ { name: t('section.menu.list'), value: 'list' },
537
+ ],
538
+ }, t('section.menu.title'));
539
+
540
+ switch (action) {
541
+ case 'add':
542
+ return themeSectionAddCommand();
543
+ case 'create':
544
+ return themeSectionCreateCommand();
545
+ case 'fork':
546
+ return themeSectionForkCommand();
547
+ case 'list':
548
+ return themeSectionListCommand();
549
+ }
550
+ }
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