@struggler/cli 1.0.11 → 1.0.13

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/Makefile CHANGED
@@ -55,9 +55,14 @@ link:
55
55
  $(PNPM) setup; \
56
56
  fi
57
57
  @$(PNPM) link --global
58
+ @hash -r
59
+ @which struggler-cli || true
60
+ @struggler-cli -v || true
58
61
 
59
62
  unlink:
60
- @$(PNPM) unlink --global @struggler/cli
63
+ @$(PNPM) unlink --global @struggler/cli || true
64
+ @npm uninstall -g @struggler/cli || true
65
+ @hash -r
61
66
 
62
67
  init:
63
68
  $(NODE) $(CLI_ENTRY) --config $(CONFIG) --dir $(DIR) $(DRY_RUN) $(JSON) init
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # struggler-cli
2
2
 
3
- `struggler-cli` is a small deployment CLI for front-end build assets on Qiniu Cloud. It can generate versioned paths, upload build output, refresh CDN URLs, and now supports `dry-run`, concurrent uploads, ignore rules, manifest export, JSON output, and a one-shot `deploy` command.
3
+ `struggler-cli` is a small deployment CLI for front-end build assets on Qiniu Cloud. It supports versioned paths, upload, CDN refresh, upload cache, profiles for multiple Qiniu accounts, `dry-run`, concurrency, ignore rules, manifest export, JSON output, and a one-shot `deploy` command.
4
+
5
+ Works on **macOS, Linux, and Windows** (Node.js `path` / `fs` APIs are cross-platform).
4
6
 
5
7
  ## Install
6
8
 
@@ -14,105 +16,182 @@ For local command usage:
14
16
  pnpm link --global
15
17
  ```
16
18
 
17
- ## Config Files
19
+ ## First-time setup (recommended: profiles)
20
+
21
+ Use **profiles** to store Qiniu credentials under your user home: `~/.struggler-cli/` (Windows: `%USERPROFILE%\\.struggler-cli\\`). This folder is created automatically; it is **not** shipped with the npm package.
18
22
 
19
- The CLI expects two files in the same directory:
23
+ ```text
24
+ ~/.struggler-cli/ # created on first profile add/import (Windows/macOS/Linux)
25
+ profiles/
26
+ prod.json # Qiniu credentials for production
27
+ staging.json
28
+ current # one line: active profile name, e.g. prod
20
29
 
21
- - `qiniu.json`: Qiniu credentials and bucket metadata
22
- - `config.json`: generated deploy prefix metadata used by upload/refresh
30
+ your-project/
31
+ command/
32
+ config.json # deploy prefix (publicPath / base), from init
33
+ upload-cache.json # upload cache (optional)
34
+ dist/ # build output to upload (-d)
35
+ ```
23
36
 
24
- Example `qiniu.json`:
37
+ ### Step 1 — Create a profile
25
38
 
26
- ```json
27
- {
28
- "path": "your-project",
29
- "accessKey": "",
30
- "secretKey": "",
31
- "Bucket": "",
32
- "zone": "Zone_z1",
33
- "domain": "https://cdn.example.com/"
34
- }
39
+ From your project root:
40
+
41
+ ```bash
42
+ struggler-cli profile add prod
35
43
  ```
36
44
 
37
- Optional ignore file:
45
+ This creates `~/.struggler-cli/profiles/prod.json` from the built-in template. Open it and fill in:
38
46
 
39
- ```text
40
- .strugglerignore
47
+ | Field | Description |
48
+ |-------|-------------|
49
+ | `path` | CDN path prefix segment for this app |
50
+ | `accessKey` | Qiniu access key |
51
+ | `secretKey` | Qiniu secret key |
52
+ | `Bucket` | Bucket name |
53
+ | `zone` | e.g. `Zone_z0`, `Zone_z1`, `Zone_z2` |
54
+ | `domain` | CDN domain, e.g. `https://cdn.example.com/` |
55
+
56
+ ### Step 2 — Activate the profile
57
+
58
+ ```bash
59
+ struggler-cli profile use prod
41
60
  ```
42
61
 
43
- Example:
62
+ Or pass the profile name per command: `-c prod`.
44
63
 
45
- ```text
46
- .DS_Store
47
- *.map
48
- legacy/**
64
+ ### Step 3 — Generate deploy metadata
65
+
66
+ ```bash
67
+ struggler-cli init -d ./dist
49
68
  ```
50
69
 
51
- ## Commands
70
+ Writes `command/config.json` with a timestamped `publicPath` and `base` URL (always under `./command/`, same as older versions).
52
71
 
53
- Initialize versioned config:
72
+ ### Step 4 — Upload
54
73
 
55
74
  ```bash
