@magentrix-corp/magentrix-cli 1.0.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.
- package/LICENSE +25 -0
- package/README.md +471 -0
- package/actions/autopublish.js +283 -0
- package/actions/autopublish.old.js +293 -0
- package/actions/autopublish.v2.js +447 -0
- package/actions/create.js +329 -0
- package/actions/help.js +165 -0
- package/actions/main.js +81 -0
- package/actions/publish.js +567 -0
- package/actions/pull.js +139 -0
- package/actions/setup.js +61 -0
- package/actions/status.js +17 -0
- package/bin/magentrix.js +159 -0
- package/package.json +61 -0
- package/utils/cacher.js +112 -0
- package/utils/cli/checkInstanceUrl.js +29 -0
- package/utils/cli/helpers/compare.js +281 -0
- package/utils/cli/helpers/ensureApiKey.js +57 -0
- package/utils/cli/helpers/ensureCredentials.js +60 -0
- package/utils/cli/helpers/ensureInstanceUrl.js +63 -0
- package/utils/cli/writeRecords.js +223 -0
- package/utils/compare.js +135 -0
- package/utils/compress.js +18 -0
- package/utils/config.js +451 -0
- package/utils/diff.js +49 -0
- package/utils/downloadAssets.js +75 -0
- package/utils/filetag.js +115 -0
- package/utils/hash.js +14 -0
- package/utils/magentrix/api/assets.js +145 -0
- package/utils/magentrix/api/auth.js +56 -0
- package/utils/magentrix/api/createEntity.js +61 -0
- package/utils/magentrix/api/deleteEntity.js +55 -0
- package/utils/magentrix/api/meqlQuery.js +31 -0
- package/utils/magentrix/api/retrieveEntity.js +32 -0
- package/utils/magentrix/api/updateEntity.js +66 -0
- package/utils/magentrix/fetch.js +154 -0
- package/utils/merge.js +22 -0
- package/utils/preferences.js +40 -0
- package/utils/spinner.js +43 -0
- package/utils/template.js +52 -0
- package/utils/updateFileBase.js +103 -0
- package/vars/config.js +1 -0
- package/vars/global.js +33 -0
package/utils/compare.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { sha256 } from './hash.js';
|
|
3
|
+
import { findFileByTag } from './filetag.js';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compares a local file to its remote representation and determines their sync status.
|
|
8
|
+
*
|
|
9
|
+
* - Checks file existence, last modified times, and content hashes.
|
|
10
|
+
* - Returns detailed sync info; never logs or warns directly.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} localFilePath - The path to the local file.
|
|
13
|
+
* @param {Object} remoteData - Object containing remote file metadata and content.
|
|
14
|
+
* @param {string} remoteData.content - The file content from the remote.
|
|
15
|
+
* @param {string} remoteData.ModifiedOn - The last modified date of the remote file (ISO string).
|
|
16
|
+
* @returns {Object} An object describing the sync status. Possible statuses:
|
|
17
|
+
* - 'in_sync': Files match (hashes and times).
|
|
18
|
+
* - 'behind': Local file is older than remote, but content is identical.
|
|
19
|
+
* - 'conflict': Local file is behind remote and contents differ (conflict).
|
|
20
|
+
* - 'ahead': Local file is newer than remote and contents differ.
|
|
21
|
+
* - 'ahead_identical': Local file is newer than remote but contents are identical (possible clock drift).
|
|
22
|
+
* - 'content_differs': Timestamps match but contents differ (possible manual edit).
|
|
23
|
+
* - 'missing': Local file does not exist (safe to download from remote).
|
|
24
|
+
* Additional returned fields depend on status.
|
|
25
|
+
*/
|
|
26
|
+
export function compareLocalAndRemote(localFilePath, remoteData) {
|
|
27
|
+
// If local file does not exist, it's fine—just return 'missing'
|
|
28
|
+
if (!fs.existsSync(localFilePath)) {
|
|
29
|
+
return {
|
|
30
|
+
status: 'missing',
|
|
31
|
+
message: 'Local file is missing.'
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Gather local file details
|
|
36
|
+
const localStats = fs.statSync(localFilePath);
|
|
37
|
+
const localContent = fs.readFileSync(localFilePath, 'utf8');
|
|
38
|
+
const localMtime = Math.ceil(localStats.mtimeMs); // Local last modified time (ms)
|
|
39
|
+
const localHash = sha256(localContent); // Local file content hash
|
|
40
|
+
|
|
41
|
+
// Gather remote file details
|
|
42
|
+
const remoteContent = remoteData.content;
|
|
43
|
+
const remoteMtime = Math.ceil(new Date(remoteData.ModifiedOn).getTime()); // Remote last modified time (ms)
|
|
44
|
+
const remoteHash = sha256(remoteContent); // Remote file content hash
|
|
45
|
+
|
|
46
|
+
// Case: local is behind remote (older mtime)
|
|
47
|
+
if (localMtime < remoteMtime) {
|
|
48
|
+
if (localHash !== remoteHash) {
|
|
49
|
+
// File changed on both local and remote: conflict!
|
|
50
|
+
return {
|
|
51
|
+
status: 'conflict',
|
|
52
|
+
localMtime,
|
|
53
|
+
remoteMtime,
|
|
54
|
+
localHash,
|
|
55
|
+
remoteHash,
|
|
56
|
+
message: 'Local file is behind remote and has conflicting changes.'
|
|
57
|
+
};
|
|
58
|
+
} else {
|
|
59
|
+
// Local is older but identical to remote
|
|
60
|
+
// return {
|
|
61
|
+
// status: 'behind',
|
|
62
|
+
// localMtime,
|
|
63
|
+
// remoteMtime,
|
|
64
|
+
// message: 'Local file is behind remote but contents are identical.'
|
|
65
|
+
// };
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
status: 'in_sync',
|
|
69
|
+
localMtime,
|
|
70
|
+
remoteMtime,
|
|
71
|
+
message: 'Local and remote file are in sync.'
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Case: local is ahead of remote (newer mtime)
|
|
76
|
+
else if (localMtime > remoteMtime) {
|
|
77
|
+
if (localHash !== remoteHash) {
|
|
78
|
+
return {
|
|
79
|
+
status: 'ahead',
|
|
80
|
+
localMtime,
|
|
81
|
+
remoteMtime,
|
|
82
|
+
localHash,
|
|
83
|
+
remoteHash,
|
|
84
|
+
message: 'Local file is ahead of remote and content differs.'
|
|
85
|
+
};
|
|
86
|
+
} else {
|
|
87
|
+
// Local is ahead but identical
|
|
88
|
+
// return {
|
|
89
|
+
// status: 'ahead_identical',
|
|
90
|
+
// localMtime,
|
|
91
|
+
// remoteMtime,
|
|
92
|
+
// message: 'Local file is ahead of remote but contents are identical.'
|
|
93
|
+
// };
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
status: 'in_sync',
|
|
97
|
+
localMtime,
|
|
98
|
+
remoteMtime,
|
|
99
|
+
message: 'Local and remote file are in sync.'
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Case: mtimes match
|
|
104
|
+
else {
|
|
105
|
+
// Check for rename
|
|
106
|
+
const matchingFilePath = findFileByTag(remoteData.Id);
|
|
107
|
+
const absoluteLocalPath = path.resolve(localFilePath);
|
|
108
|
+
|
|
109
|
+
if (matchingFilePath !== absoluteLocalPath) {
|
|
110
|
+
return {
|
|
111
|
+
status: 'rename',
|
|
112
|
+
localMtime,
|
|
113
|
+
remoteMtime,
|
|
114
|
+
message: 'Local and remote file names differ.'
|
|
115
|
+
};
|
|
116
|
+
} else if (localHash === remoteHash) {
|
|
117
|
+
return {
|
|
118
|
+
status: 'in_sync',
|
|
119
|
+
localMtime,
|
|
120
|
+
remoteMtime,
|
|
121
|
+
message: 'Local and remote file are in sync.'
|
|
122
|
+
};
|
|
123
|
+
} else {
|
|
124
|
+
// Timestamps match but contents do not
|
|
125
|
+
return {
|
|
126
|
+
status: 'content_differs',
|
|
127
|
+
localMtime,
|
|
128
|
+
remoteMtime,
|
|
129
|
+
localHash,
|
|
130
|
+
remoteHash,
|
|
131
|
+
message: 'File times match, but content differs (possible manual edit).'
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import pako from 'pako';
|
|
2
|
+
|
|
3
|
+
export const compressString = (input) => {
|
|
4
|
+
const compressed = pako.deflate(input);
|
|
5
|
+
const compressedStr = Buffer.from(compressed).toString('base64');
|
|
6
|
+
return compressedStr;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const decompressString = (compressedInput) => {
|
|
10
|
+
try {
|
|
11
|
+
if (!compressedInput) return '';
|
|
12
|
+
const decoded = Buffer.from(compressedInput, 'base64');
|
|
13
|
+
const decompressed = pako.inflate(decoded, { to: 'string' });
|
|
14
|
+
return decompressed;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
}
|
package/utils/config.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Config class handles global and per-project configuration.
|
|
7
|
+
* - Global config: ~/.config/magentrix/config.json (or %APPDATA%\magentrix\config.json on Windows)
|
|
8
|
+
* - Project config: ./.magentrix/config.json (walks up dirs to support monorepos)
|
|
9
|
+
* - Secure file/folder permissions (Unix)
|
|
10
|
+
* - Optionally, global config can be namespaced by pathHash (SHA256 hash of a directory path)
|
|
11
|
+
* - Supports searching through config data like a database
|
|
12
|
+
*/
|
|
13
|
+
class Config {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} opts
|
|
16
|
+
* @param {string} opts.projectDir - Directory to treat as project root (default: process.cwd())
|
|
17
|
+
* @param {string} opts.projectFolder - Name of per-project config folder (default: '.magentrix')
|
|
18
|
+
* @param {string} opts.configFile - Config file name (default: 'config.json')
|
|
19
|
+
* @param {string} opts.globalAppName - Name for global config dir (default: 'magentrix')
|
|
20
|
+
*/
|
|
21
|
+
constructor({
|
|
22
|
+
projectDir = process.cwd(),
|
|
23
|
+
projectFolder = '.magentrix',
|
|
24
|
+
configFile = 'config.json',
|
|
25
|
+
globalAppName = 'magentrix',
|
|
26
|
+
} = {}) {
|
|
27
|
+
this.projectDir = projectDir;
|
|
28
|
+
this.projectFolder = projectFolder;
|
|
29
|
+
this.configFile = configFile;
|
|
30
|
+
this.globalAppName = globalAppName;
|
|
31
|
+
|
|
32
|
+
// File paths for global and project config
|
|
33
|
+
this.globalConfigPath = this.getGlobalConfigPath();
|
|
34
|
+
this.projectConfigPath = this.getProjectConfigPath();
|
|
35
|
+
|
|
36
|
+
// In-memory cache for config files
|
|
37
|
+
this._globalConfig = null;
|
|
38
|
+
this._projectConfig = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Determine the global config file path:
|
|
43
|
+
* - Linux/macOS: ~/.config/[app]/config.json
|
|
44
|
+
* - Windows: %APPDATA%/[app]/config.json
|
|
45
|
+
*/
|
|
46
|
+
getGlobalConfigPath() {
|
|
47
|
+
const base =
|
|
48
|
+
process.platform === 'win32'
|
|
49
|
+
? path.join(process.env.APPDATA, this.globalAppName)
|
|
50
|
+
: path.join(os.homedir(), '.config', this.globalAppName);
|
|
51
|
+
|
|
52
|
+
if (!fs.existsSync(base)) fs.mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
53
|
+
return path.join(base, this.configFile);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Determine the project config file path:
|
|
58
|
+
* - Walks up from current directory looking for .magentrix/config.json.
|
|
59
|
+
* - If not found, uses current working directory as default.
|
|
60
|
+
*/
|
|
61
|
+
getProjectConfigPath() {
|
|
62
|
+
let dir = this.projectDir;
|
|
63
|
+
while (dir !== path.dirname(dir)) {
|
|
64
|
+
const cfgPath = path.join(dir, this.projectFolder, this.configFile);
|
|
65
|
+
if (fs.existsSync(cfgPath)) return cfgPath;
|
|
66
|
+
dir = path.dirname(dir);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fallback: create project config folder in current directory
|
|
70
|
+
const fallbackDir = path.join(this.projectDir, this.projectFolder);
|
|
71
|
+
if (!fs.existsSync(fallbackDir)) fs.mkdirSync(fallbackDir, { recursive: true, mode: 0o700 });
|
|
72
|
+
return path.join(fallbackDir, this.configFile);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Internal: Load config JSON from file for project or global config.
|
|
77
|
+
* @param {'project'|'global'} type
|
|
78
|
+
* @param {string} [customPath] - Optional custom file path for project loads
|
|
79
|
+
* @returns {Object} Parsed config, or empty object if missing.
|
|
80
|
+
*/
|
|
81
|
+
_loadConfig(type = 'project', customPath = null) {
|
|
82
|
+
const cfgPath = type === 'global'
|
|
83
|
+
? this.globalConfigPath
|
|
84
|
+
: (customPath || this.projectConfigPath);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
if (fs.existsSync(cfgPath)) {
|
|
88
|
+
const data = fs.readFileSync(cfgPath, 'utf8');
|
|
89
|
+
return JSON.parse(data);
|
|
90
|
+
}
|
|
91
|
+
} catch (e) {
|
|
92
|
+
throw new Error(`Failed to read ${type} config at ${cfgPath}: ${e.message}`);
|
|
93
|
+
}
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Internal: Write config JSON to file, using secure file permissions.
|
|
99
|
+
* @param {Object} obj - The config object to save.
|
|
100
|
+
* @param {'project'|'global'} type
|
|
101
|
+
* @param {string} [customPath] - Optional custom file path for project saves
|
|
102
|
+
*/
|
|
103
|
+
_saveConfig(obj, type = 'project', customPath = null) {
|
|
104
|
+
const cfgPath = type === 'global'
|
|
105
|
+
? this.globalConfigPath
|
|
106
|
+
: (customPath || this.projectConfigPath);
|
|
107
|
+
|
|
108
|
+
const data = JSON.stringify(obj, null, 2);
|
|
109
|
+
try {
|
|
110
|
+
fs.writeFileSync(cfgPath, data, { mode: 0o600 });
|
|
111
|
+
} catch (e) {
|
|
112
|
+
throw new Error(`Failed to write ${type} config at ${cfgPath}: ${e.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read a value from config (global or project).
|
|
118
|
+
* If opts.pathHash is specified in global mode, data is stored under that subkey.
|
|
119
|
+
* For project reads, opts.filename can specify a custom filename.
|
|
120
|
+
* @param {string} [key] - The config key to read. If missing, returns entire config object.
|
|
121
|
+
* @param {Object} opts
|
|
122
|
+
* @param {boolean} opts.global - If true, use global config. Otherwise, use project config.
|
|
123
|
+
* @param {string} [opts.pathHash] - Optional. Hash for namespaced global config.
|
|
124
|
+
* @param {string} [opts.filename] - Optional. Custom filename for project reads (ignored for global).
|
|
125
|
+
* @returns {*} The value for the key, or full config object.
|
|
126
|
+
*/
|
|
127
|
+
read(key, opts = {}) {
|
|
128
|
+
const isGlobal = opts.global === true;
|
|
129
|
+
const filename = opts.filename;
|
|
130
|
+
const pathHash = opts.pathHash;
|
|
131
|
+
|
|
132
|
+
if (isGlobal) {
|
|
133
|
+
if (this._globalConfig === null) this._globalConfig = this._loadConfig('global');
|
|
134
|
+
if (pathHash) {
|
|
135
|
+
this._globalConfig[pathHash] = this._globalConfig[pathHash] || {};
|
|
136
|
+
return key
|
|
137
|
+
? this._globalConfig[pathHash][key]
|
|
138
|
+
: { ...this._globalConfig[pathHash] };
|
|
139
|
+
}
|
|
140
|
+
return key
|
|
141
|
+
? this._globalConfig[key]
|
|
142
|
+
: { ...this._globalConfig };
|
|
143
|
+
} else {
|
|
144
|
+
if (filename) {
|
|
145
|
+
const projectFolderPath = path.join(this.projectDir, this.projectFolder);
|
|
146
|
+
const customPath = path.join(projectFolderPath, filename);
|
|
147
|
+
const customConfig = this._loadConfig('project', customPath);
|
|
148
|
+
return key
|
|
149
|
+
? customConfig[key]
|
|
150
|
+
: { ...customConfig };
|
|
151
|
+
} else {
|
|
152
|
+
if (this._projectConfig === null) this._projectConfig = this._loadConfig('project');
|
|
153
|
+
return key
|
|
154
|
+
? this._projectConfig[key]
|
|
155
|
+
: { ...this._projectConfig };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Remove a key from config (global or project).
|
|
162
|
+
* If opts.pathHash is specified in global mode, key is removed from that subkey.
|
|
163
|
+
* For project removals, opts.filename can specify a custom filename.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} key - The config key to remove.
|
|
166
|
+
* @param {Object} opts
|
|
167
|
+
* @param {boolean} opts.global - If true, use global config. Otherwise, use project config.
|
|
168
|
+
* @param {string} [opts.pathHash] - Optional. Hash for namespaced global config.
|
|
169
|
+
* @param {string} [opts.filename] - Optional. Custom filename for project config.
|
|
170
|
+
*/
|
|
171
|
+
removeKey(key, opts = {}) {
|
|
172
|
+
const isGlobal = opts.global === true;
|
|
173
|
+
const filename = opts.filename;
|
|
174
|
+
const pathHash = opts.pathHash;
|
|
175
|
+
|
|
176
|
+
if (isGlobal) {
|
|
177
|
+
if (this._globalConfig === null) this._globalConfig = this._loadConfig('global');
|
|
178
|
+
|
|
179
|
+
if (pathHash) {
|
|
180
|
+
if (!this._globalConfig[pathHash]) return;
|
|
181
|
+
delete this._globalConfig[pathHash][key];
|
|
182
|
+
} else {
|
|
183
|
+
delete this._globalConfig[key];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this._saveConfig(this._globalConfig, 'global');
|
|
187
|
+
} else {
|
|
188
|
+
if (filename) {
|
|
189
|
+
const projectFolderPath = path.join(this.projectDir, this.projectFolder);
|
|
190
|
+
if (!fs.existsSync(projectFolderPath)) return;
|
|
191
|
+
const customPath = path.join(projectFolderPath, filename);
|
|
192
|
+
const customConfig = this._loadConfig('project', customPath) || {};
|
|
193
|
+
delete customConfig[key];
|
|
194
|
+
this._saveConfig(customConfig, 'project', customPath);
|
|
195
|
+
} else {
|
|
196
|
+
if (this._projectConfig === null) this._projectConfig = this._loadConfig('project');
|
|
197
|
+
delete this._projectConfig[key];
|
|
198
|
+
this._saveConfig(this._projectConfig, 'project');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Save a key-value pair to config (global or project).
|
|
206
|
+
* If opts.pathHash is specified in global mode, data is stored under that subkey.
|
|
207
|
+
* For project saves, opts.filename can specify a custom filename.
|
|
208
|
+
* @param {string} key - The config key.
|
|
209
|
+
* @param {*} value - The value to store.
|
|
210
|
+
* @param {Object} opts
|
|
211
|
+
* @param {boolean} opts.global - If true, use global config. Otherwise, use project config.
|
|
212
|
+
* @param {string} [opts.pathHash] - Optional. Hash for namespaced global config.
|
|
213
|
+
* @param {string} [opts.filename] - Optional. Custom filename for project saves (ignored for global).
|
|
214
|
+
*/
|
|
215
|
+
save(key, value, opts = {}) {
|
|
216
|
+
const isGlobal = opts.global === true;
|
|
217
|
+
const filename = opts.filename;
|
|
218
|
+
const pathHash = opts.pathHash;
|
|
219
|
+
|
|
220
|
+
if (isGlobal) {
|
|
221
|
+
if (this._globalConfig === null) this._globalConfig = this._loadConfig('global');
|
|
222
|
+
if (pathHash) {
|
|
223
|
+
this._globalConfig[pathHash] = this._globalConfig[pathHash] || {};
|
|
224
|
+
this._globalConfig[pathHash][key] = value;
|
|
225
|
+
} else {
|
|
226
|
+
this._globalConfig[key] = value;
|
|
227
|
+
}
|
|
228
|
+
this._saveConfig(this._globalConfig, 'global');
|
|
229
|
+
} else {
|
|
230
|
+
if (filename) {
|
|
231
|
+
// Ensure project config folder exists
|
|
232
|
+
const projectFolderPath = path.join(this.projectDir, this.projectFolder);
|
|
233
|
+
if (!fs.existsSync(projectFolderPath)) {
|
|
234
|
+
fs.mkdirSync(projectFolderPath, { recursive: true, mode: 0o700 });
|
|
235
|
+
}
|
|
236
|
+
const customPath = path.join(projectFolderPath, filename);
|
|
237
|
+
// Load, update, and save the custom config file independently
|
|
238
|
+
const customConfig = this._loadConfig('project', customPath) || {};
|
|
239
|
+
customConfig[key] = value;
|
|
240
|
+
this._saveConfig(customConfig, 'project', customPath);
|
|
241
|
+
} else {
|
|
242
|
+
// Default project config
|
|
243
|
+
if (this._projectConfig === null) this._projectConfig = this._loadConfig('project');
|
|
244
|
+
this._projectConfig[key] = value;
|
|
245
|
+
this._saveConfig(this._projectConfig, 'project');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Internal: Deep search through nested objects and arrays
|
|
252
|
+
* @param {*} obj - The object/value to search through
|
|
253
|
+
* @param {Object} query - The query object with key-value pairs to match
|
|
254
|
+
* @param {Object} [options={}] - Search options
|
|
255
|
+
* @param {boolean} [options.exact=true] - Whether to use exact matching or partial matching
|
|
256
|
+
* @param {boolean} [options.caseSensitive=true] - Whether string comparisons should be case sensitive
|
|
257
|
+
* @returns {boolean} True if the object matches the query
|
|
258
|
+
*/
|
|
259
|
+
_matchesQuery(obj, query, options = {}) {
|
|
260
|
+
const { exact = true, caseSensitive = true } = options;
|
|
261
|
+
if (obj === null || obj === undefined) return false;
|
|
262
|
+
for (const [queryKey, expectedValue] of Object.entries(query)) {
|
|
263
|
+
if (!this._hasMatchingValue(obj, queryKey, expectedValue, options)) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Internal: Check if an object has a matching value for a given key (supports nested keys)
|
|
272
|
+
* @param {*} obj - The object to search in
|
|
273
|
+
* @param {string} key - The key to search for (supports dot notation for nested keys)
|
|
274
|
+
* @param {*} expectedValue - The expected value
|
|
275
|
+
* @param {Object} options - Search options
|
|
276
|
+
* @returns {boolean} True if a matching value is found
|
|
277
|
+
*/
|
|
278
|
+
_hasMatchingValue(obj, key, expectedValue, options) {
|
|
279
|
+
const { exact, caseSensitive } = options;
|
|
280
|
+
const keys = key.split('.');
|
|
281
|
+
let current = obj;
|
|
282
|
+
|
|
283
|
+
for (const k of keys) {
|
|
284
|
+
if (current === null || current === undefined) return false;
|
|
285
|
+
|
|
286
|
+
if (Array.isArray(current)) {
|
|
287
|
+
return current.some(item =>
|
|
288
|
+
this._hasMatchingValue(item, keys.slice(keys.indexOf(k)).join('.'), expectedValue, options)
|
|
289
|
+
);
|
|
290
|
+
} else if (typeof current === 'object') {
|
|
291
|
+
current = current[k];
|
|
292
|
+
} else {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return this._compareValues(current, expectedValue, options);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Internal: Compare two values based on search options
|
|
302
|
+
* @param {*} actual - The actual value from the object
|
|
303
|
+
* @param {*} expected - The expected value from the query
|
|
304
|
+
* @param {Object} options - Comparison options
|
|
305
|
+
* @returns {boolean} True if values match according to options
|
|
306
|
+
*/
|
|
307
|
+
_compareValues(actual, expected, options) {
|
|
308
|
+
const { exact, caseSensitive } = options;
|
|
309
|
+
if (exact) {
|
|
310
|
+
if (
|
|
311
|
+
typeof actual === 'string' &&
|
|
312
|
+
typeof expected === 'string' &&
|
|
313
|
+
!caseSensitive
|
|
314
|
+
) {
|
|
315
|
+
return actual.toLowerCase() === expected.toLowerCase();
|
|
316
|
+
}
|
|
317
|
+
return actual === expected;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Partial matching for strings
|
|
321
|
+
if (
|
|
322
|
+
typeof actual === 'string' &&
|
|
323
|
+
typeof expected === 'string'
|
|
324
|
+
) {
|
|
325
|
+
const a = caseSensitive ? actual : actual.toLowerCase();
|
|
326
|
+
const e = caseSensitive ? expected : expected.toLowerCase();
|
|
327
|
+
return a.includes(e);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return actual === expected;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Search through config data like a database query.
|
|
335
|
+
* Supports nested object searching and various matching options.
|
|
336
|
+
*
|
|
337
|
+
* @param {Object} query - Query object with key-value pairs to match
|
|
338
|
+
* @param {Object} [opts={}] - Search options
|
|
339
|
+
* @param {boolean} [opts.global=false] - Search in global config if true, project config if false
|
|
340
|
+
* @param {string} [opts.filename] - Custom filename for project searches
|
|
341
|
+
* @param {string} [opts.pathHash] - Hash for namespaced global config searches
|
|
342
|
+
* @param {boolean} [opts.exact=true] - Use exact matching (false for partial string matching)
|
|
343
|
+
* @param {boolean} [opts.caseSensitive=true] - Case sensitive string comparisons
|
|
344
|
+
* @param {string} [opts.searchIn] - Specific top-level key to search within (optional)
|
|
345
|
+
*
|
|
346
|
+
* @returns {Array} Array of matching objects/values with their keys
|
|
347
|
+
*/
|
|
348
|
+
searchObject(query, opts = {}) {
|
|
349
|
+
const {
|
|
350
|
+
global = false,
|
|
351
|
+
filename,
|
|
352
|
+
pathHash,
|
|
353
|
+
exact = true,
|
|
354
|
+
caseSensitive = true,
|
|
355
|
+
searchIn,
|
|
356
|
+
} = opts;
|
|
357
|
+
|
|
358
|
+
let configData = global
|
|
359
|
+
? this.read(null, { global: true, pathHash })
|
|
360
|
+
: this.read(null, { global: false, filename });
|
|
361
|
+
|
|
362
|
+
if (searchIn && configData[searchIn]) {
|
|
363
|
+
configData = { [searchIn]: configData[searchIn] };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const results = [];
|
|
367
|
+
const recurse = (obj, keyPath = '') => {
|
|
368
|
+
if (obj === null || obj === undefined) return;
|
|
369
|
+
if (typeof obj === 'object' && this._matchesQuery(obj, query, { exact, caseSensitive })) {
|
|
370
|
+
results.push({ key: keyPath, value: obj, matches: query });
|
|
371
|
+
}
|
|
372
|
+
if (typeof obj === 'object') {
|
|
373
|
+
if (Array.isArray(obj)) {
|
|
374
|
+
obj.forEach((item, idx) => recurse(item, `${keyPath}[${idx}]`));
|
|
375
|
+
} else {
|
|
376
|
+
Object.entries(obj).forEach(([k, v]) =>
|
|
377
|
+
recurse(v, keyPath ? `${keyPath}.${k}` : k)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
recurse(configData);
|
|
383
|
+
return results;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Convenience method for simple key-value searches
|
|
388
|
+
* @param {string} key - The key to search for
|
|
389
|
+
* @param {*} value - The value to match
|
|
390
|
+
* @param {Object} [opts={}] - Same options as searchObject
|
|
391
|
+
* @returns {Array} Array of matching results
|
|
392
|
+
*/
|
|
393
|
+
findByKeyValue(key, value, opts = {}) {
|
|
394
|
+
return this.searchObject({ [key]: value }, opts);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Search for objects containing any of the specified values
|
|
399
|
+
* @param {Object} query - Query object with key-value pairs (OR logic)
|
|
400
|
+
* @param {Object} [opts={}] - Same options as searchObject
|
|
401
|
+
* @returns {Array} Array of matching results
|
|
402
|
+
*/
|
|
403
|
+
searchObjectAny(query, opts = {}) {
|
|
404
|
+
const {
|
|
405
|
+
global = false,
|
|
406
|
+
filename,
|
|
407
|
+
pathHash,
|
|
408
|
+
exact = true,
|
|
409
|
+
caseSensitive = true,
|
|
410
|
+
searchIn,
|
|
411
|
+
} = opts;
|
|
412
|
+
|
|
413
|
+
let configData = global
|
|
414
|
+
? this.read(null, { global: true, pathHash })
|
|
415
|
+
: this.read(null, { global: false, filename });
|
|
416
|
+
|
|
417
|
+
if (searchIn && configData[searchIn]) {
|
|
418
|
+
configData = { [searchIn]: configData[searchIn] };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const results = [];
|
|
422
|
+
const recurseAny = (obj, keyPath = '') => {
|
|
423
|
+
if (obj === null || obj === undefined) return;
|
|
424
|
+
if (typeof obj === 'object') {
|
|
425
|
+
const matched = Object.entries(query).filter(([qk, qv]) =>
|
|
426
|
+
this._hasMatchingValue(obj, qk, qv, { exact, caseSensitive })
|
|
427
|
+
);
|
|
428
|
+
if (matched.length) {
|
|
429
|
+
results.push({
|
|
430
|
+
key: keyPath,
|
|
431
|
+
value: obj,
|
|
432
|
+
matches: Object.fromEntries(matched),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (typeof obj === 'object') {
|
|
437
|
+
if (Array.isArray(obj)) {
|
|
438
|
+
obj.forEach((item, idx) => recurseAny(item, `${keyPath}[${idx}]`));
|
|
439
|
+
} else {
|
|
440
|
+
Object.entries(obj).forEach(([k, v]) =>
|
|
441
|
+
recurseAny(v, keyPath ? `${keyPath}.${k}` : k)
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
recurseAny(configData);
|
|
447
|
+
return results;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export default Config;
|
package/utils/diff.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execSync, spawn } from 'child_process';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks if VS Code is installed and accessible via the CLI (`code` command).
|
|
6
|
+
* @returns {boolean} True if VS Code is available in PATH, false otherwise.
|
|
7
|
+
*/
|
|
8
|
+
export const canOpenDiffInVSCode = () => {
|
|
9
|
+
try {
|
|
10
|
+
execSync('code --version', { stdio: 'ignore' });
|
|
11
|
+
return true;
|
|
12
|
+
} catch (err) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Opens a side-by-side diff of two files in VS Code, if available.
|
|
19
|
+
* Falls back to terminal diff if VS Code is unavailable.
|
|
20
|
+
* @param {string} file1 - Path to the first file.
|
|
21
|
+
* @param {string} file2 - Path to the second file.
|
|
22
|
+
* @returns {boolean} True if VS Code diff was opened, false if fallback used.
|
|
23
|
+
*/
|
|
24
|
+
export const openDiffInVSCode = (file1, file2) => {
|
|
25
|
+
if (!canOpenDiffInVSCode()) {
|
|
26
|
+
console.log(
|
|
27
|
+
chalk.yellow(
|
|
28
|
+
'Warning: VS Code is not installed or the `code` command is not in your PATH.\n' +
|
|
29
|
+
'Falling back to terminal diff.\nSee: https://code.visualstudio.com/docs/setup/mac#_launching-from-the-command-line'
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
// Fallback: Use built-in diff (Unix) or fc (Windows)
|
|
33
|
+
try {
|
|
34
|
+
if (process.platform === 'win32') {
|
|
35
|
+
execSync(`fc "${file1}" "${file2}"`, { stdio: 'inherit' });
|
|
36
|
+
} else {
|
|
37
|
+
execSync(`diff -u "${file1}" "${file2}"`, { stdio: 'inherit' });
|
|
38
|
+
}
|
|
39
|
+
} catch (e) {
|
|
40
|
+
// Optionally handle diff exit code (e.g., files differ)
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const child = spawn('code', ['--diff', file1, file2], { stdio: 'inherit', shell: true });
|
|
46
|
+
console.log(chalk.green('Opening diff in VS Code...'));
|
|
47
|
+
|
|
48
|
+
return true;
|
|
49
|
+
};
|