tsoft-cli 3.12.2 → 3.12.4

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,18 @@ 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.12.4] - 2026-08-11
9
+
10
+ ### Fixed
11
+ - `theme pull` no longer fails on Windows with `EBUSY: resource busy or locked`. Putting a finished download in place moved the existing theme directory aside first, and Windows refuses to rename a directory that a terminal is sitting in or that has a file open in an editor — so the command failed for anyone whose editor had the theme open, which is the normal case. The theme directory now stays where it is: fresh files are copied in and files no longer present in the archive are removed afterwards, so nothing has to be renamed. macOS and Linux were unaffected because they allow renaming a directory that is in use.
12
+
13
+ ## [3.12.3] - 2026-08-11
14
+
15
+ ### Fixed
16
+ - A failed download no longer destroys the theme you already had on disk. `theme pull`, `theme create` and `theme init` deleted the target directory *before* fetching anything, so a download that arrived truncated or died mid-transfer left nothing behind but a `theme.zip` that could not be opened — the files you had been working on were already gone. Downloads now land in a temporary directory next to the target and are only put in place once the archive has been received and read successfully; if anything goes wrong the existing files stay exactly as they were.
17
+ - A download cut off mid-transfer is now reported instead of hanging. Only the file being written was watched for errors, not the incoming data, so a dropped connection could leave the command waiting indefinitely or crash it outside its own error handling.
18
+ - `theme section` no longer risks the theme it is updating: a corrupt archive is detected before any file in the theme is touched.
19
+
8
20
  ## [3.12.2] - 2026-08-11
9
21
 
10
22
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsoft-cli",
3
- "version": "3.12.2",
3
+ "version": "3.12.4",
4
4
  "description": "Command-line tool for tsoft360 theme development and management",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,8 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import fs from 'fs/promises';
3
- import {createWriteStream} from 'fs';
4
3
  import path from 'path';
5
- import AdmZip from 'adm-zip';
6
4
  import chokidar from 'chokidar';
7
5
  import {createApiClient} from '../api-client.js';
8
6
  import open from 'open';
@@ -12,6 +10,7 @@ import {brandedConfirm, brandedSelect, showWarning, showError, showInfo} from '.
12
10
  import {themeSectionMenuCommand} from './theme-section.js';
13
11
  import {fetchAllThemes} from '../theme-list.js';
14
12
  import { t } from '../i18n.js';
13
+ import {extractThemeArchive} from '../theme-download.js';
15
14
 
