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 +40 -0
- package/locales/en.json +28 -1
- package/locales/tr.json +28 -1
- package/package.json +4 -1
- package/src/api-client.js +11 -9
- package/src/commands/theme-dev.js +120 -28
- package/src/commands/theme-section.js +237 -4
- package/src/index.js +19 -2
- package/src/ui/prompt-wrapper.js +35 -1
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.
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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 (
|
|
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
|
|
281
|
+
const watcherOptions = {
|
|
230
282
|
ignored: /(^|[\/\\])\../, persistent: true, ignoreInitial: true, awaitWriteFinish: {
|
|
231
283
|
stabilityThreshold: 300, pollInterval: 100
|
|
232
284
|
}
|
|
233
|
-
}
|
|
285
|
+
};
|
|
234
286
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
keywords: [block.name, block.folder],
|
|
115
131
|
}));
|
|
116
132
|
|
|
117
|
-
const selectedBlock = await
|
|
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);
|
package/src/ui/prompt-wrapper.js
CHANGED
|
@@ -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
|