@struggler/cli 1.0.3 → 1.0.6

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/lib/deploy.js ADDED
@@ -0,0 +1,132 @@
1
+ const path = require('path');
2
+ const { listFiles } = require('./files');
3
+ const { createIgnoreMatcher } = require('./ignore');
4
+ const { printMessage, writeManifest, shouldUseJson, printJson } = require('./output');
5
+
6
+ const DEFAULT_CONCURRENCY = 5;
7
+
8
+ function normalizeConcurrency(value) {
9
+ const parsed = parseInt(value, 10);
10
+ if (Number.isNaN(parsed) || parsed < 1) {
11
+ return DEFAULT_CONCURRENCY;
12
+ }
13
+
14
+ return parsed;
15
+ }
16
+
17
+ function toRemoteKey(prefix, rootDir, localFile) {
18
+ return `${prefix}${path.relative(rootDir, localFile)}`.replace(/\\/g, '/');
19
+ }
20
+
21
+ async function collectDeployFiles(dir, options = {}) {
22
+ const files = await listFiles(dir);
23
+ const ignoreMatcher = createIgnoreMatcher(dir, options);
24
+ return files
25
+ .filter((filePath) => !filePath.endsWith('.gz'))
26
+ .filter((filePath) => !ignoreMatcher.shouldIgnore(filePath))
27
+ .sort((left, right) => left.localeCompare(right));
28
+ }
29
+
30
+ async function runWithConcurrency(items, concurrency, worker) {
31
+ const normalizedConcurrency = Math.min(normalizeConcurrency(concurrency), Math.max(items.length, 1));
32
+ const results = new Array(items.length);
33
+ let nextIndex = 0;
34
+
35
+ async function consume() {
36
+ while (nextIndex < items.length) {
37
+ const currentIndex = nextIndex;
38
+ nextIndex += 1;
39
+ results[currentIndex] = await worker(items[currentIndex], currentIndex);
40
+ }
41
+ }
42
+
43
+ const tasks = Array.from({ length: normalizedConcurrency }, () => consume());
44
+ await Promise.all(tasks);
45
+ return results;
46
+ }
47
+
48
+ function createSummary(action, dryRun, results, startedAt, extra = {}) {
49
+ const succeeded = results.filter((item) => item.ok);
50
+ const failed = results.filter((item) => !item.ok);
51
+
52
+ return {
53
+ action,
54
+ dryRun,
55
+ total: results.length,
56
+ succeededCount: succeeded.length,
57
+ failedCount: failed.length,
58
+ durationMs: Date.now() - startedAt,
59
+ succeeded,
60
+ failed,
61
+ results,
62
+ ...extra,
63
+ };
64
+ }
65
+
66
+ function logPlan(action, items, options) {
67
+ printMessage(options, `[dry-run] ${action} plan (${items.length} files)`);
68
+ items.forEach((item) => {
69
+ printMessage(options, `- ${item.localFile} -> ${item.target}`);
70
+ });
71
+ }
72
+
73
+ 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`);
76
+ if (summary.failedCount > 0) {
77
+ summary.failed.forEach((item) => {
78
+ printMessage(options, `[failed] ${item.localFile}: ${item.error}`);
79
+ });
80
+ }
81
+ }
82
+
83
+ function maybeWriteManifest(summary, options, extra = {}) {
84
+ if (!options.manifest) {
85
+ return summary;
86
+ }
87
+
88
+ const manifestPath = writeManifest(options.manifest, {
89
+ ...summary,
90
+ ...extra,
91
+ });
92
+
93
+ return {
94
+ ...summary,
95
+ manifestPath,
96
+ };
97
+ }
98
+
99
+ function finalizeOutput(summary, options, extra = {}) {
100
+ const finalSummary = maybeWriteManifest(summary, options, extra);
101
+ if (shouldUseJson(options)) {
102
+ printJson(finalSummary);
103
+ } else {
104
+ logSummary(finalSummary, options);
105
+ if (finalSummary.manifestPath) {
106
+ printMessage(options, `[manifest] ${finalSummary.manifestPath}`);
107
+ }
108
+ }
109
+
110
+ return finalSummary;
111
+ }
112
+
113
+ function ensureRequiredConfig(config, requiredFields) {
114
+ const missingFields = requiredFields.filter((field) => !config[field]);
115
+ if (missingFields.length > 0) {
116
+ throw new Error(`Missing required config: ${missingFields.join(', ')}`);
117
+ }
118
+ }
119
+
120
+ module.exports = {
121
+ DEFAULT_CONCURRENCY,
122
+ normalizeConcurrency,
123
+ toRemoteKey,
124
+ collectDeployFiles,
125
+ runWithConcurrency,
126
+ createSummary,
127
+ logPlan,
128
+ logSummary,
129
+ maybeWriteManifest,
130
+ finalizeOutput,
131
+ ensureRequiredConfig,
132
+ };
package/lib/files.js CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const fsp = fs.promises;
6
+
7
+ async function walkFiles(filePath) {
8
+ const entries = await fsp.readdir(filePath, { withFileTypes: true });
9
+ const files = await Promise.all(entries.map(async (entry) => {
10
+ const absolutePath = path.join(filePath, entry.name);
11
+ if (entry.isDirectory()) {
12
+ return walkFiles(absolutePath);
13
+ }
14
+
15
+ return [absolutePath];
16
+ }));
17
+
18
+ return files.flat();
19
+ }
5
20
 
6
21
  module.exports = {
7
22
  // 获取目录名称
@@ -23,11 +38,10 @@ module.exports = {
23
38
 
24
39
  setJsonData: (filePath,data) => {
25
40
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
26
- fs.writeFile(
41
+ fs.writeFileSync(
27
42
  filePath,
28
43
  JSON.stringify(data, null, "\t"
29
- ),
30
- (err) => { }
44
+ )
31
45
  )
32
46
  },
33
47
  setSyncJsonData: (filePath, data) => {
@@ -36,5 +50,17 @@ module.exports = {
36
50
  filePath,
37
51
  JSON.stringify(data, null, "\t"),
38
52
  )
53
+ },
54
+ listFiles: async (filePath) => {
55
+ if (!fs.existsSync(filePath)) {
56
+ return [];
57
+ }
58
+
59
+ const stat = await fsp.stat(filePath);
60
+ if (!stat.isDirectory()) {
61
+ return [filePath];
62
+ }
63
+
64
+ return walkFiles(filePath);
39
65
  }
40
- };
66
+ };
package/lib/i18n.js ADDED
@@ -0,0 +1,191 @@
1
+ const LANGUAGES = {
2
+ zh: {
3
+ appDescription: '用于将前端打包产物上传到七牛云 OSS 的命令行工具。',
4
+ options: {
5
+ version: '显示版本号。',
6
+ config: '指定上传配置文件路径。',
7
+ dir: '指定要上传的目录。',
8
+ dryRun: '仅预览执行计划,不写文件也不调用七牛接口。',
9
+ concurrency: '设置上传并发数。',
10
+ exclude: '排除文件匹配规则,支持逗号分隔多个模式。',
11
+ ignoreFile: '指定自定义忽略文件。',
12
+ manifest: '将命令执行结果写入 manifest JSON 文件。',
13
+ json: '输出机器可读的 JSON 结果。',
14
+ skipInit: '在 deploy 时跳过 init 步骤。',
15
+ skipRefresh: '在 deploy 时跳过 refresh 步骤。',
16
+ noCache: '禁用上传缓存,强制重新上传所有文件。',
17
+ lang: '切换菜单语言,可选 zh / en。',
18
+ help: '显示帮助信息。',
19
+ },
20
+ commands: {
21
+ init: '为指定路径生成上传配置。',
22
+ upload: '将指定目录下文件上传到七牛云。',
23
+ refresh: '刷新七牛云 CDN 文件。',
24
+ deploy: '一次执行 init、upload 和 refresh。',
25
+ },
26
+ help: {
27
+ helpCommandDescription: '显示指定命令的帮助信息',
28
+ commandUsage: '用法:',
29
+ commandDescription: '说明:',
30
+ commandOptions: '参数:',
31
+ commandCommands: '命令:',
32
+ subcommandTerm: '命令',
33
+ optionTerm: '参数',
34
+ argumentTerm: '参数',
35
+ usage: '用法',
36
+ options: '参数',
37
+ commands: '命令',
38
+ arguments: '参数',
39
+ },
40
+ },
41
+ en: {
42
+ appDescription: 'CLI to upload front-end build files to Qiniu Cloud OSS.',
43
+ options: {
44
+ version: 'Display the version number.',
45
+ config: 'Specify the path to the upload configuration file.',
46
+ dir: 'Specify the directory to upload.',
47
+ dryRun: 'Preview actions without writing files or calling Qiniu APIs.',
48
+ concurrency: 'Set upload concurrency.',
49
+ exclude: 'Exclude file glob patterns, supports comma-separated values.',
50
+ ignoreFile: 'Specify a custom ignore file.',
51
+ manifest: 'Write the command summary to a manifest JSON file.',
52
+ json: 'Print machine-readable JSON output.',
53
+ skipInit: 'Skip init during deploy.',
54
+ skipRefresh: 'Skip refresh during deploy.',
55
+ noCache: 'Disable upload cache and force re-upload all files.',
56
+ lang: 'Switch menu language, supported values: zh / en.',
57
+ help: 'Display help information.',
58
+ },
59
+ commands: {
60
+ init: 'Create upload configuration for the specified path.',
61
+ upload: 'Upload files under the specified directory to Qiniu Cloud.',
62
+ refresh: 'Refresh Qiniu Cloud CDN files.',
63
+ deploy: 'Run init, upload, and refresh in one command.',
64
+ },
65
+ help: {
66
+ helpCommandDescription: 'display help for command',
67
+ commandUsage: 'Usage:',
68
+ commandDescription: 'Description:',
69
+ commandOptions: 'Options:',
70
+ commandCommands: 'Commands:',
71
+ subcommandTerm: 'command',
72
+ optionTerm: 'option',
73
+ argumentTerm: 'argument',
74
+ usage: 'Usage',
75
+ options: 'Options',
76
+ commands: 'Commands',
77
+ arguments: 'Arguments',
78
+ },
79
+ },
80
+ };
81
+
82
+ function normalizeLocaleTag(value) {
83
+ if (!value) {
84
+ return '';
85
+ }
86
+
87
+ return value.toLowerCase().replace('.', '_');
88
+ }
89
+
90
+ function detectLangFromLocale(localeValue) {
91
+ const normalized = normalizeLocaleTag(localeValue);
92
+ if (!normalized) {
93
+ return null;
94
+ }
95
+
96
+ if (normalized.startsWith('en')) {
97
+ return 'en';
98
+ }
99
+
100
+ if (normalized.startsWith('zh')) {
101
+ return 'zh';
102
+ }
103
+
104
+ return null;
105
+ }
106
+
107
+ function detectLangFromVSLang(value) {
108
+ if (!value) {
109
+ return null;
110
+ }
111
+
112
+ const normalized = String(value).trim();
113
+ const englishCodes = new Set(['1033', '2057', '4105', '5129', '6153']);
114
+ const chineseCodes = new Set(['2052', '1028', '3076', '4100']);
115
+
116
+ if (englishCodes.has(normalized)) {
117
+ return 'en';
118
+ }
119
+
120
+ if (chineseCodes.has(normalized)) {
121
+ return 'zh';
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ function detectLangFromWindowsValue(value) {
128
+ return detectLangFromLocale(value);
129
+ }
130
+
131
+ function detectSystemLang(env = process.env) {
132
+ const localeCandidates = [
133
+ env.LC_ALL,
134
+ env.LC_MESSAGES,
135
+ env.LANG,
136
+ env.PreferredUILanguages,
137
+ env.UILANG,
138
+ env.Culture,
139
+ env.UserLanguage,
140
+ ];
141
+
142
+ for (const candidate of localeCandidates) {
143
+ const detected = detectLangFromLocale(candidate);
144
+ if (detected) {
145
+ return detected;
146
+ }
147
+ }
148
+
149
+ const windowsSpecificCandidates = [
150
+ env.VSLANG,
151
+ env.PREFERRED_UI_LANGUAGES,
152
+ env.UI_LANG,
153
+ ];
154
+
155
+ for (const candidate of windowsSpecificCandidates) {
156
+ const detected = detectLangFromVSLang(candidate) || detectLangFromWindowsValue(candidate);
157
+ if (detected) {
158
+ return detected;
159
+ }
160
+ }
161
+
162
+ return 'zh';
163
+ }
164
+
165
+ function resolveLang(argv) {
166
+ const langFlagIndex = argv.findIndex((item) => item === '--lang');
167
+ if (langFlagIndex >= 0 && argv[langFlagIndex + 1] && LANGUAGES[argv[langFlagIndex + 1]]) {
168
+ return argv[langFlagIndex + 1];
169
+ }
170
+
171
+ const inlineFlag = argv.find((item) => item.startsWith('--lang='));
172
+ if (inlineFlag) {
173
+ const [, value] = inlineFlag.split('=');
174
+ if (LANGUAGES[value]) {
175
+ return value;
176
+ }
177
+ }
178
+
179
+ return detectSystemLang();
180
+ }
181
+
182
+ function getLocale(lang) {
183
+ return LANGUAGES[lang] || LANGUAGES.zh;
184
+ }
185
+
186
+ module.exports = {
187
+ LANGUAGES,
188
+ detectSystemLang,
189
+ resolveLang,
190
+ getLocale,
191
+ };
package/lib/ignore.js ADDED
@@ -0,0 +1,69 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const DEFAULT_IGNORE_FILE = '.strugglerignore';
5
+
6
+ function escapeRegExp(value) {
7
+ return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
8
+ }
9
+
10
+ function patternToRegExp(pattern) {
11
+ const normalizedPattern = pattern.replace(/\\/g, '/');
12
+ const escaped = escapeRegExp(normalizedPattern)
13
+ .replace(/\\\*\\\*/g, '.*')
14
+ .replace(/\\\*/g, '[^/]*');
15
+ return new RegExp(`(^|/)${escaped}$`);
16
+ }
17
+
18
+ function parseIgnoreFile(ignoreFilePath) {
19
+ if (!fs.existsSync(ignoreFilePath)) {
20
+ return [];
21
+ }
22
+
23
+ return fs.readFileSync(ignoreFilePath, 'utf8')
24
+ .split(/\r?\n/)
25
+ .map((line) => line.trim())
26
+ .filter((line) => line && !line.startsWith('#'));
27
+ }
28
+
29
+ function normalizeExcludePatterns(value) {
30
+ if (!value) {
31
+ return [];
32
+ }
33
+
34
+ if (Array.isArray(value)) {
35
+ return value.flatMap((item) => normalizeExcludePatterns(item));
36
+ }
37
+
38
+ return String(value)
39
+ .split(',')
40
+ .map((item) => item.trim())
41
+ .filter(Boolean);
42
+ }
43
+
44
+ function createIgnoreMatcher(rootDir, options = {}) {
45
+ const ignoreFilePath = path.resolve(process.cwd(), options.ignoreFile || DEFAULT_IGNORE_FILE);
46
+ const patterns = [
47
+ ...parseIgnoreFile(ignoreFilePath),
48
+ ...normalizeExcludePatterns(options.exclude),
49
+ ];
50
+ const matchers = patterns.map((pattern) => patternToRegExp(pattern));
51
+
52
+ return {
53
+ patterns,
54
+ shouldIgnore(filePath) {
55
+ if (matchers.length === 0) {
56
+ return false;
57
+ }
58
+
59
+ const relativePath = path.relative(rootDir, filePath).replace(/\\/g, '/');
60
+ return matchers.some((matcher) => matcher.test(relativePath));
61
+ },
62
+ };
63
+ }
64
+
65
+ module.exports = {
66
+ DEFAULT_IGNORE_FILE,
67
+ normalizeExcludePatterns,
68
+ createIgnoreMatcher,
69
+ };
package/lib/output.js ADDED
@@ -0,0 +1,37 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function shouldUseJson(options = {}) {
5
+ return Boolean(options.json);
6
+ }
7
+
8
+ function printMessage(options, message) {
9
+ if (!shouldUseJson(options)) {
10
+ console.log(message);
11
+ }
12
+ }
13
+
14
+ function printError(options, message) {
15
+ if (!shouldUseJson(options)) {
16
+ console.error(message);
17
+ }
18
+ }
19
+
20
+ function printJson(payload) {
21
+ console.log(JSON.stringify(payload, null, 2));
22
+ }
23
+
24
+ function writeManifest(filePath, payload) {
25
+ const resolvedPath = path.resolve(process.cwd(), filePath);
26
+ fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
27
+ fs.writeFileSync(resolvedPath, JSON.stringify(payload, null, 2));
28
+ return resolvedPath;
29
+ }
30
+
31
+ module.exports = {
32
+ shouldUseJson,
33
+ printMessage,
34
+ printError,
35
+ printJson,
36
+ writeManifest,
37
+ };
package/lib/prefix.js CHANGED
@@ -1,24 +1,10 @@
1
1
  let { getConfig } = require('./config')
2
2
  let { getJsonData } = require('./files')
3
3
 
4
- //七牛文件上传前缀,使用时间戳作为文件上传前缀
5
- function formatDate(date) {
6
- date = date || new Date()
7
- const year = date.getFullYear()
8
- const month = date.getMonth() + 1
9
- const day = date.getDate()
10
- const hours = date.getHours()
11
- const minutes = date.getMinutes()
12
-
13
- return `${year}${month < 10 ? `0${month}` : month}${day < 10 ? `0${day}` : day}${hours < 10 ? `0${hours}` : hours}${minutes < 10 ? `0${minutes}` : minutes}`
14
- }
15
-
16
-
17
-
18
4
  module.exports = {
19
5
  prefix: (options)=>{
20
6
  const { publicPath } = getJsonData(getConfig(options))
21
- return publicPath
7
+ return publicPath || ''
22
8
  }
23
9
 
24
- }
10
+ }
package/package.json CHANGED
@@ -1,28 +1,29 @@
1
1
  {
2
- "name": "@struggler/cli",
3
- "version": "1.0.3",
4
- "description": "CLI to Upload vite packaged files to Qiniu Cloud OSS.",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "bin": {
10
- "struggler-cli": "./index.js"
11
- },
12
- "keywords": [
13
- "Qiniu",
14
- "OSS"
15
- ],
16
- "author": "moqi(str@li.cm)",
17
- "license": "ISC",
18
- "dependencies": {
19
- "chalk": "^v4.1.2",
20
- "clear": "^0.1.0",
21
- "commander": "^11.0.0",
22
- "figlet": "^1.6.0",
23
- "qiniu": "^7.8.0"
24
- },
25
- "publicConfig": {
26
- "registry": "http://registry.npmjs.org/"
27
- }
28
- }
2
+ "name": "@struggler/cli",
3
+ "version": "1.0.6",
4
+ "description": "CLI to Upload vite packaged files to Qiniu Cloud OSS.",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "add-version": "node ./command/addVersion.js",
8
+ "test": "node --test"
9
+ },
10
+ "bin": {
11
+ "struggler-cli": "./index.js"
12
+ },
13
+ "keywords": [
14
+ "Qiniu",
15
+ "OSS"
16
+ ],
17
+ "author": "moqi(str@li.cm)",
18
+ "license": "ISC",
19
+ "dependencies": {
20
+ "chalk": "^v4.1.2",
21
+ "clear": "^0.1.0",
22
+ "commander": "^11.0.0",
23
+ "figlet": "^1.6.0",
24
+ "qiniu": "^7.8.0"
25
+ },
26
+ "publicConfig": {
27
+ "registry": "http://registry.npmjs.org/"
28
+ }
29
+ }
@@ -0,0 +1,41 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { getConfig, getQiniuConfig, getDir } = require('../lib/config');
4
+ const { normalizeConcurrency, toRemoteKey } = require('../lib/deploy');
5
+ const { normalizeExcludePatterns } = require('../lib/ignore');
6
+
7
+ test('config helpers resolve qiniu and derived config paths together', () => {
8
+ const options = {
9
+ config: './test/command/qiniu.json',
10
+ dir: './test/dist3',
11
+ };
12
+
13
+ assert.equal(
14
+ getQiniuConfig(options),
15
+ `${process.cwd()}/test/command/qiniu.json`
16
+ );
17
+ assert.equal(
18
+ getConfig(options),
19
+ `${process.cwd()}/test/command/config.json`
20
+ );
21
+ assert.equal(
22
+ getDir(options),
23
+ `${process.cwd()}/test/dist3`
24
+ );
25
+ });
26
+
27
+ test('deploy helpers normalize concurrency and build remote keys', () => {
28
+ assert.equal(normalizeConcurrency('0'), 5);
29
+ assert.equal(normalizeConcurrency('3'), 3);
30
+ assert.equal(
31
+ toRemoteKey('release/20260313/', '/tmp/dist', '/tmp/dist/assets/app.js'),
32
+ 'release/20260313/assets/app.js'
33
+ );
34
+ });
35
+
36
+ test('ignore helpers split exclude patterns consistently', () => {
37
+ assert.deepEqual(
38
+ normalizeExcludePatterns('*.map,.DS_Store, dist/**'),
39
+ ['*.map', '.DS_Store', 'dist/**']
40
+ );
41
+ });
@@ -0,0 +1,52 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const init = require('../command/init');
7
+
8
+ function createTempWorkspace() {
9
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'struggler-cli-init-'));
10
+ }
11
+
12
+ test('init dry-run does not create files and returns generated config', async () => {
13
+ const previousCwd = process.cwd();
14
+ const workspace = createTempWorkspace();
15
+
16
+ try {
17
+ process.chdir(workspace);
18
+
19
+ const config = await init({
20
+ config: './command/qiniu.json',
21
+ dryRun: true,
22
+ });
23
+
24
+ assert.equal(fs.existsSync(path.join(workspace, 'command/qiniu.json')), false);
25
+ assert.match(config.publicPath, /^\/\d{12}\/$/);
26
+ assert.match(config.base, /^\/\d{12}\/$/);
27
+ } finally {
28
+ process.chdir(previousCwd);
29
+ fs.rmSync(workspace, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test('init writes qiniu template and deploy config when not in dry-run mode', async () => {
34
+ const previousCwd = process.cwd();
35
+ const workspace = createTempWorkspace();
36
+
37
+ try {
38
+ process.chdir(workspace);
39
+
40
+ const config = await init({
41
+ config: './command/qiniu.json',
42
+ });
43
+
44
+ assert.equal(fs.existsSync(path.join(workspace, 'command/qiniu.json')), true);
45
+ assert.equal(fs.existsSync(path.join(workspace, 'command/config.json')), true);
46
+ assert.equal(config.publicPath.endsWith('/'), true);
47
+ assert.equal(config.base.endsWith('/'), true);
48
+ } finally {
49
+ process.chdir(previousCwd);
50
+ fs.rmSync(workspace, { recursive: true, force: true });
51
+ }
52
+ });
package/test/test.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  struggler-cli upload -d ./dist3
2
2
 
3
- struggler-cli addVersion
3
+ pnpm add-version
4
4
 
5
- struggler-cli init
5
+ struggler-cli init