tsoft-cli 3.12.3 → 3.13.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.
@@ -3,6 +3,7 @@ import { listKnownStores, setActiveStore, getActiveTheme } from '../storage.js';
3
3
  import { brandedSelect, brandedSearch, showSuccess, showError, showInfo } from '../ui/prompt-wrapper.js';
4
4
  import { isJsonMode, jsonOut } from '../output-mode.js';
5
5
  import { t } from '../i18n.js';
6
+ import { renderTable } from '../ui/table.js';
6
7
 
7
8
  const SEARCH_THRESHOLD = 7;
8
9
 
@@ -43,11 +44,17 @@ export async function storeListCommand() {
43
44
 
44
45
  console.log(chalk.bold.cyan(t('store.count', { count: stores.length }) + '\n'));
45
46
 
46
- for (const entry of stores) {
47
+ const rows = stores.map((entry) => {
47
48
  const marker = entry.active ? chalk.green('●') : chalk.gray('○');
48
49
  const name = entry.active ? chalk.bold.green(entry.store) : chalk.white(entry.store);
49
- console.log(`${marker} ${name} ${sessionLabel(entry)}`);
50
- }
50
+
51
+ return [`${marker} ${name}`, sessionLabel(entry)];
52
+ });
53
+
54
+ console.log(renderTable({
55
+ head: [t('table.head.store'), t('table.head.session')],
56
+ rows,
57
+ }));
51
58
 
52
59
  console.log();
53
60
  }
@@ -4,7 +4,8 @@ import path from 'path';
4
4
  import chokidar from 'chokidar';
5
5
  import {createApiClient} from '../api-client.js';
6
6
  import open from 'open';
7
- import {normalizeStoreDomain, slugify, setActiveTheme, getActiveTheme, getActiveStore, getWorkspaceRoot} from '../storage.js';
7
+ import {normalizeStoreDomain, slugify, setActiveTheme, getActiveTheme, getActiveStore, getWorkspaceRoot, getWorkspaceContext} from '../storage.js';
8
+ import {buildThemeMissingHint, formatThemeMissingHint} from '../ui/workspace-hint.js';
8
9
  import {decorateThemeName} from '../ui/theme-choice.js';
9
10
  import {brandedConfirm, brandedSelect, showWarning, showError, showInfo} from '../ui/prompt-wrapper.js';
10
11
  import {themeSectionMenuCommand} from './theme-section.js';
