@struggler/cli 1.0.5 → 1.0.7

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/command/upload.js CHANGED
@@ -2,7 +2,7 @@ var qiniu = require("qiniu");
2
2
 
3
3
  var qiniuPrefix = require("../lib/prefix")
4
4
 
5
- let { getQiniuConfig, getDir } = require('../lib/config')
5
+ let { getQiniuConfig, getDir, getCachePath } = require('../lib/config')
6
6
  let { getJsonData } = require('../lib/files')
7
7
  const { createIgnoreMatcher } = require('../lib/ignore');
8
8
  const {
@@ -16,6 +16,8 @@ const {
16
16
  toRemoteKey,
17
17
  } = require('../lib/deploy');
18
18
  const { printMessage } = require('../lib/output');
19
+ const { computeFileMd5, readCache, writeCache, isCacheHit, updateCacheEntry } = require('../lib/cache');
20
+ const { createProgressBar } = require('../lib/progress');
19
21
 
20
22
  async function main(options, runtime = {}) {
21
23
  const qiniuConfig = getJsonData(getQiniuConfig(options))
@@ -25,20 +27,39 @@ async function main(options, runtime = {}) {
25
27
  const ignoreMatcher = createIgnoreMatcher(dir, options);
26
28
  const excludedPatterns = runtime.excludePatterns || ignoreMatcher.patterns;
27
29
  const files = runtime.files || await collectDeployFiles(dir, options);
28
- const plans = files.map((localFile) => {
30
+
31
+ const useCache = !options.noCache;
32
+ const cachePath = getCachePath(options);
33
+ const cache = useCache ? readCache(cachePath) : {};
34
+
35
+ const plans = [];
36
+ const skippedPlans = [];
37
+
38
+ for (const localFile of files) {
29
39
  const key = toRemoteKey(prefix, dir, localFile);
30
- return {
40
+ const plan = {
31
41
  localFile,
32
42
  key,
33
43
  target: `${qiniuConfig.domain || ''}${key}`,
34
44
  };
35
- });
45
+
46
+ if (useCache && !options.dryRun) {
47
+ const md5 = computeFileMd5(localFile);
48
+ plan.localMd5 = md5;
49
+ if (isCacheHit(cache, key, md5)) {
50
+ skippedPlans.push(plan);
51
+ continue;
52
+ }
53
+ }
54
+
55
+ plans.push(plan);
56
+ }
36
57
 
37
58
  if (options.dryRun) {
38
59
  if (!runtime.suppressOutput) {
39
- logPlan('upload', plans, options);
60
+ logPlan('upload', [...plans, ...skippedPlans], options);
40
61
  }
41
- const summary = createSummary('upload', true, plans.map((plan) => ({
62
+ const summary = createSummary('upload', true, [...plans, ...skippedPlans].map((plan) => ({
42
63
  ok: true,
43
64
  localFile: plan.localFile,
44
65
  key: plan.key,
@@ -47,6 +68,7 @@ async function main(options, runtime = {}) {
47
68
  prefix,
48
69
  concurrency: normalizeConcurrency(options.concurrency),
49
70
  excludedPatterns,
71
+ skippedCount: 0,
50
72
  });
51
73
  return runtime.suppressOutput ? summary : finalizeOutput(summary, options, runtime.manifestExtra);
52
74
  }
@@ -102,11 +124,22 @@ async function main(options, runtime = {}) {
102
124
  });
103
125
  }
104
126
 
127
+ const totalFiles = plans.length + skippedPlans.length;
128
+ const bar = createProgressBar(totalFiles, {
129
+ json: options.json,
130
+ suppressOutput: runtime.suppressOutput,
131
+ });
132
+
133
+ for (const plan of skippedPlans) {
134
+ bar.tick({ filename: plan.localFile, skipped: true });
135
+ }
136
+
105
137
  const results = await runWithConcurrency(plans, normalizeConcurrency(options.concurrency), async (plan) => {
106
138
  try {
107
139
  const response = await upload(plan);
108
- if (!runtime.suppressOutput) {
109
- printMessage(options, `[uploaded] ${plan.localFile} -> ${plan.target}`);
140
+ bar.tick({ filename: plan.localFile });
141
+ if (useCache && plan.localMd5) {
142
+ updateCacheEntry(cache, plan.key, plan.localMd5, response.hash);
110
143
  }
111
144
  return {
112
145
  ok: true,
@@ -116,6 +149,7 @@ async function main(options, runtime = {}) {
116
149
  hash: response.hash,
117
150
  };
118
151
  } catch (error) {
152
+ bar.tick({ filename: plan.localFile, failed: true });
119
153
  return {
120
154
  ok: false,
121
155
  localFile: plan.localFile,
@@ -126,10 +160,27 @@ async function main(options, runtime = {}) {
126
160
  }
127
161
  });
128
162
 
129
- const summary = createSummary('upload', false, results, startedAt, {
163
+ bar.finish();
164
+
165
+ if (useCache) {
166
+ writeCache(cachePath, cache);
167
+ }
168
+
169
+ const skippedResults = skippedPlans.map((plan) => ({
170
+ ok: true,
171
+ skipped: true,
172
+ localFile: plan.localFile,
173
+ key: plan.key,
174
+ target: plan.target,
175
+ }));
176
+
177
+ const allResults = [...results, ...skippedResults];
178
+
179
+ const summary = createSummary('upload', false, allResults, startedAt, {
130
180
  prefix,
131
181
  concurrency: normalizeConcurrency(options.concurrency),
132
182
  excludedPatterns,
183
+ skippedCount: skippedPlans.length,
133
184
  });
134
185
  const finalSummary = runtime.suppressOutput ? summary : finalizeOutput(summary, options, runtime.manifestExtra);
135
186
  if (finalSummary.failedCount > 0) {
package/index.js CHANGED
@@ -60,7 +60,7 @@ program.configureHelp({
60
60
  },
61
61
  })
62
62
 
63
- program.name("struggler-cli").description(locale.appDescription).version(packageJson.version, "-v, --version", locale.options.version).helpOption("-h, --help", locale.options.help).option("-c, --config <path>", locale.options.config, "./command/qiniu.json").option("-d, --dir <path>", locale.options.dir, "./dist").option("--dry-run", locale.options.dryRun).option("--concurrency <number>", locale.options.concurrency, "5").option("--exclude <pattern>", locale.options.exclude).option("--ignore-file <path>", locale.options.ignoreFile, ".strugglerignore").option("--manifest <path>", locale.options.manifest).option("--json", locale.options.json).option("--skip-init", locale.options.skipInit).option("--skip-refresh", locale.options.skipRefresh).option("--lang <lang>", locale.options.lang, lang)
63
+ program.name("struggler-cli").description(locale.appDescription).version(packageJson.version, "-v, --version", locale.options.version).helpOption("-h, --help", locale.options.help).option("-c, --config <path>", locale.options.config, "./command/qiniu.json").option("-d, --dir <path>", locale.options.dir, "./dist").option("--dry-run", locale.options.dryRun).option("--concurrency <number>", locale.options.concurrency, "5").option("--exclude <pattern>", locale.options.exclude).option("--ignore-file <path>", locale.options.ignoreFile, ".strugglerignore").option("--manifest <path>", locale.options.manifest).option("--json", locale.options.json).option("--skip-init", locale.options.skipInit).option("--skip-refresh", locale.options.skipRefresh).option("--no-cache", locale.options.noCache).option("--lang <lang>", locale.options.lang, lang)
64
64
 
65
65
  program
66
66
  .command("init")
package/lib/cache.js ADDED
@@ -0,0 +1,37 @@
1
+ const fs = require('fs');
2
+ const crypto = require('crypto');
3
+ const { getJsonData, setSyncJsonData } = require('./files');
4
+
5
+ function computeFileMd5(filePath) {
6
+ const content = fs.readFileSync(filePath);
7
+ return crypto.createHash('md5').update(content).digest('hex');
8
+ }
9
+
10
+ function readCache(cachePath) {
11
+ return getJsonData(cachePath);
12
+ }
13
+
14
+ function writeCache(cachePath, cache) {
15
+ setSyncJsonData(cachePath, cache);
16
+ }
17
+
18
+ function isCacheHit(cache, relativeKey, localMd5) {
19
+ const entry = cache[relativeKey];
20
+ return entry && entry.localMd5 === localMd5;
21
+ }
22
+
23
+ function updateCacheEntry(cache, relativeKey, localMd5, qiniuHash) {
24
+ cache[relativeKey] = {
25
+ localMd5,
26
+ qiniuHash,
27
+ uploadedAt: new Date().toISOString(),
28
+ };
29
+ }
30
+
31
+ module.exports = {
32
+ computeFileMd5,
33
+ readCache,
34
+ writeCache,
35
+ isCacheHit,
36
+ updateCacheEntry,
37
+ };
package/lib/config.js CHANGED
@@ -2,6 +2,7 @@ let path = require('path')
2
2
 
3
3
  const DEFAULT_QINIU_CONFIG = './command/qiniu.json';
4
4
  const DEFAULT_CONFIG = './command/config.json';
5
+ const DEFAULT_CACHE = './command/upload-cache.json';
5
6
 
6
7
  function resolveFromCwd(targetPath) {
7
8
  return path.resolve(process.cwd(), targetPath);
@@ -20,6 +21,15 @@ function getConfigPath(options = {}) {
20
21
  return path.join(path.dirname(qiniuConfigPath), 'config.json');
21
22
  }
22
23
 
24
+ function getCachePath(options = {}) {
25
+ if (!options.config) {
26
+ return resolveFromCwd(DEFAULT_CACHE);
27
+ }
28
+
29
+ const qiniuConfigPath = getQiniuConfigPath(options);
30
+ return path.join(path.dirname(qiniuConfigPath), 'upload-cache.json');
31
+ }
32
+
23
33
  module.exports = {
24
34
  getConfig: (options) => {
25
35
  return getConfigPath(options)
@@ -27,6 +37,9 @@ module.exports = {
27
37
  getQiniuConfig: (options) => {
28
38
  return getQiniuConfigPath(options)
29
39
  },
40
+ getCachePath: (options) => {
41
+ return getCachePath(options)
42
+ },
30
43
  getDir: (options) => {
31
44
  return resolveFromCwd(options.dir || './dist')
32
45
  },
package/lib/deploy.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const chalk = require('chalk');
2
3
  const { listFiles } = require('./files');
3
4
  const { createIgnoreMatcher } = require('./ignore');
4
5
  const { printMessage, writeManifest, shouldUseJson, printJson } = require('./output');
@@ -70,13 +71,65 @@ function logPlan(action, items, options) {
70
71
  });
71
72
  }
72
73
 
74
+ function formatDuration(ms) {
75
+ if (ms < 1000) return `${ms}ms`;
76
+ return `${(ms / 1000).toFixed(1)}s`;
77
+ }
78
+
73
79
  function logSummary(summary, options) {
74
- const mode = summary.dryRun ? 'dry-run' : 'live';
75
- printMessage(options, `[summary] ${summary.action} mode=${mode} total=${summary.total} succeeded=${summary.succeededCount} failed=${summary.failedCount} duration=${summary.durationMs}ms`);
80
+ const allOk = summary.failedCount === 0;
81
+ const isDryRun = summary.dryRun;
82
+ const skipped = summary.skippedCount || 0;
83
+ const uploaded = summary.succeededCount - skipped;
84
+
85
+ const statusIcon = allOk ? chalk.green('✓') : chalk.red('✗');
86
+ const actionLabel = summary.action === 'upload' ? '上传' : summary.action === 'refresh' ? '刷新' : summary.action;
87
+ const modeTag = isDryRun ? chalk.yellow(' [dry-run]') : '';
88
+
89
+ printMessage(options, '');
90
+ printMessage(options, ` ${statusIcon} ${chalk.bold(actionLabel + '完成')}${modeTag} ${chalk.dim(formatDuration(summary.durationMs))}`);
91
+ printMessage(options, '');
92
+
93
+ const rows = [];
94
+ if (summary.action === 'upload') {
95
+ rows.push([chalk.dim('上传'), chalk.green(`${uploaded} 个文件`)]);
96
+ if (skipped > 0) {
97
+ rows.push([chalk.dim('跳过'), chalk.cyan(`${skipped} 个文件`) + chalk.dim(' (缓存命中,无变更)')]);
98
+ }
99
+ if (summary.failedCount > 0) {
100
+ rows.push([chalk.dim('失败'), chalk.red(`${summary.failedCount} 个文件`)]);
101
+ }
102
+ rows.push([chalk.dim('合计'), chalk.white(`${summary.total} 个文件`)]);
103
+ } else {
104
+ rows.push([chalk.dim('成功'), chalk.green(`${summary.succeededCount} 个`)]);
105
+ if (summary.failedCount > 0) {
106
+ rows.push([chalk.dim('失败'), chalk.red(`${summary.failedCount} 个`)]);
107
+ }
108
+ rows.push([chalk.dim('合计'), chalk.white(`${summary.total} 个`)]);
109
+ }
110
+
111
+ const labelWidth = 4;
112
+ rows.forEach(([label, value]) => {
113
+ printMessage(options, ` ${label.padEnd ? label : label} ${value}`);
114
+ });
115
+ printMessage(options, '');
116
+
76
117
  if (summary.failedCount > 0) {
77
118
  summary.failed.forEach((item) => {
78
- printMessage(options, `[failed] ${item.localFile}: ${item.error}`);
119
+ printMessage(options, ` ${chalk.red('✗')} ${chalk.dim(item.localFile)}`);
120
+ printMessage(options, ` ${chalk.red(item.error)}`);
121
+ });
122
+ printMessage(options, '');
123
+ }
124
+
125
+ const uploadedItems = (summary.succeeded || []).filter((item) => !item.skipped && item.target);
126
+ if (uploadedItems.length > 0) {
127
+ printMessage(options, ` ${chalk.dim('─── 已上传文件链接 ───')}`);
128
+ printMessage(options, '');
129
+ uploadedItems.forEach((item) => {
130
+ printMessage(options, ` ${chalk.cyan(item.target)}`);
79
131
  });
132
+ printMessage(options, '');
80
133
  }
81
134
  }
82
135
 
package/lib/i18n.js CHANGED
@@ -13,6 +13,7 @@ const LANGUAGES = {
13
13
  json: '输出机器可读的 JSON 结果。',
14
14
  skipInit: '在 deploy 时跳过 init 步骤。',
15
15
  skipRefresh: '在 deploy 时跳过 refresh 步骤。',
16
+ noCache: '禁用上传缓存,强制重新上传所有文件。',
16
17
  lang: '切换菜单语言,可选 zh / en。',
17
18
  help: '显示帮助信息。',
18
19
  },
@@ -51,6 +52,7 @@ const LANGUAGES = {
51
52
  json: 'Print machine-readable JSON output.',
52
53
  skipInit: 'Skip init during deploy.',
53
54
  skipRefresh: 'Skip refresh during deploy.',
55
+ noCache: 'Disable upload cache and force re-upload all files.',
54
56
  lang: 'Switch menu language, supported values: zh / en.',
55
57
  help: 'Display help information.',
56
58
  },
@@ -0,0 +1,82 @@
1
+ const chalk = require('chalk');
2
+
3
+ const BAR_WIDTH = 30;
4
+
5
+ function buildBar(completed, total) {
6
+ const ratio = total === 0 ? 1 : completed / total;
7
+ const filled = Math.round(BAR_WIDTH * ratio);
8
+ const empty = BAR_WIDTH - filled;
9
+ return chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
10
+ }
11
+
12
+ function truncateFilename(filename, maxLen) {
13
+ if (!filename || filename.length <= maxLen) {
14
+ return (filename || '').padEnd(maxLen);
15
+ }
16
+ return '...' + filename.slice(-(maxLen - 3));
17
+ }
18
+
19
+ function createProgressBar(total, options = {}) {
20
+ if (options.json || options.suppressOutput || total === 0) {
21
+ return { tick: () => {}, finish: () => {} };
22
+ }
23
+
24
+ let completed = 0;
25
+ let failed = 0;
26
+ let skipped = 0;
27
+ let lastLine = '';
28
+
29
+ const isTTY = process.stdout.isTTY;
30
+
31
+ function render(currentFile, status) {
32
+ const bar = buildBar(completed, total);
33
+ const pct = total === 0 ? 100 : Math.round((completed / total) * 100);
34
+ const cols = (process.stdout.columns || 80) - BAR_WIDTH - 30;
35
+ const nameWidth = Math.max(10, Math.min(40, cols));
36
+ const name = truncateFilename(currentFile, nameWidth);
37
+
38
+ let statusIcon;
39
+ if (status === 'skipped') {
40
+ statusIcon = chalk.cyan('↩');
41
+ } else if (status === 'failed') {
42
+ statusIcon = chalk.red('✗');
43
+ } else {
44
+ statusIcon = chalk.green('✓');
45
+ }
46
+
47
+ const countStr = chalk.white(`${completed}/${total}`);
48
+ const pctStr = chalk.yellow(`${pct}%`);
49
+ const line = ` ${bar} ${countStr} ${pctStr} ${statusIcon} ${chalk.dim(name)}`;
50
+
51
+ if (isTTY) {
52
+ process.stdout.write('\r' + line.padEnd(lastLine.length));
53
+ } else {
54
+ console.log(line);
55
+ }
56
+ lastLine = line;
57
+ }
58
+
59
+ function tick({ filename = '', skipped: isSkipped = false, failed: isFailed = false } = {}) {
60
+ completed += 1;
61
+ if (isSkipped) skipped += 1;
62
+ if (isFailed) failed += 1;
63
+ render(filename, isSkipped ? 'skipped' : isFailed ? 'failed' : 'uploaded');
64
+ }
65
+
66
+ function finish() {
67
+ if (!isTTY) return;
68
+ const bar = buildBar(total, total);
69
+ const parts = [
70
+ ` ${bar}`,
71
+ chalk.white(`${total}/${total}`),
72
+ chalk.green('100%'),
73
+ ];
74
+ if (skipped > 0) parts.push(chalk.cyan(`${skipped} skipped`));
75
+ if (failed > 0) parts.push(chalk.red(`${failed} failed`));
76
+ process.stdout.write('\r' + parts.join(' ').padEnd(lastLine.length) + '\n');
77
+ }
78
+
79
+ return { tick, finish };
80
+ }
81
+
82
+ module.exports = { createProgressBar };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@struggler/cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "CLI to Upload vite packaged files to Qiniu Cloud OSS.",
5
5
  "main": "index.js",
6
6
  "scripts": {