56
- node ./index.js --config ./command/qiniu.json --dir ./dist init
75
+ struggler-cli upload -d ./dist
57
76
  ```
58
77
 
59
- Preview upload work without changing files or calling Qiniu:
78
+ ### More environments
60
79
 
61
80
  ```bash
62
- node ./index.js --config ./command/qiniu.json --dir ./dist --dry-run upload
81
+ struggler-cli profile add staging
82
+ # edit ~/.struggler-cli/profiles/staging.json
83
+
84
+ struggler-cli profile import dev ./path/to/existing-qiniu.json
85
+
86
+ struggler-cli profile list
87
+ struggler-cli profile current
88
+ struggler-cli profile use staging
89
+ struggler-cli init
90
+ struggler-cli upload -d ./dist
63
91
  ```
64
92
 
65
- Upload with concurrency:
93
+ Because profiles are user-level, they are outside your project repo by default.
94
+
95
+ ## Legacy setup (single `command/qiniu.json`)
96
+
97
+ Still supported for existing projects:
98
+
99
+ ```text
100
+ command/
101
+ qiniu.json
102
+ config.json
103
+ ```
66
104
 
67
105
  ```bash
68
- node ./index.js --config ./command/qiniu.json --dir ./dist --concurrency 8 upload
106
+ struggler-cli init -c ./command/qiniu.json -d ./dist
107
+ struggler-cli upload -c ./command/qiniu.json -d ./dist
69
108
  ```
70
109
 
71
- Upload with ignore patterns and a manifest:
110
+ If no profile is active and you omit `-c`, the CLI defaults to `./command/qiniu.json`.
111
+
112
+ ## How `-c` resolves config
113
+
114
+ | You pass | Resolved to |
115
+ |----------|-------------|
116
+ | *(omit)* | Active profile in `~/.struggler-cli/current`, else `./command/qiniu.json` |
117
+ | `-c prod` | `~/.struggler-cli/profiles/prod.json` |
118
+ | `-c ./command/qiniu.json` | That file path (legacy) |
119
+
120
+ `config.json` and upload cache stay in `./command/` unless you pass `--config-dir`.
121
+
122
+ ## Profile commands
72
123
 
73
124
  ```bash
