@infly/libs 2.0.40 → 2.0.43

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.
@@ -0,0 +1,194 @@
1
+ const { execFileSync } = require("node:child_process");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+
5
+ const {
6
+ EXIT_SELECTION,
7
+ selectOption: defaultSelectOption,
8
+ } = require("./select-option");
9
+
10
+ function loadConfig(cwd = process.cwd()) {
11
+ const packagePath = path.resolve(cwd, "package.json");
12
+ if (!fs.existsSync(packagePath)) {
13
+ throw new Error(`Project package not found: ${packagePath}.`);
14
+ }
15
+ delete require.cache[require.resolve(packagePath)];
16
+ const inflyConfig = require(packagePath).infly;
17
+ if (!inflyConfig?.dev && !inflyConfig?.build) {
18
+ throw new Error(`Project config is missing in ${packagePath}. Add infly.dev or infly.build.`);
19
+ }
20
+ return {
21
+ config: {
22
+ targets: inflyConfig.targets,
23
+ dev: inflyConfig.dev,
24
+ build: inflyConfig.build,
25
+ },
26
+ configDir: path.dirname(packagePath),
27
+ };
28
+ }
29
+
30
+ function normalizeTargets(targets) {
31
+ if (Array.isArray(targets)) {
32
+ return Object.fromEntries(targets.map((target) => [target, {}]));
33
+ }
34
+ if (targets && typeof targets === "object") return targets;
35
+ throw new Error("Project targets must be a non-empty array or object.");
36
+ }
37
+
38
+ function normalizeEnvironments(environments) {
39
+ if (Array.isArray(environments)) {
40
+ return Object.fromEntries(environments.map((entry) => {
41
+ const separatorIndex = entry.indexOf(":");
42
+ const environment = separatorIndex < 0 ? entry : entry.slice(0, separatorIndex);
43
+ const mode = separatorIndex < 0 ? entry : entry.slice(separatorIndex + 1);
44
+ if (!environment || !mode) {
45
+ throw new Error(`Invalid build environment mapping: ${entry}`);
46
+ }
47
+ return [environment, { mode, args: ["--mode", mode] }];
48
+ }));
49
+ }
50
+ if (environments && typeof environments === "object") return environments;
51
+ throw new Error("Build environments must be an array or object.");
52
+ }
53
+
54
+ function resolveBuildPlatforms(targets, buildConfig, targetName) {
55
+ const normalizedTargets = normalizeTargets(targets);
56
+ if (normalizedTargets[targetName]) return [targetName];
57
+
58
+ const preset = buildConfig.presets?.[targetName];
59
+ if (!preset) throw new Error(`Unknown build target: ${targetName}`);
60
+
61
+ return preset.targets.map((name) => {
62
+ if (!normalizedTargets[name]) {
63
+ throw new Error(`Unknown build target in preset ${targetName}: ${name}`);
64
+ }
65
+ return name;
66
+ });
67
+ }
68
+
69
+ function commandParts(command) {
70
+ const parts = typeof command === "string"
71
+ ? command.trim().split(/\s+/)
72
+ : command;
73
+ if (!Array.isArray(parts) || parts.length === 0 || !parts[0]) {
74
+ throw new Error("Command configuration must be a non-empty string or array.");
75
+ }
76
+ return { command: parts[0], args: parts.slice(1) };
77
+ }
78
+
79
+ function defaultRunCommand(command, args, options) {
80
+ execFileSync(command, args, {
81
+ cwd: options.cwd,
82
+ env: options.env,
83
+ stdio: "inherit",
84
+ });
85
+ }
86
+
87
+ function printDryRun(command, args, env) {
88
+ const platform = env.VUE_APP_PLATFORM ? `VUE_APP_PLATFORM=${env.VUE_APP_PLATFORM} ` : "";
89
+ console.log(`[dry-run] ${platform}${command} ${args.join(" ")}`.trim());
90
+ }
91
+
92
+ async function choose(selectOption, message, entries) {
93
+ return selectOption(
94
+ message,
95
+ entries.map(([value, item]) => ({ value, label: item.label || value })),
96
+ );
97
+ }
98
+
99
+ async function runProjectCommand(options = {}, dependencies = {}) {
100
+ if (typeof options.config === "string") {
101
+ throw new Error("--config is not supported for project commands; use package.json infly config.");
102
+ }
103
+
104
+ const configObject = options.configObject
105
+ || (options.config && typeof options.config === "object" ? options.config : undefined);
106
+ const loaded = configObject
107
+ ? { config: configObject, configDir: options.configDir }
108
+ : loadConfig(options.cwd || process.cwd());
109
+ const config = loaded.config;
110
+ const configDir = options.configDir || loaded.configDir || process.cwd();
111
+ if (!config) throw new Error("Project command config is required.");
112
+ const targets = normalizeTargets(config.targets);
113
+
114
+ const selectOption = dependencies.selectOption || defaultSelectOption;
115
+ const runCommand = dependencies.runCommand || defaultRunCommand;
116
+ const log = dependencies.log || console.log;
117
+ const exitIfSelected = (selection) => {
118
+ if (selection !== EXIT_SELECTION) return false;
119
+ log("已退出,未执行任何操作。");
120
+ return true;
121
+ };
122
+ const action = options.action || await selectOption("请选择操作", [
123
+ { value: "dev", label: "启动开发环境" },
124
+ { value: "build", label: "构建项目" },
125
+ ]);
126
+ if (exitIfSelected(action)) return { cancelled: true };
127
+
128
+ if (action === "dev") {
129
+ const devConfig = config.dev;
130
+ if (!devConfig) throw new Error("Dev configuration is missing.");
131
+ const targetName = options.target || await choose(
132
+ selectOption,
133
+ "请选择目标平台",
134
+ Object.entries(targets),
135
+ );
136
+ if (exitIfSelected(targetName)) return { cancelled: true };
137
+ const target = targets[targetName];
138
+ if (!target) throw new Error(`Unknown dev target: ${targetName}`);
139
+ const parts = commandParts(devConfig.command);
140
+ const env = { ...process.env, VUE_APP_PLATFORM: targetName };
141
+ if (options.dryRun) printDryRun(parts.command, parts.args, env);
142
+ else runCommand(parts.command, parts.args, { cwd: configDir, env });
143
+ return { action, target: targetName };
144
+ }
145
+
146
+ if (action !== "build") throw new Error(`Unknown project action: ${action}`);
147
+ const buildConfig = config.build;
148
+ if (!buildConfig) throw new Error("Build configuration is missing.");
149
+ const environments = normalizeEnvironments(buildConfig.environments);
150
+ const environment = options.env || await choose(
151
+ selectOption,
152
+ "请选择构建环境",
153
+ Object.entries(environments),
154
+ );
155
+ if (exitIfSelected(environment)) return { cancelled: true };
156
+ const environmentConfig = environments[environment];
157
+ if (!environmentConfig) throw new Error(`Unknown build environment: ${environment}`);
158
+ const targetEntries = [
159
+ ...Object.entries(targets),
160
+ ...Object.entries(buildConfig.presets || {}),
161
+ ];
162
+ const targetName = options.target || await choose(
163
+ selectOption,
164
+ "请选择目标平台",
165
+ targetEntries,
166
+ );
167
+ if (exitIfSelected(targetName)) return { cancelled: true };
168
+ const platforms = resolveBuildPlatforms(targets, buildConfig, targetName);
169
+
170
+ for (const platform of platforms) {
171
+ const env = { ...process.env, VUE_APP_PLATFORM: platform };
172
+ const lifecycleArgs = environmentConfig.mode ? ["--mode", environmentConfig.mode] : [];
173
+ const steps = [
174
+ { command: buildConfig.before, extraArgs: lifecycleArgs },
175
+ { command: buildConfig.command, extraArgs: environmentConfig.args || [] },
176
+ { command: buildConfig.after, extraArgs: lifecycleArgs },
177
+ ];
178
+
179
+ for (const step of steps) {
180
+ const parts = commandParts(step.command);
181
+ const args = [...parts.args, ...step.extraArgs];
182
+ if (options.dryRun) printDryRun(parts.command, args, env);
183
+ else runCommand(parts.command, args, { cwd: configDir, env });
184
+ }
185
+ }
186
+
187
+ return { action, environment, target: targetName, platforms };
188
+ }
189
+
190
+ module.exports = {
191
+ loadConfig,
192
+ resolveBuildPlatforms,
193
+ runProjectCommand,
194
+ };
@@ -0,0 +1,267 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const test = require("node:test");
6
+
7
+ const {
8
+ loadConfig,
9
+ resolveBuildPlatforms,
10
+ runProjectCommand,
11
+ } = require("./project-command");
12
+ const { EXIT_SELECTION } = require("./select-option");
13
+
14
+ const config = {
15
+ targets: {
16
+ SQ223: { label: "宿迁贰贰叁" },
17
+ QDXY: { label: "青岛信跃" },
18
+ XZYA: { label: "徐州沂埃平台" },
19
+ },
20
+ dev: {
21
+ command: ["pnpm", "exec", "vue-cli-service", "serve"],
22
+ },
23
+ build: {
24
+ before: ["infly-libs", "beforeBuild"],
25
+ command: ["pnpm", "exec", "vue-cli-service", "build"],
26
+ after: ["infly-libs", "afterBuild"],
27
+ environments: {
28
+ prod: { label: "正式环境", mode: "production", args: [] },
29
+ stage: { label: "测试环境", mode: "staging", args: ["--mode", "staging"] },
30
+ },
31
+ presets: {
32
+ all: { label: "批量构建", targets: ["SQ223", "QDXY", "XZYA"] },
33
+ },
34
+ },
35
+ };
36
+
37
+ test("resolveBuildPlatforms derives platforms from shared target keys", () => {
38
+ assert.deepEqual(resolveBuildPlatforms(config.targets, config.build, "SQ223"), ["SQ223"]);
39
+ assert.deepEqual(resolveBuildPlatforms(config.targets, config.build, "all"), [
40
+ "SQ223",
41
+ "QDXY",
42
+ "XZYA",
43
+ ]);
44
+ });
45
+
46
+ test("runProjectCommand accepts target arrays and string dev commands", async () => {
47
+ const calls = [];
48
+ const compactConfig = {
49
+ targets: ["QDXY"],
50
+ dev: { command: "pnpm exec vue-cli-service serve" },
51
+ };
52
+
53
+ await runProjectCommand(
54
+ { action: "dev", target: "QDXY", config: compactConfig, configDir: "C:/project" },
55
+ { runCommand: (command, args, options) => calls.push({ command, args, options }) },
56
+ );
57
+
58
+ assert.equal(calls[0].command, "pnpm");
59
+ assert.deepEqual(calls[0].args, ["exec", "vue-cli-service", "serve"]);
60
+ assert.equal(calls[0].options.env.VUE_APP_PLATFORM, "QDXY");
61
+ });
62
+
63
+ test("runProjectCommand accepts string build lifecycle commands", async () => {
64
+ const calls = [];
65
+ const compactConfig = {
66
+ targets: { SQ223: {} },
67
+ build: {
68
+ before: "infly-libs beforeBuild",
69
+ command: "pnpm exec vue-cli-service build",
70
+ after: "infly-libs afterBuild",
71
+ environments: ["stage:staging"],
72
+ },
73
+ };
74
+
75
+ await runProjectCommand(
76
+ { action: "build", target: "SQ223", env: "stage", config: compactConfig, configDir: "C:/project" },
77
+ { runCommand: (command, args, options) => calls.push({ command, args, options }) },
78
+ );
79
+
80
+ assert.deepEqual(
81
+ calls.map(({ command, args }) => ({ command, args })),
82
+ [
83
+ { command: "infly-libs", args: ["beforeBuild", "--mode", "staging"] },
84
+ { command: "pnpm", args: ["exec", "vue-cli-service", "build", "--mode", "staging"] },
85
+ { command: "infly-libs", args: ["afterBuild", "--mode", "staging"] },
86
+ ],
87
+ );
88
+ });
89
+
90
+ test("loadConfig reads project commands from package.json infly config", () => {
91
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "project-config-discovery-"));
92
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
93
+ name: "fixture",
94
+ infly: {
95
+ buildConfigs: { enabled: true },
96
+ targets: { QDXY: { label: "青岛信跃" } },
97
+ dev: { marker: "dev" },
98
+ build: { marker: "build" },
99
+ },
100
+ }));
101
+
102
+ try {
103
+ const loaded = loadConfig(dir);
104
+ assert.deepEqual(loaded.config, {
105
+ targets: { QDXY: { label: "青岛信跃" } },
106
+ dev: { marker: "dev" },
107
+ build: { marker: "build" },
108
+ });
109
+ assert.equal(loaded.configDir, dir);
110
+ } finally {
111
+ fs.rmSync(dir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test("runProjectCommand starts a selected dev target", async () => {
116
+ const calls = [];
117
+
118
+ await runProjectCommand(
119
+ { action: "dev", target: "QDXY", config, configDir: "C:/project" },
120
+ { runCommand: (command, args, options) => calls.push({ command, args, options }) },
121
+ );
122
+
123
+ assert.equal(calls.length, 1);
124
+ assert.equal(calls[0].command, "pnpm");
125
+ assert.deepEqual(calls[0].args, ["exec", "vue-cli-service", "serve"]);
126
+ assert.equal(calls[0].options.cwd, "C:/project");
127
+ assert.equal(calls[0].options.env.VUE_APP_PLATFORM, "QDXY");
128
+ });
129
+
130
+ test("runProjectCommand executes lifecycle for every selected build platform", async () => {
131
+ const calls = [];
132
+
133
+ await runProjectCommand(
134
+ { action: "build", target: "SQ223", env: "stage", config, configDir: "C:/project" },
135
+ { runCommand: (command, args, options) => calls.push({ command, args, options }) },
136
+ );
137
+
138
+ assert.deepEqual(
139
+ calls.map(({ command, args, options }) => ({
140
+ command,
141
+ args,
142
+ platform: options.env.VUE_APP_PLATFORM,
143
+ })),
144
+ [
145
+ { command: "infly-libs", args: ["beforeBuild", "--mode", "staging"], platform: "SQ223" },
146
+ {
147
+ command: "pnpm",
148
+ args: ["exec", "vue-cli-service", "build", "--mode", "staging"],
149
+ platform: "SQ223",
150
+ },
151
+ { command: "infly-libs", args: ["afterBuild", "--mode", "staging"], platform: "SQ223" },
152
+ ],
153
+ );
154
+ });
155
+
156
+ test("runProjectCommand prompts when action, environment, and target are omitted", async () => {
157
+ const answers = ["build", "stage", "QDXY"];
158
+ const prompts = [];
159
+ const calls = [];
160
+
161
+ await runProjectCommand(
162
+ { config, configDir: "C:/project", dryRun: true },
163
+ {
164
+ selectOption: async (message, choices) => {
165
+ prompts.push({ message, choices });
166
+ return answers.shift();
167
+ },
168
+ runCommand: (...args) => calls.push(args),
169
+ },
170
+ );
171
+
172
+ assert.deepEqual(prompts.map(({ message }) => message), [
173
+ "请选择操作",
174
+ "请选择构建环境",
175
+ "请选择目标平台",
176
+ ]);
177
+ assert.equal(calls.length, 0);
178
+ });
179
+
180
+ test("runProjectCommand exits cleanly when the user chooses exit", async () => {
181
+ const logs = [];
182
+ const result = await runProjectCommand(
183
+ { config, configDir: "C:/project" },
184
+ {
185
+ selectOption: async () => EXIT_SELECTION,
186
+ log: (message) => logs.push(message),
187
+ runCommand: () => assert.fail("no command should run after exit"),
188
+ },
189
+ );
190
+
191
+ assert.deepEqual(result, { cancelled: true });
192
+ assert.deepEqual(logs, ["已退出,未执行任何操作。"]);
193
+ });
194
+
195
+ test("postal project scripts keep defaults non-interactive and platform scripts interactive", () => {
196
+ const appDir = path.resolve(__dirname, "../../../apps/postal-benefits-platform");
197
+ const packageJson = require(path.join(appDir, "package.json"));
198
+ const scripts = packageJson.scripts;
199
+
200
+ assert.equal(scripts.dev, "vue-cli-service serve");
201
+ assert.equal(scripts["project"], "infly-libs project");
202
+ assert.equal(scripts["dev:platform"], "infly-libs project dev");
203
+ assert.equal(scripts["build:prod"], "infly-libs beforeBuild && vue-cli-service build && infly-libs afterBuild");
204
+ assert.equal(scripts["build:stage"], "infly-libs beforeBuild && vue-cli-service build --mode staging && infly-libs afterBuild");
205
+ assert.equal(scripts["build:prod:platform"], "infly-libs project build --env prod");
206
+ assert.equal(scripts["build:stage:platform"], "infly-libs project build --env stage");
207
+ });
208
+
209
+ test("postal package shares one target map across dev and build", () => {
210
+ const appDir = path.resolve(__dirname, "../../../apps/postal-benefits-platform");
211
+ const projectConfig = require(path.join(appDir, "package.json")).infly;
212
+
213
+ assert.equal(projectConfig.dev.targets, undefined);
214
+ assert.equal(projectConfig.build.targets, undefined);
215
+ assert.deepEqual(projectConfig.targets, [
216
+ "DEFAULT",
217
+ "MER",
218
+ "JXEE",
219
+ "JXEELEVEL",
220
+ "SQ223",
221
+ "SQ223LEVEL",
222
+ "QDXY",
223
+ "XZYA",
224
+ ]);
225
+ assert.deepEqual(projectConfig.build.environments, ["prod:production", "stage:staging"]);
226
+ assert.deepEqual(projectConfig.build.presets.all.targets, [
227
+ "SQ223",
228
+ "SQ223LEVEL",
229
+ "QDXY",
230
+ "XZYA",
231
+ ]);
232
+ assert.doesNotMatch(JSON.stringify(projectConfig), /益爱圈/);
233
+ });
234
+
235
+ test("project build requires an explicit environment outside a TTY", async () => {
236
+ await assert.rejects(
237
+ () => runProjectCommand({
238
+ action: "build",
239
+ target: "QDXY",
240
+ dryRun: true,
241
+ configObject: {
242
+ targets: { QDXY: { label: "青岛信跃" } },
243
+ build: {
244
+ before: ["infly-libs", "beforeBuild"],
245
+ command: ["pnpm", "build"],
246
+ after: ["infly-libs", "afterBuild"],
247
+ environments: { prod: { label: "正式环境", mode: "production", args: [] } },
248
+ },
249
+ },
250
+ configDir: ".",
251
+ }),
252
+ /interactive selection requires a TTY/,
253
+ );
254
+ });
255
+ test("runProjectCommand rejects project config file paths", async () => {
256
+ await assert.rejects(
257
+ () => runProjectCommand({ action: "dev", config: "legacy.config.js" }),
258
+ /--config is not supported for project commands/,
259
+ );
260
+ });
261
+
262
+ test("runProjectCommand rejects unknown targets before executing commands", async () => {
263
+ await assert.rejects(
264
+ () => runProjectCommand({ action: "dev", target: "missing", config, configDir: "." }),
265
+ /Unknown dev target: missing/,
266
+ );
267
+ });
@@ -0,0 +1,60 @@
1
+ const EXIT_SELECTION = "__exit__";
2
+
3
+ function isReadlineClosedError(error) {
4
+ return error?.code === "ERR_USE_AFTER_CLOSE";
5
+ }
6
+
7
+ function makePromptCancellationSafe(prompt) {
8
+ const unsafeStop = prompt.stop;
9
+ if (typeof unsafeStop !== "function") return;
10
+
11
+ prompt.removeListener("close", unsafeStop);
12
+ const safeStop = () => {
13
+ try {
14
+ unsafeStop();
15
+ } catch (error) {
16
+ if (!isReadlineClosedError(error)) throw error;
17
+ }
18
+ };
19
+ prompt.stop = safeStop;
20
+ prompt.once("close", safeStop);
21
+ }
22
+
23
+ async function selectOption(message, choices, dependencies = {}) {
24
+ const stdin = dependencies.stdin || process.stdin;
25
+ const stdout = dependencies.stdout || process.stdout;
26
+ if (!stdin.isTTY || !stdout.isTTY) {
27
+ throw new Error(`${message}: interactive selection requires a TTY; pass an explicit option.`);
28
+ }
29
+
30
+ const Select = dependencies.Select || require("enquirer").Select;
31
+ const prompt = new Select({
32
+ name: "selection",
33
+ message,
34
+ stdin,
35
+ stdout,
36
+ choices: [
37
+ ...choices.map((choice) => ({
38
+ name: choice.value,
39
+ message: choice.label,
40
+ })),
41
+ { name: EXIT_SELECTION, message: "退出" },
42
+ ],
43
+ });
44
+
45
+ prompt.once("start", makePromptCancellationSafe);
46
+ try {
47
+ return await prompt.run();
48
+ } catch (error) {
49
+ if (!error || error.name === "CancelPromptError" || isReadlineClosedError(error)) {
50
+ return EXIT_SELECTION;
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ module.exports = {
57
+ EXIT_SELECTION,
58
+ makePromptCancellationSafe,
59
+ selectOption,
60
+ };
@@ -0,0 +1,52 @@
1
+ const assert = require("node:assert/strict");
2
+ const { EventEmitter } = require("node:events");
3
+ const test = require("node:test");
4
+
5
+ const {
6
+ EXIT_SELECTION,
7
+ makePromptCancellationSafe,
8
+ selectOption,
9
+ } = require("./select-option");
10
+
11
+ test("makePromptCancellationSafe ignores readline cleanup after it was closed", () => {
12
+ const prompt = new EventEmitter();
13
+ const unsafeStop = () => {
14
+ const error = new Error("readline was closed");
15
+ error.code = "ERR_USE_AFTER_CLOSE";
16
+ throw error;
17
+ };
18
+ prompt.stop = unsafeStop;
19
+ prompt.once("close", unsafeStop);
20
+
21
+ makePromptCancellationSafe(prompt);
22
+
23
+ assert.doesNotThrow(() => prompt.emit("close"));
24
+ });
25
+
26
+ test("selectOption offers exit and converts cancellation into an exit result", async () => {
27
+ let promptOptions;
28
+ class CancelledSelect extends EventEmitter {
29
+ constructor(options) {
30
+ super();
31
+ promptOptions = options;
32
+ }
33
+
34
+ run() {
35
+ const error = new Error("cancelled");
36
+ error.name = "CancelPromptError";
37
+ return Promise.reject(error);
38
+ }
39
+ }
40
+
41
+ const result = await selectOption(
42
+ "请选择操作",
43
+ [{ value: "dev", label: "启动开发环境" }],
44
+ { Select: CancelledSelect, stdin: { isTTY: true }, stdout: { isTTY: true } },
45
+ );
46
+
47
+ assert.equal(result, EXIT_SELECTION);
48
+ assert.deepEqual(promptOptions.choices.at(-1), {
49
+ name: EXIT_SELECTION,
50
+ message: "退出",
51
+ });
52
+ });