helloloop 0.6.1 → 0.7.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.
@@ -4,35 +4,101 @@ import path from "node:path";
4
4
  import { fileExists, readJson, writeJson } from "./common.mjs";
5
5
  import { normalizeEngineName } from "./engine_metadata.mjs";
6
6
 
7
+ function defaultEmailNotificationSettings() {
8
+ return {
9
+ enabled: false,
10
+ to: [],
11
+ from: "",
12
+ smtp: {
13
+ host: "",
14
+ port: 465,
15
+ secure: true,
16
+ starttls: false,
17
+ username: "",
18
+ usernameEnv: "",
19
+ password: "",
20
+ passwordEnv: "",
21
+ timeoutSeconds: 30,
22
+ rejectUnauthorized: true,
23
+ },
24
+ };
25
+ }
26
+
7
27
  function defaultUserSettings() {
8
28
  return {
9
29
  defaultEngine: "",
10
30
  lastSelectedEngine: "",
31
+ notifications: {
32
+ email: defaultEmailNotificationSettings(),
33
+ },
11
34
  };
12
35
  }
13
36
 
37
+ export function resolveUserSettingsHome() {
38
+ return String(process.env.HELLOLOOP_HOME || "").trim()
39
+ || path.join(os.homedir(), ".helloloop");
40
+ }
41
+
14
42
  export function resolveUserSettingsFile(userSettingsFile = "") {
15
43
  return userSettingsFile
16
- || String(process.env.HELLOLOOP_USER_SETTINGS_FILE || "").trim()
17
- || path.join(os.homedir(), ".helloloop", "settings.json");
44
+ || String(process.env.HELLOLOOP_SETTINGS_FILE || "").trim()
45
+ || path.join(resolveUserSettingsHome(), "settings.json");
18
46
  }
19
47
 
20
- export function loadUserSettings(options = {}) {
48
+ function normalizeEmailNotificationSettings(emailSettings = {}) {
49
+ const defaults = defaultEmailNotificationSettings();
50
+ const smtp = emailSettings?.smtp || {};
51
+
52
+ return {
53
+ ...defaults,
54
+ ...emailSettings,
55
+ to: Array.isArray(emailSettings?.to)
56
+ ? emailSettings.to.map((item) => String(item || "").trim()).filter(Boolean)
57
+ : (typeof emailSettings?.to === "string" && emailSettings.to.trim() ? [emailSettings.to.trim()] : []),
58
+ smtp: {
59
+ ...defaults.smtp,
60
+ ...smtp,
61
+ },
62
+ };
63
+ }
64
+
65
+ export function loadUserSettingsDocument(options = {}) {
21
66
  const settingsFile = resolveUserSettingsFile(options.userSettingsFile);
22
- if (!fileExists(settingsFile)) {
23
- return defaultUserSettings();
24
- }
67
+ const settings = fileExists(settingsFile) ? readJson(settingsFile) : {};
25
68
 
26
- const settings = readJson(settingsFile);
27
69
  return {
70
+ ...defaultUserSettings(),
71
+ ...settings,
28
72
  defaultEngine: normalizeEngineName(settings?.defaultEngine),
29
73
  lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine),
74
+ notifications: {
75
+ ...(settings?.notifications || {}),
76
+ email: normalizeEmailNotificationSettings(settings?.notifications?.email || {}),
77
+ },
78
+ };
79
+ }
80
+
81
+ export function loadUserSettings(options = {}) {
82
+ const settings = loadUserSettingsDocument(options);
83
+ return {
84
+ defaultEngine: settings.defaultEngine,
85
+ lastSelectedEngine: settings.lastSelectedEngine,
30
86
  };
31
87
  }
32
88
 