@@ -471,7 +472,13 @@ export async function themeDevCommand(themeName) {
471
472
 
472
473
  const exists = await fs.access(themePath).then(() => true).catch(() => false);
473
474
  if (!exists) {
474
- showError(t('dev.theme_not_found', { path: themePath }));
475
+ const missing = buildThemeMissingHint({
476
+ context: getWorkspaceContext(),
477
+ activeStore: store,
478
+ activeTheme: themeDir,
479
+ workspaceRoot: getWorkspaceRoot(),
480
+ });
481
+ showError(formatThemeMissingHint(missing, themePath));
475
482
  return;
476
483
  }
477
484
 
@@ -130,7 +130,9 @@ export async function themeInitCommand(fromSlug) {
130
130
  console.log(chalk.cyan(' ' + t('init.source_label') + ' ') + chalk.white(`${forkedData.source.name} v${forkedData.source.version}`));
131
131
  console.log(chalk.cyan(' ' + t('init.location_label') + ' ') + chalk.white(`${storeSlug}/${forkedSlug}/`));
132
132
  console.log();
133
- console.log(chalk.gray(t('init.dev_tip', { slug: forkedSlug })));
133
+ console.log(chalk.gray(t('init.dev_tip')));
134
+ console.log(chalk.white(` cd ${storeSlug}/${forkedSlug}`));
135
+ console.log(chalk.white(' tsoft theme dev'));
134
136
  console.log();
135
137
 
136
138
  } catch (error) {
@@ -7,6 +7,7 @@ import {getActiveTheme, getThemeUuidBySlug, normalizeStoreDomain, getActiveStore
7
7
  import {brandedConfirm, brandedSelect, brandedSearch, brandedInput, showWarning, showSuccess, showInfo} from '../ui/prompt-wrapper.js';
8
8
  import { t } from '../i18n.js';
9
9
  import {mergeThemeArchive} from '../theme-download.js';
10
+ import {renderTable} from '../ui/table.js';
10
11
 
11
12
  const SECTION_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
12
13
 
@@ -52,30 +53,28 @@ export async function themeSectionListCommand() {
52
53
  console.log(chalk.bold.cyan(t('section.list.count', { count: blocks.length }) + '\n'));
53
54
 
54
55
  blocks.forEach((block) => {
55
- // Block başlığı
56
- console.log(chalk.bold.cyan(`📦 ${block.name}`));
57
- console.log(chalk.gray(` ${t('section.list.id_label')} ${block.id}`));
58
- console.log(chalk.gray(` ${t('section.list.folder_label')} ${block.folder}`));
56
+ console.log(chalk.bold.cyan(`📦 ${block.name}`) + chalk.gray(` ${block.folder}`));
59
57
 
60
- // Section'lar
61
58
  if (block.default_sections && block.default_sections.length > 0) {
62
- console.log(chalk.yellow(' ' + t('section.list.sections_label', { count: block.default_sections.length })));
63
-
64
- block.default_sections.forEach((section) => {
65
- console.log(chalk.white(` • ${section.name} ${chalk.gray(`(ID: ${section.id}, v${section.version})`)}`));
66
-
67
- // Meta bilgiler varsa göster
68
- if (section.meta) {
69
- if (section.meta.description) {
70
- console.log(chalk.gray(` ${section.meta.description}`));
71
- }
72
- if (section.meta.tags && section.meta.tags.length > 0) {
73
- console.log(chalk.gray(` ${t('section.list.tags_label')} ${section.meta.tags.join(', ')}`));
74
- }
75
- }
76
-
77
- console.log(chalk.gray(` ${t('section.list.file_label')} ${section.content}`));
78
- });
59
+ const rows = block.default_sections.map((section) => [
60
+ chalk.white(section.name),
61
+ chalk.gray(`v${section.version}`),
62
+ chalk.gray(String(section.id)),
63
+ chalk.gray(section.content),
64
+ chalk.gray(section.meta?.description ?? ''),
65
+ ]);
66
+
67
+ console.log(renderTable({
68
+ head: [
69
+ t('table.head.section'),
70
+ t('table.head.version'),
71
+ t('table.head.id'),
72
+ t('table.head.file'),
73
+ t('table.head.description'),
74
+ ],
75
+ rows,
76
+ indent: ' ',
77
+ }));
79
78
  } else {
80
79
  console.log(chalk.red(' ' + t('section.list.no_sections')));
81
80
  }
@@ -7,6 +7,7 @@ import {normalizeStoreDomain, slugify, setActiveTheme, getActiveTheme, getActive
7
7
  import {brandedConfirm, brandedSelect, brandedSearch, showWarning} from '../ui/prompt-wrapper.js';
8
8
  import {fetchAllThemes} from '../theme-list.js';
9
9
  import {themeLabels, decorateThemeName} from '../ui/theme-choice.js';
10
+ import {renderTable} from '../ui/table.js';
10
11
  import { isJsonMode, jsonOut } from '../output-mode.js';
11
12
  import { mapError } from '../errors/error-mapper.js';
12
13
  import { t } from '../i18n.js';
@@ -48,18 +49,32 @@ export async function themeListCommand() {
48
49
 
49
50
  const localTheme = await getActiveTheme();
50
51
 
51
- themes.forEach((theme) => {
52
+ const rows = themes.map((theme) => {
52
53
  const labels = themeLabels(theme, localTheme);
53
54
  const indicator = labels.includes('selected')
54
55
  ? chalk.cyan('●')
55
56
  : (labels.includes('published') ? chalk.green('●') : chalk.gray('○'));
56
- const description = theme.description ? chalk.gray(` - ${theme.description}`) : '';
57
-
58
- console.log(`${indicator} ${decorateThemeName(theme, localTheme)}${description}`);
59
- console.log(chalk.gray(` ${t('theme.folder_label')} ${theme.theme_folder || theme.alias}`));
60
- console.log();
57
+ const name = labels.includes('selected')
58
+ ? chalk.bold.cyan(theme.name)
59
+ : chalk.white(theme.name);
60
+ const marks = labels.map(label => label === 'selected'
61
+ ? chalk.cyan(t('theme.mark.selected'))
62
+ : chalk.green(t('theme.mark.published')));
63
+
64
+ return [
65
+ `${indicator} ${name}`,
66
+ chalk.gray(`v${theme.version}`),
67
+ chalk.gray(theme.theme_folder || theme.alias),
68
+ marks.join(' '),
69
+ ];
61
70
  });
62
71
 
72
+ console.log(renderTable({
73
+ head: [t('table.head.theme'), t('table.head.version'), t('table.head.folder'), t('table.head.status')],
74
+ rows,
75
+ }));
76
+ console.log();
77
+
63
78
  } catch (error) {
64
79
  if (isJsonMode()) {
65
80
  const mapped = mapError(error, { command: 'theme list' });
@@ -301,6 +316,7 @@ export async function themeCreateCommand(themeName) {
301
316
  console.log(chalk.bold.green(t('theme.create_success')));
302
317
  console.log(chalk.gray(' ' + t('theme.create_location', { path: `${storeSlug}/${themeSlug}/` }) + '\n'));
303
318
  console.log(chalk.cyan(t('theme.dev_start_tip')));
319
+ console.log(chalk.white(` cd ${storeSlug}/${themeSlug}`));
304
320
  console.log(chalk.white(' tsoft theme dev\n'));
305
321
 
306
322
  } catch (error) {
@@ -385,6 +401,9 @@ export async function themePullCommand() {
385
401
 
386
402
  console.log(chalk.bold.green(t('theme.pull.success')));
387
403
  console.log(chalk.gray(' ' + t('theme.create_location', { path: `${storeSlug}/${themeSlug}/` }) + '\n'));
404
+ console.log(chalk.cyan(t('theme.dev_start_tip')));
405
+ console.log(chalk.white(` cd ${storeSlug}/${themeSlug}`));
406
+ console.log(chalk.white(' tsoft theme dev\n'));
388
407
  } catch (error) {
389
408
  console.error(chalk.red('\n❌ Hata: ') + error.message);
390
409
  if (error.response?.status === 401) {
@@ -0,0 +1,116 @@
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { fileURLToPath } from 'url';
4
+ import { createRequire } from 'module';
5
+ import chalk from 'chalk';
6
+ import axios from 'axios';
7
+ import { isJsonMode, jsonOut } from '../output-mode.js';
8
+ import { showError, showInfo, showSuccess } from '../ui/prompt-wrapper.js';
9
+ import { t } from '../i18n.js';
10
+ import {
11
+ PACKAGE_NAME,
12
+ detectInstallMethod,
13
+ upgradeCommandFor,
14
+ isNewerAvailable,
15
+ } from '../upgrade-plan.js';
16
+
17
+ const run = promisify(execFile);
18
+ const require = createRequire(import.meta.url);
19
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
20
+ const CHECK_TIMEOUT_MS = 3000;
21
+
22
+ function installedVersion() {
23
+ return require('../../package.json').version;
24
+ }
25
+
26
+ function modulePath() {
27
+ return fileURLToPath(import.meta.url);
28
+ }
29
+
30
+ export async function fetchLatestVersion(timeout = CHECK_TIMEOUT_MS) {
31
+ try {
32
+ const { data } = await axios.get(REGISTRY_URL, { timeout });
33
+
34
+ return typeof data?.version === 'string' ? data.version : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export async function upgradeCommand() {
41
+ const current = installedVersion();
42
+ const method = detectInstallMethod({ modulePath: modulePath() });
43
+ const command = upgradeCommandFor(method);
44
+
45
+ if (!isJsonMode()) {
46
+ console.log(chalk.cyan(t('upgrade.checking')));
47
+ }
48
+
49
+ const latest = await fetchLatestVersion();
50
+
51
+ if (!latest) {
52
+ if (isJsonMode()) {
53
+ jsonOut({ status: 'error', command: 'upgrade', error: { message: t('upgrade.check_failed'), hint: null } });
54
+ process.exit(1);
55
+ }
56
+ showError(t('upgrade.check_failed'));
57
+ process.exit(1);
58
+ }
59
+
60
+ if (!isNewerAvailable(current, latest)) {
61
+ if (isJsonMode()) {
62
+ jsonOut({ status: 'ok', command: 'upgrade', current, latest, upToDate: true });
63
+ return;
64
+ }
65
+ showSuccess(t('upgrade.current', { version: current }));
66
+ return;
67
+ }
68
+
69
+ if (isJsonMode()) {
70
+ jsonOut({ status: 'ok', command: 'upgrade', current, latest, upToDate: false, installMethod: method, command_hint: command });
71
+ return;
72
+ }
73
+
74
+ console.log(chalk.yellow(t('upgrade.available', { current, latest })));
75
+ console.log();
76
+
77
+ if (!command) {
78
+ showInfo(method === 'npx' ? t('upgrade.npx_notice') : t('upgrade.unknown_install'));
79
+ return;
80
+ }
81
+
82
+ console.log(chalk.gray(t('upgrade.running', { command })));
83
+ console.log();
84
+
85
+ const [file, ...args] = command.split(' ');
86
+
87
+ try {
88
+ const { stdout } = await run(file, args, { timeout: 120000 });
89
+
90
+ if (stdout.trim()) {
91
+ console.log(chalk.gray(stdout.trim()));
92
+ }
93
+
94
+ showSuccess(t('upgrade.success', { version: latest }));
95
+ } catch (error) {
96
+ showError(t('upgrade.failed', { error: error.stderr?.trim() || error.message }));
97
+ process.exit(1);
98
+ }
99
+ }
100
+
101
+ export async function buildUpgradeNotice() {
102
+ const current = installedVersion();
103
+ const latest = await fetchLatestVersion();
104
+
105
+ if (!isNewerAvailable(current, latest)) {
106
+ return null;
107
+ }
108
+
109
+ const method = detectInstallMethod({ modulePath: modulePath() });
110
+
111
+ return {
112
+ current,
113
+ latest,
114
+ command: upgradeCommandFor(method) ?? `npm install -g ${PACKAGE_NAME}@latest`,
115
+ };
116
+ }
package/src/index.js CHANGED
@@ -3,8 +3,8 @@
3
3
  import { createRequire } from 'module';
4
4
  import { Command } from 'commander';
5
5
  import chalk from 'chalk';
6
- import { setJsonMode, setVerboseMode } from './output-mode.js';
7
- import { initLocale } from './i18n.js';
6
+ import { setJsonMode, setVerboseMode, isJsonMode, jsonOut } from './output-mode.js';
7
+ import { initLocale, t } from './i18n.js';
8
8
  import { renderBrandedHelp } from './ui/help-renderer.js';
9
9
  import { loginCommand } from './commands/login.js';
10
10
  import { logoutCommand } from './commands/logout.js';
@@ -18,7 +18,15 @@ import { themeSubmitCommand } from './commands/theme-submit.js';
18
18
  import { themeInitCommand } from './commands/theme-init.js';
19
19
  import { orgSwitchCommand } from './commands/org-switch.js';
20
20
  import { storeListCommand, storeUseCommand } from './commands/store.js';
21
- import { setStoreOverride, applyWorkspaceContext } from './storage.js';
21
+ import { docsCommand } from './commands/docs.js';
22
+ import { upgradeCommand, buildUpgradeNotice } from './commands/upgrade.js';
23
+ import { configAutoupgradeCommand } from './commands/config.js';
24
+ import { getAutoupgrade, getLastCheck, setLastCheck } from './cli-settings.js';
25
+ import { setStoreOverride, applyWorkspaceContext, getWorkspaceContext, getWorkspaceRoot, getActiveStore, getActiveTheme } from './storage.js';
26
+ import { buildWorkspaceHint, formatWorkspaceHint, commandUsesThemeContext } from './ui/workspace-hint.js';
27
+ import { suggestCommand, formatUnknownCommand } from './ui/command-suggestion.js';
28
+ import { collectCommandTree, renderCompletion, SUPPORTED_SHELLS } from './ui/completion.js';
29
+ import { showError, brandedConfirm } from './ui/prompt-wrapper.js';
22
30
 
23
31
  const require = createRequire(import.meta.url);
24
32
  const { version } = require('../package.json');
@@ -38,7 +46,7 @@ program.option('--verbose', 'Verbose debug output');
38
46
  program.option('--store <domain>', 'Run this command against a specific store');
39
47
 
40
48
  // preAction hook: runs before every command
41
- program.hook('preAction', async (thisCommand) => {
49
+ program.hook('preAction', async (thisCommand, actionCommand) => {
42
50
  const opts = thisCommand.opts();
43
51
  if (opts.json) {
44
52
  setJsonMode(true);
@@ -51,10 +59,130 @@ program.hook('preAction', async (thisCommand) => {
51
59
  setStoreOverride(opts.store);
52
60
  }
53
61
  await applyWorkspaceContext();
62
+ await warnWhenOutsideThemeDirectory(actionCommand);
63
+ await noticeNewVersion(actionCommand);
54
64
  });
55
65
 
66
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
67
+ const NO_CHECK_COMMANDS = new Set(['upgrade', 'update', 'completion', 'config autoupgrade']);
68
+
69
+ async function noticeNewVersion(actionCommand) {
70
+ if (isJsonMode() || NO_CHECK_COMMANDS.has(commandPathOf(actionCommand))) {
71
+ return;
72
+ }
73
+
74
+ try {
75
+ if (!(await getAutoupgrade())) {
76
+ return;
77
+ }
78
+
79
+ const now = Date.now();
80
+
81
+ if (now - (await getLastCheck()) < CHECK_INTERVAL_MS) {
82
+ return;
83
+ }
84
+
85
+ await setLastCheck(now);
86
+
87
+ const notice = await buildUpgradeNotice();
88
+
89
+ if (notice) {
90
+ console.log(chalk.yellow(t('upgrade.notice', notice)));
91
+ console.log(chalk.gray(' ' + t('upgrade.notice_disable')));
92
+ console.log();
93
+ }
94
+ } catch {
95
+ return;
96
+ }
97
+ }
98
+
99
+ function commandPathOf(actionCommand) {
100
+ const segments = [];
101
+
102
+ for (let cmd = actionCommand; cmd?.parent; cmd = cmd.parent) {
103
+ segments.unshift(cmd.name());
104
+ }
105
+
106
+ return segments.join(' ');
107
+ }
108
+
109
+ async function warnWhenOutsideThemeDirectory(actionCommand) {
110
+ if (isJsonMode() || !commandUsesThemeContext(commandPathOf(actionCommand))) {
111
+ return;
112
+ }
113
+
114
+ const activeStore = await getActiveStore();
115
+
116
+ if (!activeStore) {
117
+ return;
118
+ }
119
+
120
+ const hint = buildWorkspaceHint({
121
+ context: getWorkspaceContext(),
122
+ activeStore,
123
+ activeTheme: await getActiveTheme(activeStore),
124
+ workspaceRoot: getWorkspaceRoot(),
125
+ });
126
+
127
+ if (hint) {
128
+ console.log(formatWorkspaceHint(hint));
129
+ console.log();
130
+ }
131
+ }
132
+
133
+ function visibleNames(cmd) {
134
+ const names = [];
135
+
136
+ for (const sub of cmd.commands) {
137
+ if (sub._hidden) {
138
+ continue;
139
+ }
140
+ names.push(sub.name());
141
+ if (sub.alias()) {
142
+ names.push(sub.alias());
143
+ }
144
+ }
145
+
146
+ return names;
147
+ }
148
+
149
+ async function rejectUnknownCommand(cmd, parentPath = null) {
150
+ const [unknown] = cmd.args;
151
+ const message = formatUnknownCommand(unknown, visibleNames(cmd), parentPath);
152
+
153
+ if (isJsonMode()) {
154
+ jsonOut({ status: 'error', error: { message, hint: null } });
155
+ process.exit(1);
156
+ }
157
+
158
+ showError(message);
159
+
160
+ const suggestion = suggestCommand(unknown, visibleNames(cmd));
161
+
162
+ if (suggestion && process.stdin.isTTY) {
163
+ const full = parentPath ? `${parentPath} ${suggestion}` : suggestion;
164
+ const accepted = await brandedConfirm({
165
+ message: t('error.run_suggestion', { command: `tsoft ${full}` }),
166
+ default: true,
167
+ });
168
+
169
+ if (accepted) {
170
+ const rest = cmd.args.slice(1);
171
+ await program.parseAsync([...process.argv.slice(0, 2), ...full.split(' '), ...rest]);
172
+ return;
173
+ }
174
+ }
175
+
176
+ process.exit(1);
177
+ }
178
+
56
179
  // Show branded help when no arguments provided
57
- program.action(async () => {
180
+ program.action(async (_options, cmd) => {
181
+ if (cmd.args.length) {
182
+ await rejectUnknownCommand(cmd);
183
+ return;
184
+ }
185
+
58
186
  await renderBrandedHelp(program);
59
187
  });
60
188
 
@@ -88,6 +216,47 @@ program
88
216
  await whoamiCommand();
89
217
  });
90
218
 
219
+ program
220
+ .command('docs')
221
+ .description('Open the developer documentation in your browser')
222
+ .action(async () => {
223
+ await docsCommand();
224
+ });
225
+
226
+ program
227
+ .command('upgrade')
228
+ .alias('update')
229
+ .description('Update the CLI to the latest version')
230
+ .action(async () => {
231
+ await upgradeCommand();
232
+ });
233
+
234
+ const configCmd = program
235
+ .command('config')
236
+ .description('Show and change CLI settings');
237
+
238
+ configCmd
239
+ .command('autoupgrade [action]')
240
+ .description('Turn the version check on or off (on | off | status)')
241
+ .action(async (action) => {
242
+ await configAutoupgradeCommand(action ?? 'status');
243
+ });
244
+
245
+ program
246
+ .command('completion <shell>')
247
+ .description(`Generate a shell completion script (${SUPPORTED_SHELLS.join(', ')})`)
248
+ .action((shell) => {
249
+ if (!SUPPORTED_SHELLS.includes(shell)) {
250
+ showError(t('completion.unsupported_shell', {
251
+ shell,
252
+ supported: SUPPORTED_SHELLS.join(', '),
253
+ }));
254
+ process.exit(1);
255
+ }
256
+
257
+ process.stdout.write(renderCompletion(shell, collectCommandTree(program)));
258
+ });
259
+
91
260
  const theme = program
92
261
  .command('theme')
93
262
  .description('Theme management commands');
@@ -195,7 +364,12 @@ org
195
364
  const section = theme
196
365
  .command('section')
197
366
  .description('Section management commands')
198
- .action(async () => {
367
+ .action(async (_options, cmd) => {
368
+ if (cmd.args.length) {
369
+ await rejectUnknownCommand(cmd, 'theme section');
370
+ return;
371
+ }
372
+
199
373
  await themeSectionMenuCommand();
200
374
  });
201
375
 
@@ -21,79 +21,85 @@ async function stageArchive(stream, stagingPath) {
21
21
  await fs.rm(zipPath, { force: true });
22
22
  }
23
23
 
24
- /**
25
- * Tema arşivini indirir, doğrular ve ancak ondan sonra hedefin üzerine açar.
26
- *
27
- * Hedefteki dosyalar silinmez; arşivdekiler üzerine yazılır. Bozuk veya yarım
28
- * inen bir arşiv hedefe hiç dokunmaz.
29
- *
30
- * @param {import('stream').Readable} stream - Arşiv veri akışı
31
- * @param {string} themePath - Arşivin açılacağı dizin
32
- * @returns {Promise<void>}
33
- */
34
- export async function mergeThemeArchive(stream, themePath) {
35
- await fs.mkdir(themePath, { recursive: true });
24
+ async function withStaging(themePath, apply) {
25
+ const parent = path.dirname(themePath);
26
+ await fs.mkdir(parent, { recursive: true });
36
27
 
37
- const staging = await fs.mkdtemp(path.join(themePath, '.tsoft-update-'));
28
+ const staging = await fs.mkdtemp(path.join(parent, `.${path.basename(themePath)}-`));
38
29
 
39
30
  try {
40
- const zipPath = path.join(staging, 'theme.zip');
31
+ return await apply(staging);
32
+ } finally {
33
+ await fs.rm(staging, { recursive: true, force: true });
34
+ }
35
+ }
41
36
 
42
- await pipeline(stream, createWriteStream(zipPath));
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
+ }
43
45
 
44
- const { size } = await fs.stat(zipPath);
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));
45
56
 
46
- if (size === 0) {
47
- throw new Error('EMPTY_ARCHIVE');
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 });
48
60
  }
49
-
50
- const zip = new AdmZip(zipPath);
51
- zip.extractAllTo(themePath, true);
52
- } finally {
53
- await fs.rm(staging, { recursive: true, force: true });
54
61
  }
55
62
  }
56
63
 
57
64
  /**
58
- * Tema arşivini indirir, doğrular ve ancak ondan sonra hedefin üzerine koyar.
65
+ * Tema arşivini indirir, doğrular ve ancak ondan sonra hedefe yerleştirir.
59
66
  *
60
67
  * Arşiv hedefin yanındaki geçici dizine açılır; indirme yarıda koparsa veya
61
- * arşiv bozuksa hedef dizine dokunulmaz. Böylece başarısız bir indirme
62
- * kullanıcının mevcut tema dosyalarını silmez.
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.
63
71
  *
64
72
  * @param {import('stream').Readable} stream - Arşiv veri akışı
65
73
  * @param {string} themePath - Temanın açılacağı hedef dizin
66
74
  * @returns {Promise<void>}
67
75
  */
68
76
  export async function extractThemeArchive(stream, themePath) {
69
- const parent = path.dirname(themePath);
70
- await fs.mkdir(parent, { recursive: true });
71
-
72
- const staging = await fs.mkdtemp(path.join(parent, `.${path.basename(themePath)}-`));
73
- const backup = `${staging}-previous`;
74
-
75
- try {
77
+ await withStaging(themePath, async (staging) => {
76
78
  await stageArchive(stream, staging);
77
- } catch (error) {
78
- await fs.rm(staging, { recursive: true, force: true });
79
- throw error;
80
- }
81
79
 
82
- const hadPrevious = await fs.access(themePath).then(() => true).catch(() => false);
80
+ await fs.mkdir(themePath, { recursive: true });
83
81
 
84
- if (hadPrevious) {
85
- await fs.rename(themePath, backup);
86
- }
82
+ await copyInto(staging, themePath);
83
+ await removeStaleEntries(themePath, staging);
84
+ });
85
+ }
87
86
 
88
- try {
89
- await fs.rename(staging, themePath);
90
- } catch (error) {
91
- if (hadPrevious) {
92
- await fs.rename(backup, themePath);
93
- }
94
- await fs.rm(staging, { recursive: true, force: true });
95
- throw error;
96
- }
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 });
97
102
 
98
- await fs.rm(backup, { recursive: true, force: true });
103
+ await copyInto(staging, themePath);
104
+ });
99
105
  }