16
15
  function resolveSectionFolder(relativePath) {
17
16
  if (!relativePath) {
@@ -61,20 +60,7 @@ async function pullAndSync(themeUuid, themePath) {
61
60
  console.log(chalk.yellow(t('dev.sync_pulling')));
62
61
  const response = await apiClient.download(`/theme/${themeUuid}/download`, {});
63
62
 
64
- await fs.mkdir(themePath, {recursive: true});
65
- const zipPath = path.join(themePath, 'theme.zip');
66
- const writer = createWriteStream(zipPath);
67
-
68
- response.data.pipe(writer);
69
-
70
- await new Promise((resolve, reject) => {
71
- writer.on('finish', resolve);
72
- writer.on('error', reject);
73
- });
74
-
75
- const zip = new AdmZip(zipPath);
76
- zip.extractAllTo(themePath, true);
77
- await fs.unlink(zipPath);
63
+ await extractThemeArchive(response.data, themePath);
78
64
 
79
65
  console.log(chalk.green(t('dev.sync_updated')));
80
66
 
@@ -1,8 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import fs from 'fs/promises';
3
- import {createWriteStream} from 'fs';
4
3
  import path from 'path';
5
- import AdmZip from 'adm-zip';
6
4
  import {createApiClient} from '../api-client.js';
7
5
  import {slugify, getActiveStore, getWorkspaceRoot} from '../storage.js';
8
6
  import {brandedConfirm, brandedSelect, showWarning, showInfo, showError} from '../ui/prompt-wrapper.js';
@@ -11,6 +9,7 @@ import {normalizeStoreDomain} from '../storage.js';
11
9
  import {isJsonMode, jsonOut} from '../output-mode.js';
12
10
  import {mapError} from '../errors/error-mapper.js';
13
11
  import { t } from '../i18n.js';
12
+ import {extractThemeArchive} from '../theme-download.js';
14
13
 
15
14
  export async function themeInitCommand(fromSlug) {
16
15
  try {
@@ -83,21 +82,15 @@ export async function themeInitCommand(fromSlug) {
83
82
 
84
83
  // Check if directory exists
85
84
  const exists = await fs.access(themePath).then(() => true).catch(() => false);
86
- if (exists) {
87
- if (isJsonMode()) {
88
- // JSON modda otomatik overwrite
89
- await fs.rm(themePath, { recursive: true, force: true });
90
- } else {
91
- const overwrite = await brandedConfirm({
92
- message: t('init.dir_exists_prompt', { path: themePath }),
93
- default: false,
94
- }, t('init.dir_exists_title'));
95
-
96
- if (!overwrite) {
97
- showWarning(t('init.dir_cancelled'));
98
- return;
99
- }
100
- await fs.rm(themePath, { recursive: true, force: true });
85
+ if (exists && !isJsonMode()) {
86
+ const overwrite = await brandedConfirm({
87
+ message: t('init.dir_exists_prompt', { path: themePath }),
88
+ default: false,
89
+ }, t('init.dir_exists_title'));
90
+
91
+ if (!overwrite) {
92
+ showWarning(t('init.dir_cancelled'));
93
+ return;
101
94
  }
102
95
  }
103
96
 
@@ -105,19 +98,9 @@ export async function themeInitCommand(fromSlug) {
105
98
  if (!isJsonMode()) {
106
99
  console.log(chalk.yellow(t('init.downloading')));
107
100
  }
108
- await fs.mkdir(themePath, { recursive: true });
109
101
  const downloadResponse = await apiClient.download(`/theme/${forkedData.uuid}/download`, {});
110
- const zipPath = path.join(themePath, 'theme.zip');
111
- const writer = createWriteStream(zipPath);
112
- downloadResponse.data.pipe(writer);
113
- await new Promise((resolve, reject) => {
114
- writer.on('finish', resolve);
115
- writer.on('error', reject);
116
- });
117
-
118
- const zip = new AdmZip(zipPath);
119
- zip.extractAllTo(themePath, true);
120
- await fs.unlink(zipPath);
102
+
103
+ await extractThemeArchive(downloadResponse.data, themePath);
121
104
 
122
105
  // JSON mode success output
123
106
  if (isJsonMode()) {
@@ -1,13 +1,12 @@
1
1
  import chalk from 'chalk';
2
2
  import {checkbox} from '@inquirer/prompts';
3
3
  import fs from 'fs/promises';
4
- import {createWriteStream} from 'fs';
5
4
  import path from 'path';
6
- import AdmZip from 'adm-zip';
7
5
  import {createApiClient} from '../api-client.js';
8
6
  import {getActiveTheme, getThemeUuidBySlug, normalizeStoreDomain, getActiveStore, getWorkspaceRoot} from '../storage.js';
9
7
  import {brandedConfirm, brandedSelect, brandedSearch, brandedInput, showWarning, showSuccess, showInfo} from '../ui/prompt-wrapper.js';
10
8
  import { t } from '../i18n.js';
9
+ import {mergeThemeArchive} from '../theme-download.js';
11
10
 
12
11
  const SECTION_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
13
12
 
@@ -294,31 +293,13 @@ export async function themeSectionAddCommand() {
294
293
  const themePath = path.join(getWorkspaceRoot(), storeSlug, activeTheme);
295
294
 
296
295
  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
-
301
296
  // Temayı indir
302
297
  const downloadResponse = await apiClient.download(`/theme/${themeInfo.uuid}/download`, {});
303
298
 
304
- const zipPath = path.join(themePath, 'theme-update.zip');
305
- const writer = createWriteStream(zipPath);
306
-
307
- downloadResponse.data.pipe(writer);
308
-
309
- await new Promise((resolve, reject) => {
310
- writer.on('finish', resolve);
311
- writer.on('error', reject);
312
- });
313
-
314
299
  console.log(chalk.green(t('section.add.files_downloaded')));
315
300
  console.log(chalk.yellow(t('section.add.files_updating')));
316
301
 
317
- // Extract
318
- const zip = new AdmZip(zipPath);
319
- zip.extractAllTo(themePath, true);
320
-
321
- await fs.unlink(zipPath);
302
+ await mergeThemeArchive(downloadResponse.data, themePath);
322
303
 
323
304
  console.log(chalk.green(t('section.add.files_updated') + '\n'));
324
305
  console.log(chalk.bold.green(t('section.add.complete')));
@@ -337,24 +318,9 @@ export async function themeSectionAddCommand() {
337
318
  }
338
319
 
339
320
  async function downloadThemeFiles(apiClient, themeUuid, themePath) {
340
- await fs.mkdir(themePath, { recursive: true });
341
-
342
321
  const downloadResponse = await apiClient.download(`/theme/${themeUuid}/download`, {});
343
322
 
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);
323
+ await mergeThemeArchive(downloadResponse.data, themePath);
358
324
  }
359
325
 
360
326
  export async function themeSectionCreateCommand() {
@@ -1,9 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import {input} from '@inquirer/prompts';
3
3
  import fs from 'fs/promises';
4
- import {createWriteStream} from 'fs';
5
4
  import path from 'path';
6
- import AdmZip from 'adm-zip';
7
5
  import {createApiClient} from '../api-client.js';
8
6
  import {normalizeStoreDomain, slugify, setActiveTheme, getActiveTheme, getActiveStore, getWorkspaceRoot, getWorkspaceContext} from '../storage.js';
9
7
  import {brandedConfirm, brandedSelect, brandedSearch, showWarning} from '../ui/prompt-wrapper.js';
@@ -12,6 +10,7 @@ import {themeLabels, decorateThemeName} from '../ui/theme-choice.js';
12
10
  import { isJsonMode, jsonOut } from '../output-mode.js';
13
11
  import { mapError } from '../errors/error-mapper.js';
14
12
  import { t } from '../i18n.js';
13
+ import {extractThemeArchive} from '../theme-download.js';
15
14
 
16
15
  const THEME_SEARCH_THRESHOLD = 7;
17
16
 
@@ -289,37 +288,20 @@ export async function themeCreateCommand(themeName) {
289
288
 
290
289
  const response = await apiClient.download('/theme/default/download', downloadParams);
291
290
 
292
- await fs.mkdir(themePath, {recursive: true});
293
-
294
- const zipPath = path.join(themePath, 'theme.zip');
295
- const writer = createWriteStream(zipPath);
296
-
297
- response.data.pipe(writer);
298
-
299
- writer.on('finish', async () => {
300
- console.log(chalk.green(t('theme.files_downloaded')));
301
- console.log(chalk.yellow(t('theme.extracting')));
302
-
303
- const zip = new AdmZip(zipPath);
304
- zip.extractAllTo(themePath, true);
305
-
306
- await fs.unlink(zipPath);
291
+ console.log(chalk.green(t('theme.files_downloaded')));
292
+ console.log(chalk.yellow(t('theme.extracting')));
307
293
 
308
- console.log(chalk.green(t('theme.extracted', { path: `${storeSlug}/${themeSlug}/` })));
294
+ await extractThemeArchive(response.data, themePath);
309
295
 
310
- await setActiveTheme(themeSlug);
311
- console.log(chalk.green(t('theme.active_set') + '\n'));
296
+ console.log(chalk.green(t('theme.extracted', { path: `${storeSlug}/${themeSlug}/` })));
312
297
 
313
- console.log(chalk.bold.green(t('theme.create_success')));
314
- console.log(chalk.gray(' ' + t('theme.create_location', { path: `${storeSlug}/${themeSlug}/` }) + '\n'));
315
- console.log(chalk.cyan(t('theme.dev_start_tip')));
316
- console.log(chalk.white(' tsoft theme dev\n'));
317
- });
298
+ await setActiveTheme(themeSlug);
299
+ console.log(chalk.green(t('theme.active_set') + '\n'));
318
300
 
319
- writer.on('error', (error) => {
320
- console.error(chalk.red('❌ Hata: ' + t('theme.file_write_error')), error.message);
321
- process.exit(1);
322
- });
301
+ console.log(chalk.bold.green(t('theme.create_success')));
302
+ console.log(chalk.gray(' ' + t('theme.create_location', { path: `${storeSlug}/${themeSlug}/` }) + '\n'));
303
+ console.log(chalk.cyan(t('theme.dev_start_tip')));
304
+ console.log(chalk.white(' tsoft theme dev\n'));
323
305
 
324
306
  } catch (error) {
325
307
  console.error(chalk.red('\n❌ Hata: ') + error.message);
@@ -385,33 +367,16 @@ export async function themePullCommand() {
385
367
  showWarning(t('theme.cancelled'));
386
368
  return;
387
369
  }
388
-
389
- await fs.rm(themePath, {recursive: true, force: true});
390
370
  }
391
371
 
392
372
  console.log(chalk.yellow(t('theme.downloading_files')));
393
373
 
394
374
  const downloadResponse = await apiClient.download(`/theme/${themeUuid}/download`, {});
395
375
 
396
- await fs.mkdir(themePath, {recursive: true});
397
-
398
- const zipPath = path.join(themePath, 'theme.zip');
399
- const writer = createWriteStream(zipPath);
400
-
401
- downloadResponse.data.pipe(writer);
402
-
403
- await new Promise((resolve, reject) => {
404
- writer.on('finish', resolve);
405
- writer.on('error', reject);
406
- });
407
-
408
376
  console.log(chalk.green(t('theme.files_downloaded')));
409
377
  console.log(chalk.yellow(t('theme.extracting')));
410
378
 
411
- const zip = new AdmZip(zipPath);
412
- zip.extractAllTo(themePath, true);
413
-
414
- await fs.unlink(zipPath);
379
+ await extractThemeArchive(downloadResponse.data, themePath);
415
380
 
416
381
  console.log(chalk.green(t('theme.extracted', { path: `${storeSlug}/${themeSlug}/` })));
417
382
 
@@ -0,0 +1,105 @@
1
+ import fs from 'fs/promises';
2
+ import { createWriteStream } from 'fs';
3
+ import path from 'path';
4
+ import { pipeline } from 'stream/promises';
5
+ import AdmZip from 'adm-zip';
6
+
7
+ async function stageArchive(stream, stagingPath) {
8
+ const zipPath = path.join(stagingPath, 'theme.zip');
9
+
10
+ await pipeline(stream, createWriteStream(zipPath));
11
+
12
+ const { size } = await fs.stat(zipPath);
13
+
14
+ if (size === 0) {
15
+ throw new Error('EMPTY_ARCHIVE');
16
+ }
17
+
18
+ const zip = new AdmZip(zipPath);
19
+ zip.extractAllTo(stagingPath, true);
20
+
21
+ await fs.rm(zipPath, { force: true });
22
+ }
23
+
24
+ async function withStaging(themePath, apply) {
25
+ const parent = path.dirname(themePath);
26
+ await fs.mkdir(parent, { recursive: true });
27
+
28
+ const staging = await fs.mkdtemp(path.join(parent, `.${path.basename(themePath)}-`));
29
+
30
+ try {
31
+ return await apply(staging);
32
+ } finally {
33
+ await fs.rm(staging, { recursive: true, force: true });
34
+ }
35
+ }
36
+
37
+ async function copyInto(sourceDir, targetDir) {
38
+ for (const entry of await fs.readdir(sourceDir)) {
39
+ await fs.cp(path.join(sourceDir, entry), path.join(targetDir, entry), {
40
+ recursive: true,
41
+ force: true,
42
+ });
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Arşivde bulunmayan girdileri hedeften siler.
48
+ *
49
+ * Dizini yeniden adlandırmak Windows'ta içine girilmişse veya içindeki bir
50
+ * dosya açıksa EBUSY verir; bu yüzden hedef dizin yerinde bırakılır ve yalnızca
51
+ * fazlalık girdiler kaldırılır. Kalıcı bir kilit tek bir dosyayı geride
52
+ * bırakabilir, ama temanın geri kalanı yine güncellenmiş olur.
53
+ */
54
+ async function removeStaleEntries(themePath, stagingPath) {
55
+ const fresh = new Set(await fs.readdir(stagingPath));
56
+
57
+ for (const entry of await fs.readdir(themePath)) {
58
+ if (!fresh.has(entry)) {
59
+ await fs.rm(path.join(themePath, entry), { recursive: true, force: true });
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Tema arşivini indirir, doğrular ve ancak ondan sonra hedefe yerleştirir.
66
+ *
67
+ * Arşiv hedefin yanındaki geçici dizine açılır; indirme yarıda koparsa veya
68
+ * arşiv bozuksa hedef dizine dokunulmaz. Hedef dizinin kendisi taşınmaz,
69
+ * yalnızca içeriği değiştirilir — dizin bir editörde veya kabukta açıkken de
70
+ * çalışır.
71
+ *
72
+ * @param {import('stream').Readable} stream - Arşiv veri akışı
73
+ * @param {string} themePath - Temanın açılacağı hedef dizin
74
+ * @returns {Promise<void>}
75
+ */
76
+ export async function extractThemeArchive(stream, themePath) {
77
+ await withStaging(themePath, async (staging) => {
78
+ await stageArchive(stream, staging);
79
+
80
+ await fs.mkdir(themePath, { recursive: true });
81
+
82
+ await copyInto(staging, themePath);
83
+ await removeStaleEntries(themePath, staging);
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Tema arşivini indirir, doğrular ve ancak ondan sonra hedefin üzerine açar.
89
+ *
90
+ * Hedefteki dosyalar silinmez; arşivdekiler üzerine yazılır. Bozuk veya yarım
91
+ * inen bir arşiv hedefe hiç dokunmaz.
92
+ *
93
+ * @param {import('stream').Readable} stream - Arşiv veri akışı
94
+ * @param {string} themePath - Arşivin açılacağı dizin
95
+ * @returns {Promise<void>}
96
+ */
97
+ export async function mergeThemeArchive(stream, themePath) {
98
+ await withStaging(themePath, async (staging) => {
99
+ await stageArchive(stream, staging);
100
+
101
+ await fs.mkdir(themePath, { recursive: true });
102
+
103
+ await copyInto(staging, themePath);
104
+ });
105
+ }