33
89
  export function saveUserSettings(settings, options = {}) {
90
+ const currentSettings = loadUserSettingsDocument(options);
34
91
  writeJson(resolveUserSettingsFile(options.userSettingsFile), {
35
- defaultEngine: normalizeEngineName(settings?.defaultEngine),
36
- lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine),
92
+ ...currentSettings,
93
+ ...settings,
94
+ defaultEngine: normalizeEngineName(settings?.defaultEngine ?? currentSettings.defaultEngine),
95
+ lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine ?? currentSettings.lastSelectedEngine),
96
+ notifications: {
97
+ ...(currentSettings.notifications || {}),
98
+ ...(settings?.notifications || {}),
99
+ email: normalizeEmailNotificationSettings(
100
+ settings?.notifications?.email ?? currentSettings.notifications?.email ?? {},
101
+ ),
102
+ },
37
103
  });
38
104
  }
@@ -0,0 +1,21 @@
1
+ import { fileExists } from "./common.mjs";
2
+ import { loadUserSettingsDocument, resolveUserSettingsFile } from "./engine_selection_settings.mjs";
3
+
4
+ export function resolveGlobalConfigFile(explicitFile = "") {
5
+ return resolveUserSettingsFile(explicitFile);
6
+ }
7
+
8
+ export function loadGlobalConfig(options = {}) {
9
+ const configFile = resolveGlobalConfigFile(options.globalConfigFile);
10
+ const loaded = loadUserSettingsDocument({
11
+ userSettingsFile: configFile,
12
+ });
13
+
14
+ return {
15
+ ...loaded,
16
+ _meta: {
17
+ configFile,
18
+ exists: fileExists(configFile),
19
+ },
20
+ };
21
+ }
@@ -38,6 +38,54 @@ export function assertPathInside(parentDir, targetDir, label) {
38
38
  }
39
39
  }
40
40
 
41
+ function sleepSync(ms) {
42
+ const shared = new SharedArrayBuffer(4);
43
+ const view = new Int32Array(shared);
44
+ Atomics.wait(view, 0, 0, Math.max(0, ms));
45
+ }
46
+
47
+ function isRetryableRemoveError(error) {
48
+ const code = String(error?.code || "").toUpperCase();
49
+ return ["ENOTEMPTY", "EPERM", "EBUSY"].includes(code);
50
+ }
51
+
52
+ function removeDirectoryWithRetries(targetPath) {
53
+ const retryDelaysMs = [0, 50, 150, 300];
54
+ let lastError = null;
55
+
56
+ for (const delayMs of retryDelaysMs) {
57
+ if (delayMs > 0) {
58
+ sleepSync(delayMs);
59
+ }
60
+ try {
61
+ fs.rmSync(targetPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
62
+ return;
63
+ } catch (error) {
64
+ lastError = error;
65
+ if (!isRetryableRemoveError(error)) {
66
+ throw error;
67
+ }
68
+ }
69
+ }
70
+
71
+ const tempPath = `${targetPath}.removing-${Date.now()}`;
72
+ fs.renameSync(targetPath, tempPath);
73
+ try {
74
+ fs.rmSync(tempPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
75
+ } catch (error) {
76
+ throw lastError || error;
77
+ }
78
+ }
79
+
80
+ function removeFsPath(targetPath) {
81
+ const stats = fs.lstatSync(targetPath);
82
+ if (stats.isDirectory() && !stats.isSymbolicLink()) {
83
+ removeDirectoryWithRetries(targetPath);
84
+ return;
85
+ }
86
+ fs.rmSync(targetPath, { force: true, recursive: true, maxRetries: 3, retryDelay: 100 });
87
+ }
88
+
41
89
  export function removeTargetIfNeeded(targetPath, force) {
42
90
  if (!fileExists(targetPath)) {
43
91
  return;
@@ -45,14 +93,14 @@ export function removeTargetIfNeeded(targetPath, force) {
45
93
  if (!force) {
46
94
  throw new Error(`目标目录已存在:${targetPath}。若要覆盖,请追加 --force。`);
47
95
  }
48
- fs.rmSync(targetPath, { recursive: true, force: true });
96
+ removeFsPath(targetPath);
49
97
  }
50
98
 
51
99
  export function removePathIfExists(targetPath) {
52
100
  if (!fileExists(targetPath)) {
53
101
  return false;
54
102
  }
55
- fs.rmSync(targetPath, { recursive: true, force: true });
103
+ removeFsPath(targetPath);
56
104
  return true;
57
105
  }
58
106