74
- node ./index.js --config ./command/qiniu.json --dir ./dist --exclude ".DS_Store,*.map" --manifest ./artifacts/upload-manifest.json upload
125
+ struggler-cli profile list
126
+ struggler-cli profile use <name>
127
+ struggler-cli profile current
128
+ struggler-cli profile add <name>
129
+ struggler-cli profile import <name> <file>
130
+ ```
131
+
132
+ ## Other config files
133
+
134
+ Optional ignore file:
135
+
136
+ ```text
137
+ .strugglerignore
138
+ ```
139
+
140
+ Example:
141
+
142
+ ```text
143
+ .DS_Store
144
+ *.map
145
+ legacy/**
75
146
  ```
76
147
 
77
- Refresh CDN for generated URLs:
148
+ ## Commands
149
+
150
+ Initialize versioned config:
78
151
 
79
152
  ```bash
80
- node ./index.js --config ./command/qiniu.json --dir ./dist refresh
153
+ struggler-cli init -d ./dist
81
154
  ```
82
155
 
83
- Run the full flow:
156
+ Preview upload:
84
157
 
85
158
  ```bash
86
- node ./index.js --config ./command/qiniu.json --dir ./dist --concurrency 8 deploy
159
+ struggler-cli --dry-run upload -d ./dist
87
160
  ```
88
161
 
89
- Skip parts of deploy:
162
+ Upload with concurrency:
90
163
 
91
164
  ```bash
92
- node ./index.js --config ./command/qiniu.json --dir ./dist --skip-refresh deploy
165
+ struggler-cli upload -d ./dist --concurrency 8
93
166
  ```
94
167
 
95
- Machine-readable output:
168
+ Full deploy:
96
169
 
97
170
  ```bash
98
- node ./index.js --config ./command/qiniu.json --dir ./dist --json --dry-run deploy
171
+ struggler-cli deploy -d ./dist --concurrency 8
99
172
  ```
100
173
 
101
- Version bump script:
174
+ Machine-readable output:
102
175
 
103
176
  ```bash
104
- pnpm add-version
177
+ struggler-cli deploy -d ./dist --json --dry-run
105
178
  ```
106
179
 
107
- ## Makefile Shortcuts
180
+ ## Windows notes
181
+
182
+ - The folder name `.struggler-cli` is valid on Windows; Node resolves paths with `\` automatically.
183
+ - User-level profile root on Windows is `%USERPROFILE%\\.struggler-cli\\`.
184
+ - In **Cmd** or **PowerShell**, run the same commands as on Unix.
185
+ - Avoid spaces in profile names; use `prod`, `staging`, `dev`.
186
+ - When scripting, quote paths: `--config-dir "./command"`.
187
+
188
+ ## Makefile shortcuts
108
189
 
109
190
  ```bash
110
191
  make init
111
192
  make upload
112
193
  make refresh
113
194
  make deploy
114
- make upload DIR=./test/dist3 CONFIG=./test/command/qiniu.json DRY_RUN=--dry-run
115
- make deploy MANIFEST=./artifacts/deploy.json JSON=--json SKIP_REFRESH=--skip-refresh
116
195
  ```
117
196
 
118
197
  ## Test
@@ -121,4 +200,4 @@ make deploy MANIFEST=./artifacts/deploy.json JSON=--json SKIP_REFRESH=--skip-ref
121
200
  pnpm test
122
201
  ```
123
202
 
124
- The automated tests cover config path resolution, init dry-run behavior, upload ignore handling, manifest generation, and deploy JSON summaries.
203
+ Tests cover profile resolution, config paths, init, upload, and deploy.
package/command/index.js CHANGED
@@ -2,8 +2,10 @@ var init = require('./init');
2
2
  var upload = require('./upload');
3
3
  var refresh = require('./refresh');
4
4
  var deploy = require('./deploy');
5
+ var profile = require('./profile');
5
6
 
6
7
  exports.init = init;
7
8
  exports.upload = upload;
8
9
  exports.refresh = refresh;
9
10
  exports.deploy = deploy;
11
+ exports.profile = profile;
@@ -0,0 +1,84 @@
1
+ const path = require('path');
2
+ const chalk = require('chalk');
3
+ const {
4
+ listProfiles,
5
+ readCurrentProfile,
6
+ writeCurrentProfile,
7
+ importProfile,
8
+ addProfile,
9
+ getProfilePath,
10
+ getProfilesDir,
11
+ } = require('../lib/profile');
12
+ const { printMessage } = require('../lib/output');
13
+ const { getLocale } = require('../lib/i18n');
14
+
15
+ const TEMPLATE_PATH = path.resolve(__dirname, '../def/qiniu.json');
16
+
17
+ function listAction(options) {
18
+ const { messages } = getLocale(options.lang);
19
+ const profiles = listProfiles();
20
+ const current = readCurrentProfile();
21
+
22
+ if (profiles.length === 0) {
23
+ printMessage(options, chalk.yellow(` ${messages.profileListEmpty}`));
24
+ printMessage(options, chalk.dim(` ${getProfilesDir()}`));
25
+ return { profiles: [], current: null };
26
+ }
27
+
28
+ printMessage(options, messages.profileListTitle);
29
+ profiles.forEach((name) => {
30
+ const marker = name === current ? chalk.green(' *') : ' ';
31
+ const file = getProfilePath(name);
32
+ printMessage(options, `${marker} ${name}${chalk.dim(` → ${file}`)}`);
33
+ });
34
+ if (current) {
35
+ printMessage(options, chalk.dim(messages.profileListCurrentHint));
36
+ }
37
+ return { profiles, current };
38
+ }
39
+
40
+ function useAction(name, options) {
41
+ const { messages } = getLocale(options.lang);
42
+ const profilePath = writeCurrentProfile(name);
43
+ printMessage(options, chalk.green(` ✓ ${messages.profileUseDone(name)}`));
44
+ printMessage(options, chalk.dim(` ${profilePath}`));
45
+ return { name, path: profilePath };
46
+ }
47
+
48
+ function currentAction(options) {
49
+ const { messages } = getLocale(options.lang);
50
+ const current = readCurrentProfile();
51
+ if (!current) {
52
+ printMessage(options, chalk.yellow(` ${messages.profileNoCurrent}`));
53
+ return { current: null };
54
+ }
55
+ const profilePath = getProfilePath(current);
56
+ printMessage(options, `${messages.profileCurrentLabel} ${chalk.cyan(current)}`);
57
+ printMessage(options, chalk.dim(` ${profilePath}`));
58
+ return { current, path: profilePath };
59
+ }
60
+
61
+ function addAction(name, options) {
62
+ const { messages } = getLocale(options.lang);
63
+ const profilePath = addProfile(name, TEMPLATE_PATH);
64
+ printMessage(options, chalk.green(` ✓ ${messages.profileAddDone(name)}`));
65
+ printMessage(options, chalk.dim(` ${profilePath}`));
66
+ printMessage(options, chalk.dim(messages.profileEditHint));
67
+ return { name, path: profilePath };
68
+ }
69
+
70
+ function importAction(name, fromPath, options) {
71
+ const { messages } = getLocale(options.lang);
72
+ const profilePath = importProfile(name, fromPath);
73
+ printMessage(options, chalk.green(` ✓ ${messages.profileImportDone(name)}`));
74
+ printMessage(options, chalk.dim(` ${profilePath}`));
75
+ return { name, path: profilePath, from: path.resolve(fromPath) };
76
+ }
77
+
78
+ module.exports = {
79
+ list: listAction,
80
+ use: useAction,
81
+ current: currentAction,
82
+ add: addAction,
83
+ import: importAction,
84
+ };
package/index.js CHANGED
@@ -58,7 +58,7 @@ program.configureHelp({
58
58
  },
59
59
  })
60
60
 
61
- 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("--config-dir <path>", locale.options.configDir).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)
61
+ 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).option("--config-dir [path]", locale.options.configDir).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)
62
62
 
63
63
  program
64
64
  .command("init")
@@ -88,6 +88,43 @@ program
88
88
  await command.deploy(program.opts())
89
89
  })
90
90
 
91
+ const profileCmd = program.command("profile").description(locale.commands.profile)
92
+
93
+ profileCmd
94
+ .command("list")
95
+ .description(locale.commands.profileList)
96
+ .action(() => {
97
+ command.profile.list(program.opts())
98
+ })
99
+
100
+ profileCmd
101
+ .command("use <name>")
102
+ .description(locale.commands.profileUse)
103
+ .action((name) => {
104
+ command.profile.use(name, program.opts())
105
+ })
106
+
107
+ profileCmd
108
+ .command("current")
109
+ .description(locale.commands.profileCurrent)
110
+ .action(() => {
111
+ command.profile.current(program.opts())
112
+ })
113
+
114
+ profileCmd
115
+ .command("add <name>")
116
+ .description(locale.commands.profileAdd)
117
+ .action((name) => {
118
+ command.profile.add(name, program.opts())
119
+ })
120
+
121
+ profileCmd
122
+ .command("import <name> <file>")
123
+ .description(locale.commands.profileImport)
124
+ .action((name, file) => {
125
+ command.profile.import(name, file, program.opts())
126
+ })
127
+
91
128
  program.addHelpCommand(true, locale.help.helpCommandDescription)
92
129
 
93
130
  program.parseAsync().catch(error => {
package/lib/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  let path = require('path')
2
2
  const { getJsonData } = require('./files');
3
+ const { resolveProfileConfigPath } = require('./profile');
3
4
 
4
5
  const DEFAULT_QINIU_CONFIG = './command/qiniu.json';
5
6
  const DEFAULT_META_DIR = './command';
@@ -11,26 +12,37 @@ function resolveFromCwd(targetPath) {
11
12
  }
12
13
 
13
14
  function getQiniuConfigPath(options = {}) {
15
+ const profilePath = resolveProfileConfigPath(options);
16
+ if (profilePath) {
17
+ return profilePath;
18
+ }
14
19
  return resolveFromCwd(options.config || DEFAULT_QINIU_CONFIG);
15
20
  }
16
21
 
17
- /** config.json / upload-cache.json 所在目录;优先级:CLI --config-dir > qiniu.json configDir > 与 qiniu 同目录 > ./command */
22
+ function resolveConfigDirOption(options = {}) {
23
+ const value = options.configDir;
24
+ if (value === true || value === '') {
25
+ return DEFAULT_META_DIR;
26
+ }
27
+ if (typeof value === 'string') {
28
+ return value;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** config.json / upload-cache.json 所在目录;优先级:CLI --config-dir > qiniu.json configDir > 默认 ./command(与旧版一致,不随 -c 目录变化) */
18
34
  function getMetaDir(options = {}) {
19
- if (options.configDir) {
20
- return resolveFromCwd(options.configDir);
35
+ const cliConfigDir = resolveConfigDirOption(options);
36
+ if (cliConfigDir) {
37
+ return resolveFromCwd(cliConfigDir);
21
38
  }
22
39
 
23
- const qiniuConfigPath = getQiniuConfigPath(options);
24
- const qiniuConfig = getJsonData(qiniuConfigPath);
40
+ const qiniuConfig = getJsonData(getQiniuConfigPath(options));
25
41
  if (qiniuConfig.configDir) {
26
42
  return resolveFromCwd(qiniuConfig.configDir);
27
43
  }
28
44
 
29
- if (!options.config) {
30
- return resolveFromCwd(DEFAULT_META_DIR);
31
- }
32
-
33
- return path.dirname(qiniuConfigPath);
45
+ return resolveFromCwd(DEFAULT_META_DIR);
34
46
  }
35
47
 
36
48
  function getConfigPath(options = {}) {
package/lib/i18n.js CHANGED
@@ -30,15 +30,24 @@ const LANGUAGES = {
30
30
  deployUnit: '个',
31
31
  errorTitle: '出错了',
32
32
  errorMissingConfig: '配置不完整,以下字段缺失或为空:',
33
- errorMissingConfigHint: '请检查 command/qiniu.json command/config.json,或运行 struggler-cli init 初始化配置。',
33
+ errorMissingConfigHint: '请检查七牛配置(~/.struggler-cli/profiles,Windows 为 %USERPROFILE%\\.struggler-cli\\profiles)或 command/qiniu.json,以及 command/config.json;也可先运行 struggler-cli init',
34
+ profileListEmpty: '尚无 profile,请先执行:struggler-cli profile add <name>',
35
+ profileListTitle: '可用 profile:',
36
+ profileListCurrentHint: '(* 为当前激活)',
37
+ profileUseDone: (name) => `已切换 profile:${name}`,
38
+ profileNoCurrent: '未设置当前 profile。可执行:struggler-cli profile use <name>',
39
+ profileCurrentLabel: '当前 profile:',
40
+ profileAddDone: (name) => `已创建 profile:${name}`,
41
+ profileImportDone: (name) => `已导入 profile:${name}`,
42
+ profileEditHint: '请编辑该文件,填写 accessKey、secretKey、Bucket、zone、domain 等字段。',
34
43
  dryRunLabel: '预览',
35
44
  dryRunPlan: (action, n) => `[预览] ${action} 计划 (${n} 个文件)`,
36
45
  manifestWritten: '清单已写入:',
37
46
  },
38
47
  options: {
39
48
  version: '显示版本号。',
40
- config: '指定上传配置文件路径。',
41
- configDir: '指定 config.json、upload-cache.json 所在目录(默认与 -c 同目录,未传 -c 时为 ./command)。',
49
+ config: '七牛配置:profile 名(如 prod)、文件路径(如 ./command/qiniu.json);不传则优先用 ~/.struggler-cli/current(Windows 为 %USERPROFILE%\\.struggler-cli\\current),否则回退 ./command/qiniu.json。',
50
+ configDir: '指定 config.json、upload-cache.json 所在目录;不传时固定为 ./command(与旧版一致);可写 --config-dir ./command',
42
51
  dir: '指定要上传的目录。',
43
52
  dryRun: '仅预览执行计划,不写文件也不调用七牛接口。',
44
53
  concurrency: '设置上传并发数。',
@@ -57,6 +66,12 @@ const LANGUAGES = {
57
66
  upload: '将指定目录下文件上传到七牛云。',
58
67
  refresh: '刷新七牛云 CDN 文件。',
59
68
  deploy: '一次执行 init、upload 和 refresh。',
69
+ profile: '管理多套七牛配置(profile)。',
70
+ profileList: '列出所有 profile',
71
+ profileUse: '切换当前 profile',
72
+ profileCurrent: '显示当前 profile',
73
+ profileAdd: '从模版新建 profile',
74
+ profileImport: '从已有文件导入 profile',
60
75
  },
61
76
  help: {
62
77
  helpCommandDescription: '显示指定命令的帮助信息',
@@ -104,15 +119,24 @@ const LANGUAGES = {
104
119
  deployUnit: '',
105
120
  errorTitle: 'Error',
106
121
  errorMissingConfig: 'Incomplete config, the following fields are missing or empty:',
107
- errorMissingConfigHint: 'Check command/qiniu.json and command/config.json, or run struggler-cli init.',
122
+ errorMissingConfigHint: 'Check Qiniu config (~/.struggler-cli/profiles, Windows: %USERPROFILE%\\.struggler-cli\\profiles) or command/qiniu.json, plus command/config.json; you can also run struggler-cli init first.',
123
+ profileListEmpty: 'No profiles yet. Run: struggler-cli profile add <name>',
124
+ profileListTitle: 'Profiles:',
125
+ profileListCurrentHint: '(* = active)',
126
+ profileUseDone: (name) => `Active profile: ${name}`,
127
+ profileNoCurrent: 'No active profile. Run: struggler-cli profile use <name>',
128
+ profileCurrentLabel: 'Active profile:',
129
+ profileAddDone: (name) => `Created profile: ${name}`,
130
+ profileImportDone: (name) => `Imported profile: ${name}`,
131
+ profileEditHint: 'Edit the file and fill accessKey, secretKey, Bucket, zone, domain.',
108
132
  dryRunLabel: 'dry-run',
109
133
  dryRunPlan: (action, n) => `[dry-run] ${action} plan (${n} files)`,
110
134
  manifestWritten: 'Manifest written:',
111
135
  },
112
136
  options: {
113
137
  version: 'Display the version number.',
114
- config: 'Specify the path to the upload configuration file.',
115
- configDir: 'Directory for config.json and upload-cache.json (default: same dir as -c, or ./command).',
138
+ config: 'Qiniu config: profile name (e.g. prod) or file path (e.g. ./command/qiniu.json); when omitted, use ~/.struggler-cli/current first (Windows: %USERPROFILE%\\.struggler-cli\\current), then fallback to ./command/qiniu.json.',
139
+ configDir: 'Directory for config.json and upload-cache.json; defaults to ./command when omitted (legacy behavior).',
116
140
  dir: 'Specify the directory to upload.',
117
141
  dryRun: 'Preview actions without writing files or calling Qiniu APIs.',
118
142
  concurrency: 'Set upload concurrency.',
@@ -131,6 +155,12 @@ const LANGUAGES = {
131
155
  upload: 'Upload files under the specified directory to Qiniu Cloud.',
132
156
  refresh: 'Refresh Qiniu Cloud CDN files.',
133
157
  deploy: 'Run init, upload, and refresh in one command.',
158
+ profile: 'Manage multiple Qiniu configs (profiles).',
159
+ profileList: 'List profiles',
160
+ profileUse: 'Set active profile',
161
+ profileCurrent: 'Show active profile',
162
+ profileAdd: 'Create profile from template',
163
+ profileImport: 'Import profile from file',
134
164
  },
135
165
  help: {
136
166
  helpCommandDescription: 'display help for command',
package/lib/profile.js ADDED
@@ -0,0 +1,148 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const { setSyncJsonData, getJsonData } = require('./files');
5
+
6
+ const PROFILE_DIR_NAME = '.struggler-cli';
7
+ const PROFILES_SUBDIR = 'profiles';
8
+ const CURRENT_FILE = 'current';
9
+ const PROFILE_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
10
+
11
+ function getStoreRoot() {
12
+ const customHome = process.env.STRUGGLER_CLI_HOME;
13
+ if (customHome && customHome.trim()) {
14
+ return path.join(path.resolve(customHome), PROFILE_DIR_NAME);
15
+ }
16
+ return path.join(os.homedir(), PROFILE_DIR_NAME);
17
+ }
18
+
19
+ function getProfilesDir() {
20
+ return path.join(getStoreRoot(), PROFILES_SUBDIR);
21
+ }
22
+
23
+ function getCurrentFile() {
24
+ return path.join(getStoreRoot(), CURRENT_FILE);
25
+ }
26
+
27
+ function assertProfileName(name) {
28
+ if (!PROFILE_NAME_RE.test(name)) {
29
+ throw new Error(
30
+ `Invalid profile name "${name}". Use letters, numbers, _ or -, and start with a letter.`
31
+ );
32
+ }
33
+ }
34
+
35
+ function getProfilePath(name) {
36
+ assertProfileName(name);
37
+ return path.join(getProfilesDir(), `${name}.json`);
38
+ }
39
+
40
+ function ensureProfilesDir() {
41
+ fs.mkdirSync(getProfilesDir(), { recursive: true });
42
+ }
43
+
44
+ function readCurrentProfile() {
45
+ const currentFile = getCurrentFile();
46
+ if (!fs.existsSync(currentFile)) {
47
+ return null;
48
+ }
49
+ const name = fs.readFileSync(currentFile, 'utf8').trim();
50
+ return name || null;
51
+ }
52
+
53
+ function writeCurrentProfile(name) {
54
+ assertProfileName(name);
55
+ const profilePath = getProfilePath(name);
56
+ if (!fs.existsSync(profilePath)) {
57
+ throw new Error(`Profile "${name}" does not exist. Run: struggler-cli profile list`);
58
+ }
59
+ fs.mkdirSync(getStoreRoot(), { recursive: true });
60
+ fs.writeFileSync(getCurrentFile(), `${name}\n`, 'utf8');
61
+ return profilePath;
62
+ }
63
+
64
+ function listProfiles() {
65
+ const dir = getProfilesDir();
66
+ if (!fs.existsSync(dir)) {
67
+ return [];
68
+ }
69
+ return fs.readdirSync(dir)
70
+ .filter((file) => file.endsWith('.json'))
71
+ .map((file) => path.basename(file, '.json'))
72
+ .sort();
73
+ }
74
+
75
+ function isFileConfigPath(configValue, cwd = process.cwd()) {
76
+ if (!configValue) {
77
+ return false;
78
+ }
79
+ if (configValue.includes('/') || configValue.includes('\\')) {
80
+ return true;
81
+ }
82
+ const resolved = path.resolve(cwd, configValue);
83
+ return fs.existsSync(resolved) && fs.statSync(resolved).isFile();
84
+ }
85
+
86
+ function resolveProfileConfigPath(options = {}, cwd = process.cwd()) {
87
+ const configValue = options.config;
88
+
89
+ if (!configValue) {
90
+ const current = readCurrentProfile();
91
+ if (current) {
92
+ return getProfilePath(current);
93
+ }
94
+ return null;
95
+ }
96
+
97
+ if (isFileConfigPath(configValue, cwd)) {
98
+ return path.resolve(cwd, configValue);
99
+ }
100
+
101
+ assertProfileName(configValue);
102
+ const profilePath = getProfilePath(configValue);
103
+ if (!fs.existsSync(profilePath)) {
104
+ throw new Error(
105
+ `Profile "${configValue}" not found at ${profilePath}. Run: struggler-cli profile list`
106
+ );
107
+ }
108
+ return profilePath;
109
+ }
110
+
111
+ function importProfile(name, fromPath, cwd = process.cwd()) {
112
+ assertProfileName(name);
113
+ const source = path.resolve(cwd, fromPath);
114
+ if (!fs.existsSync(source)) {
115
+ throw new Error(`Config file not found: ${source}`);
116
+ }
117
+ ensureProfilesDir();
118
+ const target = getProfilePath(name);
119
+ fs.copyFileSync(source, target);
120
+ return target;
121
+ }
122
+
123
+ function addProfile(name, templatePath) {
124
+ assertProfileName(name);
125
+ ensureProfilesDir();
126
+ const target = getProfilePath(name);
127
+ if (fs.existsSync(target)) {
128
+ throw new Error(`Profile "${name}" already exists.`);
129
+ }
130
+ setSyncJsonData(target, getJsonData(templatePath));
131
+ return target;
132
+ }
133
+
134
+ module.exports = {
135
+ PROFILE_DIR_NAME,
136
+ getStoreRoot,
137
+ getProfilesDir,
138
+ getProfilePath,
139
+ getCurrentFile,
140
+ assertProfileName,
141
+ readCurrentProfile,
142
+ writeCurrentProfile,
143
+ listProfiles,
144
+ isFileConfigPath,
145
+ resolveProfileConfigPath,
146
+ importProfile,
147
+ addProfile,
148
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@struggler/cli",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "CLI to Upload vite packaged files to Qiniu Cloud OSS.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -16,7 +16,7 @@ test('config helpers resolve qiniu and derived config paths together', () => {
16
16
  );
17
17
  assert.equal(
18
18
  getConfig(options),
19
- `${process.cwd()}/test/command/config.json`
19
+ `${process.cwd()}/command/config.json`
20
20
  );
21
21
  assert.equal(
22
22
  getDir(options),
@@ -36,6 +36,29 @@ test('config-dir overrides default meta directory', () => {
36
36
  );
37
37
  });
38
38
 
39
+ test('config-dir flag without path defaults to ./command', () => {
40
+ const options = {
41
+ config: './test/command/qiniu.json',
42
+ configDir: true,
43
+ };
44
+
45
+ assert.equal(
46
+ getConfig(options),
47
+ `${process.cwd()}/command/config.json`
48
+ );
49
+ });
50
+
51
+ test('custom -c without config-dir keeps meta files in ./command', () => {
52
+ const options = {
53
+ config: './def/qiniu.json',
54
+ };
55
+
56
+ assert.equal(
57
+ getConfig(options),
58
+ `${process.cwd()}/command/config.json`
59
+ );
60
+ });
61
+
39
62
  test('deploy helpers normalize concurrency and build remote keys', () => {
40
63
  assert.equal(normalizeConcurrency('0'), 5);
41
64
  assert.equal(normalizeConcurrency('3'), 3);
@@ -0,0 +1,129 @@
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 {
7
+ addProfile,
8
+ importProfile,
9
+ writeCurrentProfile,
10
+ readCurrentProfile,
11
+ listProfiles,
12
+ resolveProfileConfigPath,
13
+ isFileConfigPath,
14
+ } = require('../lib/profile');
15
+ const { getQiniuConfig } = require('../lib/config');
16
+
17
+ function makeWorkspace() {
18
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'struggler-cli-profile-'));
19
+ return fs.realpathSync(dir);
20
+ }
21
+
22
+ function withTempProfileHome(workspace, run) {
23
+ const prevHome = process.env.STRUGGLER_CLI_HOME;
24
+ process.env.STRUGGLER_CLI_HOME = path.join(workspace, '.home');
25
+ try {
26
+ return run();
27
+ } finally {
28
+ if (prevHome === undefined) {
29
+ delete process.env.STRUGGLER_CLI_HOME;
30
+ } else {
31
+ process.env.STRUGGLER_CLI_HOME = prevHome;
32
+ }
33
+ }
34
+ }
35
+
36
+ function samePath(a, b) {
37
+ const norm = (p) => {
38
+ try {
39
+ return fs.realpathSync(p);
40
+ } catch {
41
+ const dir = path.dirname(p);
42
+ const base = path.basename(p);
43
+ try {
44
+ return path.join(fs.realpathSync(dir), base);
45
+ } catch {
46
+ return path.resolve(p);
47
+ }
48
+ }
49
+ };
50
+ return norm(a) === norm(b);
51
+ }
52
+
53
+ test('profile paths work on any platform', () => {
54
+ const workspace = makeWorkspace();
55
+ const prev = process.cwd();
56
+ process.chdir(workspace);
57
+ try {
58
+ withTempProfileHome(workspace, () => {
59
+ addProfile('prod', path.resolve(__dirname, '../def/qiniu.json'));
60
+ const profileFile = path.join(workspace, '.home', '.struggler-cli', 'profiles', 'prod.json');
61
+ assert.equal(fs.existsSync(profileFile), true);
62
+ assert.match(profileFile, /profiles[\\/]prod\.json$/);
63
+ });
64
+ } finally {
65
+ process.chdir(prev);
66
+ }
67
+ });
68
+
69
+ test('resolve current profile when -c is omitted', () => {
70
+ const workspace = makeWorkspace();
71
+ const prev = process.cwd();
72
+ process.chdir(workspace);
73
+ try {
74
+ withTempProfileHome(workspace, () => {
75
+ addProfile('staging', path.resolve(__dirname, '../def/qiniu.json'));
76
+ writeCurrentProfile('staging');
77
+ const resolved = resolveProfileConfigPath({});
78
+ const expected = path.join(workspace, '.home', '.struggler-cli', 'profiles', 'staging.json');
79
+ assert.equal(samePath(resolved, expected), true);
80
+ });
81
+ } finally {
82
+ process.chdir(prev);
83
+ }
84
+ });
85
+
86
+ test('-c prod resolves profile name; -c with path resolves file', () => {
87
+ const workspace = makeWorkspace();
88
+ const prev = process.cwd();
89
+ process.chdir(workspace);
90
+ try {
91
+ withTempProfileHome(workspace, () => {
92
+ importProfile('prod', path.resolve(__dirname, '../def/qiniu.json'));
93
+ const byName = resolveProfileConfigPath({ config: 'prod' });
94
+ assert.equal(
95
+ samePath(byName, path.join(workspace, '.home', '.struggler-cli', 'profiles', 'prod.json')),
96
+ true
97
+ );
98
+
99
+ const legacy = path.join(workspace, 'command', 'qiniu.json');
100
+ fs.mkdirSync(path.dirname(legacy), { recursive: true });
101
+ fs.copyFileSync(path.resolve(__dirname, '../def/qiniu.json'), legacy);
102
+ assert.equal(isFileConfigPath('./command/qiniu.json'), true);
103
+ assert.equal(
104
+ samePath(resolveProfileConfigPath({ config: './command/qiniu.json' }), legacy),
105
+ true
106
+ );
107
+ });
108
+ } finally {
109
+ process.chdir(prev);
110
+ }
111
+ });
112
+
113
+ test('getQiniuConfig falls back to command/qiniu.json without profile', () => {
114
+ const workspace = makeWorkspace();
115
+ const prev = process.cwd();
116
+ process.chdir(workspace);
117
+ try {
118
+ withTempProfileHome(workspace, () => {
119
+ assert.equal(listProfiles().length, 0);
120
+ assert.equal(readCurrentProfile(), null);
121
+ assert.equal(
122
+ samePath(getQiniuConfig({}), path.join(workspace, 'command', 'qiniu.json')),
123
+ true
124
+ );
125
+ });
126
+ } finally {
127
+ process.chdir(prev);
128
+ }
129
+ });