@qelos/plugins-cli 0.1.0 → 0.1.1
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/commands/push.mjs +6 -0
- package/controllers/create.mjs +5 -3
- package/controllers/pull.mjs +1 -1
- package/controllers/push.mjs +189 -2
- package/jest.config.js +23 -27
- package/package.json +4 -4
- package/services/components.mjs +2 -0
- package/services/duplicate-checker.mjs +118 -0
- package/services/hard-push.mjs +124 -0
- package/services/integrations.mjs +150 -3
- package/services/interactive-select.mjs +102 -0
package/commands/push.mjs
CHANGED
|
@@ -16,6 +16,12 @@ export default function pushCommand(program) {
|
|
|
16
16
|
type: 'string',
|
|
17
17
|
required: true
|
|
18
18
|
})
|
|
19
|
+
.option('hard', {
|
|
20
|
+
alias: 'h',
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
describe: 'Hard push: remove resources from Qelos that don\'t exist locally (only for components, integrations, plugins, blueprints when pushing a directory)',
|
|
23
|
+
default: false
|
|
24
|
+
})
|
|
19
25
|
},
|
|
20
26
|
pushController)
|
|
21
27
|
}
|
package/controllers/create.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import follow from "follow-redirects";
|
|
2
|
-
import
|
|
2
|
+
import { interactiveSelect } from "../services/interactive-select.mjs";
|
|
3
3
|
import { blue } from "../utils/colors.mjs";
|
|
4
4
|
import DecompressZip from "decompress-zip";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -53,7 +53,7 @@ export default async function createController({ name, boilerplate }) {
|
|
|
53
53
|
} else {
|
|
54
54
|
console.log(blue("Choose a boilerplate:"));
|
|
55
55
|
try {
|
|
56
|
-
const project = await
|
|
56
|
+
const project = await interactiveSelect({
|
|
57
57
|
values: {
|
|
58
58
|
vanilla: "Vanilla",
|
|
59
59
|
vue: "Vue",
|
|
@@ -62,12 +62,14 @@ export default async function createController({ name, boilerplate }) {
|
|
|
62
62
|
more: "Show more",
|
|
63
63
|
custom: "Custom from Github",
|
|
64
64
|
},
|
|
65
|
+
message: "Choose a boilerplate:"
|
|
65
66
|
});
|
|
66
67
|
repository = project.id;
|
|
67
68
|
|
|
68
69
|
if (repository === "more") {
|
|
69
|
-
const selected = await
|
|
70
|
+
const selected = await interactiveSelect({
|
|
70
71
|
values: await getOrgReposNames(organization),
|
|
72
|
+
message: "Choose a repository:"
|
|
71
73
|
});
|
|
72
74
|
repository = selected.id;
|
|
73
75
|
} else if (repository === "custom") {
|
package/controllers/pull.mjs
CHANGED
|
@@ -39,8 +39,8 @@ export default async function pullController({ type, path: targetPath = './' })
|
|
|
39
39
|
{ name: 'configs', fn: pullConfigurations },
|
|
40
40
|
{ name: 'plugins', fn: pullPlugins },
|
|
41
41
|
{ name: 'blocks', fn: pullBlocks },
|
|
42
|
+
{ name: 'connections', fn: pullConnections },
|
|
42
43
|
{ name: 'integrations', fn: pullIntegrations },
|
|
43
|
-
{ name: 'connections', fn: pullConnections }
|
|
44
44
|
];
|
|
45
45
|
|
|
46
46
|
for (const { name, fn } of types) {
|
package/controllers/push.mjs
CHANGED
|
@@ -7,15 +7,26 @@ import { pushBlocks } from '../services/blocks.mjs';
|
|
|
7
7
|
import { pushIntegrations } from '../services/integrations.mjs';
|
|
8
8
|
import { pushConnections } from '../services/connections.mjs';
|
|
9
9
|
import { getGitFiles, prepareTempDirectories } from '../services/git-files.mjs';
|
|
10
|
+
import { checkDuplicateIdentifiers, displayDuplicateConflicts } from '../services/duplicate-checker.mjs';
|
|
10
11
|
import { logger } from '../services/logger.mjs';
|
|
12
|
+
import { getLocalFiles, getRemoteResources, getIdentifierFromFile, confirmHardPush, removeResources } from '../services/hard-push.mjs';
|
|
11
13
|
import fs from 'node:fs';
|
|
12
14
|
import path from 'node:path';
|
|
13
15
|
import { mkdtemp } from 'node:fs/promises';
|
|
14
16
|
import { tmpdir } from 'node:os';
|
|
15
17
|
|
|
16
|
-
export default async function pushController({ type, path: sourcePath }) {
|
|
18
|
+
export default async function pushController({ type, path: sourcePath, hard = false }) {
|
|
17
19
|
let tempDir = null;
|
|
18
20
|
|
|
21
|
+
// Validate hard flag usage
|
|
22
|
+
if (hard) {
|
|
23
|
+
const validTypes = ['components', 'blueprints', 'plugins', 'integrations', 'all', '*'];
|
|
24
|
+
if (!validTypes.includes(type)) {
|
|
25
|
+
logger.error('--hard flag is only available for: components, blueprints, plugins, integrations, or all');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
try {
|
|
20
31
|
// Handle git-based types (committed and staged)
|
|
21
32
|
if (type === 'committed' || type === 'staged') {
|
|
@@ -66,6 +77,15 @@ export default async function pushController({ type, path: sourcePath }) {
|
|
|
66
77
|
for (const { name, fn } of types) {
|
|
67
78
|
if (!tempPaths[name]) continue;
|
|
68
79
|
|
|
80
|
+
// Check for duplicate identifiers (skip components and blocks as they can't have duplicates)
|
|
81
|
+
if (name !== 'components' && name !== 'blocks') {
|
|
82
|
+
const duplicates = checkDuplicateIdentifiers(classifiedFiles[name], name, tempPaths[name]);
|
|
83
|
+
if (duplicates.length > 0) {
|
|
84
|
+
displayDuplicateConflicts(duplicates, name);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
logger.section(`Pushing ${name} (${classifiedFiles[name].length} file(s))`);
|
|
70
90
|
|
|
71
91
|
// Show the actual files being pushed
|
|
@@ -110,12 +130,79 @@ export default async function pushController({ type, path: sourcePath }) {
|
|
|
110
130
|
basePath = path.dirname(sourcePath);
|
|
111
131
|
targetFile = path.basename(sourcePath);
|
|
112
132
|
logger.info(`Detected file path. Only pushing ${targetFile}`);
|
|
133
|
+
|
|
134
|
+
// Prevent using hard flag with single files
|
|
135
|
+
if (hard) {
|
|
136
|
+
logger.error('--hard flag can only be used when pushing a directory, not a single file');
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
113
139
|
} else if (!stat.isDirectory()) {
|
|
114
140
|
logger.error(`Path must be a file or directory: ${sourcePath}`);
|
|
115
141
|
process.exit(1);
|
|
116
142
|
}
|
|
117
143
|
|
|
118
|
-
|
|
144
|
+
// Check for duplicate identifiers when pushing a directory
|
|
145
|
+
if (!targetFile && type !== 'components' && type !== 'blocks') {
|
|
146
|
+
// basePath is already the directory path when pushing a directory
|
|
147
|
+
const typePath = basePath;
|
|
148
|
+
if (fs.existsSync(typePath)) {
|
|
149
|
+
let resourceFiles = [];
|
|
150
|
+
|
|
151
|
+
// For other types, just look at the top level
|
|
152
|
+
const files = fs.readdirSync(typePath);
|
|
153
|
+
resourceFiles = files.filter(file => {
|
|
154
|
+
if (type === 'blueprints') return file.endsWith('.blueprint.json');
|
|
155
|
+
if (type === 'configs') return file.endsWith('.config.json');
|
|
156
|
+
if (type === 'plugins') return file.endsWith('.plugin.json');
|
|
157
|
+
if (type === 'integrations' || type === 'integration') return file.endsWith('.integration.json');
|
|
158
|
+
if (type === 'connections' || type === 'connection') return file.endsWith('.connection.json');
|
|
159
|
+
return false;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const filePaths = resourceFiles.map(file => path.join(typePath, file));
|
|
163
|
+
const duplicates = checkDuplicateIdentifiers(filePaths, type, typePath);
|
|
164
|
+
if (duplicates.length > 0) {
|
|
165
|
+
displayDuplicateConflicts(duplicates, type);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Handle hard push logic for individual types (only when pushing directories)
|
|
172
|
+
if (hard && !targetFile && ['components', 'blueprints', 'plugins', 'integrations'].includes(type)) {
|
|
173
|
+
logger.info('Checking for resources to remove...');
|
|
174
|
+
|
|
175
|
+
const sdk = await initializeSdk();
|
|
176
|
+
const typePath = basePath;
|
|
177
|
+
|
|
178
|
+
// Get local files
|
|
179
|
+
const localFiles = getLocalFiles(typePath, type);
|
|
180
|
+
const localIdentifiers = localFiles.map(file => getIdentifierFromFile(file, type));
|
|
181
|
+
|
|
182
|
+
// Get remote resources
|
|
183
|
+
const remoteResources = await getRemoteResources(sdk, type);
|
|
184
|
+
const remoteIdentifiers = remoteResources.map(r => r.identifier);
|
|
185
|
+
|
|
186
|
+
// Find resources to remove (exist remotely but not locally)
|
|
187
|
+
const toRemove = remoteIdentifiers
|
|
188
|
+
.filter(id => !localIdentifiers.includes(id))
|
|
189
|
+
.map(identifier => ({ type, identifier }));
|
|
190
|
+
|
|
191
|
+
if (toRemove.length > 0) {
|
|
192
|
+
const confirmed = await confirmHardPush(toRemove);
|
|
193
|
+
if (!confirmed) {
|
|
194
|
+
logger.info('Operation cancelled by user.');
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Store the removal list for later (after push)
|
|
199
|
+
// We'll use an environment variable to pass it to the post-push cleanup
|
|
200
|
+
process.env.QELOS_HARD_PUSH_REMOVE = JSON.stringify(toRemove);
|
|
201
|
+
logger.info(`Will remove ${toRemove.length} ${type} after push completes`);
|
|
202
|
+
} else {
|
|
203
|
+
logger.info('No resources to remove');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
119
206
|
|
|
120
207
|
// Handle "all" or "*" type
|
|
121
208
|
if (type === 'all' || type === '*') {
|
|
@@ -144,6 +231,86 @@ export default async function pushController({ type, path: sourcePath }) {
|
|
|
144
231
|
continue;
|
|
145
232
|
}
|
|
146
233
|
|
|
234
|
+
// Get all files in the directory
|
|
235
|
+
const files = fs.readdirSync(typePath);
|
|
236
|
+
const resourceFiles = files.filter(file => {
|
|
237
|
+
if (name === 'components') return file.endsWith('.vue');
|
|
238
|
+
if (name === 'blocks') return file.endsWith('.html');
|
|
239
|
+
if (name === 'blueprints') return file.endsWith('.blueprint.json');
|
|
240
|
+
if (name === 'configs') return file.endsWith('.config.json');
|
|
241
|
+
if (name === 'plugins') return file.endsWith('.plugin.json');
|
|
242
|
+
if (name === 'integrations') return file.endsWith('.integration.json');
|
|
243
|
+
if (name === 'connections') return file.endsWith('.connection.json');
|
|
244
|
+
return false;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Check for duplicate identifiers (skip components and blocks as they can't have duplicates)
|
|
248
|
+
if (name !== 'components' && name !== 'blocks') {
|
|
249
|
+
const filePaths = resourceFiles.map(file => path.join(typePath, file));
|
|
250
|
+
const duplicates = checkDuplicateIdentifiers(filePaths, name, typePath);
|
|
251
|
+
if (duplicates.length > 0) {
|
|
252
|
+
displayDuplicateConflicts(duplicates, name);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Initialize SDK only after duplicate checks pass
|
|
259
|
+
const sdk = await initializeSdk();
|
|
260
|
+
|
|
261
|
+
// Handle hard push logic for "all" type
|
|
262
|
+
if (hard) {
|
|
263
|
+
logger.info('Checking for resources to remove...');
|
|
264
|
+
|
|
265
|
+
const toRemoveAll = [];
|
|
266
|
+
|
|
267
|
+
// Check each type
|
|
268
|
+
for (const { name } of types) {
|
|
269
|
+
if (!['components', 'blueprints', 'plugins', 'integrations'].includes(name)) continue;
|
|
270
|
+
|
|
271
|
+
const typePath = path.join(basePath, name);
|
|
272
|
+
if (!fs.existsSync(typePath)) continue;
|
|
273
|
+
|
|
274
|
+
// Get local files
|
|
275
|
+
const localFiles = getLocalFiles(typePath, name);
|
|
276
|
+
const localIdentifiers = localFiles.map(file => getIdentifierFromFile(file, name));
|
|
277
|
+
|
|
278
|
+
// Get remote resources
|
|
279
|
+
const remoteResources = await getRemoteResources(sdk, name);
|
|
280
|
+
const remoteIdentifiers = remoteResources.map(r => r.identifier);
|
|
281
|
+
|
|
282
|
+
// Find resources to remove
|
|
283
|
+
const toRemove = remoteIdentifiers
|
|
284
|
+
.filter(id => !localIdentifiers.includes(id))
|
|
285
|
+
.map(identifier => ({ type: name, identifier }));
|
|
286
|
+
|
|
287
|
+
toRemoveAll.push(...toRemove);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (toRemoveAll.length > 0) {
|
|
291
|
+
const confirmed = await confirmHardPush(toRemoveAll);
|
|
292
|
+
if (!confirmed) {
|
|
293
|
+
logger.info('Operation cancelled by user.');
|
|
294
|
+
process.exit(0);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Store the removal list for later (after push)
|
|
298
|
+
process.env.QELOS_HARD_PUSH_REMOVE = JSON.stringify(toRemoveAll);
|
|
299
|
+
logger.info(`Will remove ${toRemoveAll.length} resources after push completes`);
|
|
300
|
+
} else {
|
|
301
|
+
logger.info('No resources to remove');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Now push all types
|
|
306
|
+
for (const { name, fn } of types) {
|
|
307
|
+
const typePath = path.join(basePath, name);
|
|
308
|
+
|
|
309
|
+
// Skip if directory doesn't exist
|
|
310
|
+
if (!fs.existsSync(typePath)) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
147
314
|
logger.section(`Pushing ${name} from ${typePath}`);
|
|
148
315
|
try {
|
|
149
316
|
await fn(sdk, typePath);
|
|
@@ -159,6 +326,9 @@ export default async function pushController({ type, path: sourcePath }) {
|
|
|
159
326
|
|
|
160
327
|
logger.section(`Pushing ${type} from ${targetFile ? `${basePath} (${targetFile})` : basePath}`);
|
|
161
328
|
|
|
329
|
+
// Initialize SDK for individual type pushes
|
|
330
|
+
const sdk = await initializeSdk();
|
|
331
|
+
|
|
162
332
|
if (type === 'components') {
|
|
163
333
|
await pushComponents(sdk, basePath, { targetFile });
|
|
164
334
|
} else if (type === 'blueprints') {
|
|
@@ -205,5 +375,22 @@ export default async function pushController({ type, path: sourcePath }) {
|
|
|
205
375
|
logger.warning(`Failed to clean up temporary directory: ${tempDir}`, error);
|
|
206
376
|
}
|
|
207
377
|
}
|
|
378
|
+
|
|
379
|
+
// Handle hard push cleanup (remove resources after successful push)
|
|
380
|
+
if (process.env.QELOS_HARD_PUSH_REMOVE) {
|
|
381
|
+
try {
|
|
382
|
+
const toRemove = JSON.parse(process.env.QELOS_HARD_PUSH_REMOVE);
|
|
383
|
+
if (toRemove.length > 0) {
|
|
384
|
+
const sdk = await initializeSdk();
|
|
385
|
+
await removeResources(sdk, toRemove);
|
|
386
|
+
logger.success(`Removed ${toRemove.length} resources that no longer exist locally`);
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
logger.error('Failed to remove resources after push:', error);
|
|
390
|
+
} finally {
|
|
391
|
+
// Clean up the environment variable
|
|
392
|
+
delete process.env.QELOS_HARD_PUSH_REMOVE;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
208
395
|
}
|
|
209
396
|
}
|
package/jest.config.js
CHANGED
|
@@ -71,20 +71,23 @@ module.exports = {
|
|
|
71
71
|
// ],
|
|
72
72
|
|
|
73
73
|
// An array of file extensions your modules use
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
moduleFileExtensions: [
|
|
75
|
+
"js",
|
|
76
|
+
"jsx",
|
|
77
|
+
"ts",
|
|
78
|
+
"tsx",
|
|
79
|
+
"json",
|
|
80
|
+
"node",
|
|
81
|
+
"mjs"
|
|
82
|
+
],
|
|
82
83
|
|
|
83
84
|
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
|
84
|
-
|
|
85
|
+
moduleNameMapper: {
|
|
86
|
+
'^(\\.{1,2}/.*)\\.mjs$': '$1'
|
|
87
|
+
},
|
|
85
88
|
|
|
86
89
|
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
|
87
|
-
|
|
90
|
+
modulePathIgnorePatterns: [],
|
|
88
91
|
|
|
89
92
|
// Activates notifications for test results
|
|
90
93
|
// notify: false,
|
|
@@ -105,7 +108,7 @@ module.exports = {
|
|
|
105
108
|
// resetMocks: false,
|
|
106
109
|
|
|
107
110
|
// Reset the module registry before running each individual test
|
|
108
|
-
|
|
111
|
+
resetModules: false,
|
|
109
112
|
|
|
110
113
|
// A path to a custom resolver
|
|
111
114
|
// resolver: undefined,
|
|
@@ -137,7 +140,7 @@ module.exports = {
|
|
|
137
140
|
// snapshotSerializers: [],
|
|
138
141
|
|
|
139
142
|
// The test environment that will be used for testing
|
|
140
|
-
|
|
143
|
+
testEnvironment: "node",
|
|
141
144
|
|
|
142
145
|
// Options that will be passed to the testEnvironment
|
|
143
146
|
// testEnvironmentOptions: {},
|
|
@@ -146,10 +149,10 @@ module.exports = {
|
|
|
146
149
|
// testLocationInResults: false,
|
|
147
150
|
|
|
148
151
|
// The glob patterns Jest uses to detect test files
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
testMatch: [
|
|
153
|
+
"**/__tests__/**/*.[jt]s?(x)",
|
|
154
|
+
"**/?(*.)+(spec|test).[tj]s?(x)"
|
|
155
|
+
],
|
|
153
156
|
|
|
154
157
|
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
|
155
158
|
testPathIgnorePatterns: [
|
|
@@ -173,18 +176,11 @@ module.exports = {
|
|
|
173
176
|
// timers: "real",
|
|
174
177
|
|
|
175
178
|
// A map from regular expressions to paths to transformers
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// transformIgnorePatterns: [
|
|
180
|
-
// "/node_modules/",
|
|
181
|
-
// "\\.pnp\\.[^\\/]+$"
|
|
182
|
-
// ],
|
|
183
|
-
|
|
184
|
-
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
|
185
|
-
// unmockedModulePathPatterns: undefined,
|
|
179
|
+
transform: {
|
|
180
|
+
'^.+\\.mjs$': 'jest-esm-transformer'
|
|
181
|
+
},
|
|
186
182
|
|
|
187
|
-
//
|
|
183
|
+
// An array of regexp pattern strings that are matched against all source file paths before re-running tests in watch mode
|
|
188
184
|
// verbose: undefined,
|
|
189
185
|
|
|
190
186
|
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qelos/plugins-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "CLI to manage QELOS plugins",
|
|
5
5
|
"main": "cli.mjs",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@qelos/sdk": "^3.11.1",
|
|
18
18
|
"cli-progress": "^3.12.0",
|
|
19
|
-
"cli-select": "^1.1.2",
|
|
20
19
|
"decompress-zip": "^0.3.3",
|
|
21
20
|
"follow-redirects": "^1.15.11",
|
|
22
21
|
"jiti": "^2.6.1",
|
|
@@ -29,9 +28,10 @@
|
|
|
29
28
|
"access": "public"
|
|
30
29
|
},
|
|
31
30
|
"scripts": {
|
|
32
|
-
"test": "jest --runInBand"
|
|
31
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --runInBand"
|
|
33
32
|
},
|
|
34
33
|
"devDependencies": {
|
|
35
|
-
"jest": "^27.5.1"
|
|
34
|
+
"jest": "^27.5.1",
|
|
35
|
+
"jest-esm-transformer": "^1.0.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/services/components.mjs
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { logger } from './logger.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the identifier field name for a resource type
|
|
7
|
+
* @param {string} resourceType - Type of resource
|
|
8
|
+
* @returns {string} - The field name used as identifier
|
|
9
|
+
*/
|
|
10
|
+
function getIdentifierField(resourceType) {
|
|
11
|
+
const identifierFields = {
|
|
12
|
+
blueprints: 'identifier',
|
|
13
|
+
configs: 'key',
|
|
14
|
+
plugins: 'apiPath',
|
|
15
|
+
integrations: '_id',
|
|
16
|
+
connections: '_id'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return identifierFields[resourceType] || '_id';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract identifier from a resource file
|
|
24
|
+
* @param {string} filePath - Path to the resource file
|
|
25
|
+
* @param {string} resourceType - Type of resource
|
|
26
|
+
* @param {string} basePath - Base path for relative calculation (optional)
|
|
27
|
+
* @returns {string|null} - The identifier value or null if not found
|
|
28
|
+
*/
|
|
29
|
+
function extractIdentifier(filePath, resourceType, basePath = null) {
|
|
30
|
+
try {
|
|
31
|
+
// For JSON-based resources, parse and extract identifier
|
|
32
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
33
|
+
const data = JSON.parse(content);
|
|
34
|
+
const identifierField = getIdentifierField(resourceType);
|
|
35
|
+
|
|
36
|
+
return data[identifierField] || null;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logger.debug(`Failed to extract identifier from ${filePath}: ${error.message}`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check for duplicate identifiers in resource files
|
|
45
|
+
* @param {string[]} filePaths - Array of file paths to check
|
|
46
|
+
* @param {string} resourceType - Type of resource
|
|
47
|
+
* @param {string} basePath - Base path for relative calculation (optional)
|
|
48
|
+
* @returns {Object[]} - Array of duplicate groups
|
|
49
|
+
*/
|
|
50
|
+
function checkDuplicateIdentifiers(filePaths, resourceType, basePath = null) {
|
|
51
|
+
const identifierMap = new Map();
|
|
52
|
+
const duplicates = [];
|
|
53
|
+
|
|
54
|
+
for (const filePath of filePaths) {
|
|
55
|
+
const identifier = extractIdentifier(filePath, resourceType, basePath);
|
|
56
|
+
|
|
57
|
+
if (!identifier) {
|
|
58
|
+
continue; // Skip files without identifiers
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (identifierMap.has(identifier)) {
|
|
62
|
+
// Found a duplicate
|
|
63
|
+
const existing = identifierMap.get(identifier);
|
|
64
|
+
|
|
65
|
+
// Find if this duplicate group already exists
|
|
66
|
+
let duplicateGroup = duplicates.find(group => group.identifier === identifier);
|
|
67
|
+
|
|
68
|
+
if (!duplicateGroup) {
|
|
69
|
+
// Create new duplicate group
|
|
70
|
+
duplicateGroup = {
|
|
71
|
+
identifier,
|
|
72
|
+
files: [existing]
|
|
73
|
+
};
|
|
74
|
+
duplicates.push(duplicateGroup);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add current file to the group
|
|
78
|
+
duplicateGroup.files.push(filePath);
|
|
79
|
+
} else {
|
|
80
|
+
identifierMap.set(identifier, filePath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return duplicates;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Display duplicate identifier conflicts to the user
|
|
89
|
+
* @param {Object[]} duplicates - Array of duplicate groups
|
|
90
|
+
* @param {string} resourceType - Type of resource
|
|
91
|
+
*/
|
|
92
|
+
function displayDuplicateConflicts(duplicates, resourceType) {
|
|
93
|
+
if (duplicates.length === 0) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
logger.error(`\nFound ${duplicates.length} duplicate identifier conflict(s) in ${resourceType}:`);
|
|
98
|
+
|
|
99
|
+
for (const duplicate of duplicates) {
|
|
100
|
+
logger.error(`\n Duplicate identifier: "${duplicate.identifier}"`);
|
|
101
|
+
logger.error(` Found in ${duplicate.files.length} files:`);
|
|
102
|
+
|
|
103
|
+
duplicate.files.forEach(file => {
|
|
104
|
+
logger.error(` • ${path.relative(process.cwd(), file)}`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
logger.warning(` Please remove one of the duplicate files and try again.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
logger.error('\nPush aborted due to duplicate identifiers.');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export {
|
|
114
|
+
getIdentifierField,
|
|
115
|
+
extractIdentifier,
|
|
116
|
+
checkDuplicateIdentifiers,
|
|
117
|
+
displayDuplicateConflicts
|
|
118
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { red, yellow } from '../utils/colors.mjs';
|
|
4
|
+
import { logger } from './logger.mjs';
|
|
5
|
+
import { confirmDialog } from './interactive-select.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get local files from directory
|
|
9
|
+
*/
|
|
10
|
+
export function getLocalFiles(typePath, type) {
|
|
11
|
+
if (!fs.existsSync(typePath)) return [];
|
|
12
|
+
|
|
13
|
+
const files = fs.readdirSync(typePath);
|
|
14
|
+
return files.filter(file => {
|
|
15
|
+
if (type === 'components') return file.endsWith('.vue');
|
|
16
|
+
if (type === 'blueprints') return file.endsWith('.blueprint.json');
|
|
17
|
+
if (type === 'plugins') return file.endsWith('.plugin.json');
|
|
18
|
+
if (type === 'integrations') return file.endsWith('.integration.json');
|
|
19
|
+
return false;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get remote resources from Qelos
|
|
25
|
+
*/
|
|
26
|
+
export async function getRemoteResources(sdk, type) {
|
|
27
|
+
try {
|
|
28
|
+
switch (type) {
|
|
29
|
+
case 'components':
|
|
30
|
+
return await sdk.components.getList();
|
|
31
|
+
case 'blueprints':
|
|
32
|
+
return await sdk.manageBlueprints.getList();
|
|
33
|
+
case 'plugins':
|
|
34
|
+
return await sdk.managePlugins.getList();
|
|
35
|
+
case 'integrations':
|
|
36
|
+
return await sdk.integrations.getList();
|
|
37
|
+
default:
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
logger.error(`Failed to fetch remote ${type}:`, error);
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract identifier from filename
|
|
48
|
+
*/
|
|
49
|
+
export function getIdentifierFromFile(filename, type) {
|
|
50
|
+
if (type === 'components') {
|
|
51
|
+
return filename.replace('.vue', '');
|
|
52
|
+
}
|
|
53
|
+
if (type === 'blueprints') {
|
|
54
|
+
return filename.replace('.blueprint.json', '');
|
|
55
|
+
}
|
|
56
|
+
if (type === 'plugins') {
|
|
57
|
+
return filename.replace('.plugin.json', '');
|
|
58
|
+
}
|
|
59
|
+
if (type === 'integrations') {
|
|
60
|
+
return filename.replace('.integration.json', '');
|
|
61
|
+
}
|
|
62
|
+
return filename;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Show warning and get confirmation for hard push
|
|
67
|
+
*/
|
|
68
|
+
export async function confirmHardPush(toRemove) {
|
|
69
|
+
console.log(yellow('\n⚠️ WARNING: You are using the --hard flag'));
|
|
70
|
+
console.log(yellow('This will permanently remove resources from Qelos that don\'t exist locally.\n'));
|
|
71
|
+
|
|
72
|
+
console.log(red('The following resources will be removed:'));
|
|
73
|
+
toRemove.forEach(({ type, identifier }) => {
|
|
74
|
+
console.log(red(` - ${type}: ${identifier}`));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const message = '\nDo you want to continue?';
|
|
78
|
+
return await confirmDialog(message, false, {
|
|
79
|
+
noLabel: 'No (default)',
|
|
80
|
+
yesLabel: 'Yes, remove them'
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Remove resources from Qelos
|
|
86
|
+
*/
|
|
87
|
+
export async function removeResources(sdk, toRemove) {
|
|
88
|
+
for (const { type, identifier } of toRemove) {
|
|
89
|
+
try {
|
|
90
|
+
switch (type) {
|
|
91
|
+
case 'components':
|
|
92
|
+
// For components, we need to find the component by identifier first
|
|
93
|
+
const components = await sdk.components.getList();
|
|
94
|
+
const component = components.find(c => c.identifier === identifier);
|
|
95
|
+
if (component) {
|
|
96
|
+
await sdk.components.remove(component._id);
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
case 'blueprints':
|
|
100
|
+
await sdk.manageBlueprints.remove(identifier);
|
|
101
|
+
break;
|
|
102
|
+
case 'plugins':
|
|
103
|
+
// For plugins, we need to find the plugin by key first
|
|
104
|
+
const plugins = await sdk.managePlugins.getList();
|
|
105
|
+
const plugin = plugins.find(p => p.key === identifier);
|
|
106
|
+
if (plugin) {
|
|
107
|
+
await sdk.managePlugins.remove(plugin._id);
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
case 'integrations':
|
|
111
|
+
// For integrations, we need to find the integration by identifier first
|
|
112
|
+
const integrations = await sdk.integrations.getList();
|
|
113
|
+
const integration = integrations.find(i => i.identifier === identifier);
|
|
114
|
+
if (integration) {
|
|
115
|
+
await sdk.integrations.remove(integration._id);
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
logger.info(`Removed ${type}: ${identifier}`);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.error(`Failed to remove ${type}: ${identifier}`, error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -6,6 +6,7 @@ import { extractIntegrationContent, resolveReferences } from './file-refs.mjs';
|
|
|
6
6
|
const INTEGRATION_FILE_EXTENSION = '.integration.json';
|
|
7
7
|
const INTEGRATIONS_API_PATH = '/api/integrations';
|
|
8
8
|
const SERVER_ONLY_FIELDS = ['tenant', 'plugin', 'user', 'created', 'updated', '__v'];
|
|
9
|
+
const CONNECTION_FILE_EXTENSION = '.connection.json';
|
|
9
10
|
|
|
10
11
|
function slugify(value = '') {
|
|
11
12
|
return value
|
|
@@ -101,6 +102,136 @@ async function fetchIntegrations(sdk) {
|
|
|
101
102
|
return sdk.callJsonApi(INTEGRATIONS_API_PATH);
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Load all connection files and create a map of _id to file path
|
|
107
|
+
* @param {string} basePath - Base path to search for connection files
|
|
108
|
+
* @returns {Map<string, string>} Map of connection ID to relative file path
|
|
109
|
+
*/
|
|
110
|
+
function loadConnectionIdMap(basePath) {
|
|
111
|
+
const connectionMap = new Map();
|
|
112
|
+
|
|
113
|
+
// Check if connections directory exists
|
|
114
|
+
const connectionsPath = join(basePath, 'connections');
|
|
115
|
+
if (!fs.existsSync(connectionsPath)) {
|
|
116
|
+
logger.warning('Connections directory not found, connection references will not be mapped');
|
|
117
|
+
return connectionMap;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const connectionFiles = fs.readdirSync(connectionsPath)
|
|
122
|
+
.filter(file => file.endsWith(CONNECTION_FILE_EXTENSION));
|
|
123
|
+
|
|
124
|
+
for (const file of connectionFiles) {
|
|
125
|
+
const filePath = join(connectionsPath, file);
|
|
126
|
+
try {
|
|
127
|
+
const connectionData = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
128
|
+
if (connectionData._id) {
|
|
129
|
+
// Store relative path from the integrations directory
|
|
130
|
+
const relativePath = `./connections/${file}`;
|
|
131
|
+
connectionMap.set(connectionData._id, relativePath);
|
|
132
|
+
logger.debug(`Mapped connection ID ${connectionData._id} to ${relativePath}`);
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
logger.warning(`Failed to read connection file ${file}: ${error.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
logger.info(`Loaded ${connectionMap.size} connection mappings`);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
logger.error('Failed to load connection files', error);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return connectionMap;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Replace connection IDs with $refId objects in an integration
|
|
149
|
+
* @param {Object} integration - Integration object
|
|
150
|
+
* @param {Map<string, string>} connectionMap - Map of connection ID to file path
|
|
151
|
+
* @returns {Object} Integration with connection IDs replaced by $refId objects
|
|
152
|
+
*/
|
|
153
|
+
function replaceConnectionIds(integration, connectionMap) {
|
|
154
|
+
const updated = JSON.parse(JSON.stringify(integration));
|
|
155
|
+
|
|
156
|
+
// Replace trigger.source if it's a connection ID
|
|
157
|
+
if (updated.trigger?.source && typeof updated.trigger.source === 'string') {
|
|
158
|
+
const connectionPath = connectionMap.get(updated.trigger.source);
|
|
159
|
+
if (connectionPath) {
|
|
160
|
+
updated.trigger.source = { $refId: connectionPath };
|
|
161
|
+
logger.debug(`Replaced trigger.source ${integration.trigger.source} with ${connectionPath}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Replace target.source if it's a connection ID
|
|
166
|
+
if (updated.target?.source && typeof updated.target.source === 'string') {
|
|
167
|
+
const connectionPath = connectionMap.get(updated.target.source);
|
|
168
|
+
if (connectionPath) {
|
|
169
|
+
updated.target.source = { $refId: connectionPath };
|
|
170
|
+
logger.debug(`Replaced target.source ${integration.target.source} with ${connectionPath}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return updated;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve $refId objects to actual connection IDs
|
|
179
|
+
* @param {Object} integration - Integration object with potential $refId references
|
|
180
|
+
* @param {string} basePath - Base path for resolving connection files
|
|
181
|
+
* @returns {Object} Integration with $refId objects resolved to IDs
|
|
182
|
+
*/
|
|
183
|
+
function resolveConnectionReferences(integration, basePath) {
|
|
184
|
+
const updated = JSON.parse(JSON.stringify(integration));
|
|
185
|
+
|
|
186
|
+
// Resolve trigger.source if it's a $refId object
|
|
187
|
+
if (updated.trigger?.source && typeof updated.trigger.source === 'object' && updated.trigger.source.$refId) {
|
|
188
|
+
const connectionPath = updated.trigger.source.$refId;
|
|
189
|
+
const fullConnectionPath = join(basePath, connectionPath);
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
if (fs.existsSync(fullConnectionPath)) {
|
|
193
|
+
const connectionData = JSON.parse(fs.readFileSync(fullConnectionPath, 'utf-8'));
|
|
194
|
+
if (connectionData._id) {
|
|
195
|
+
updated.trigger.source = connectionData._id;
|
|
196
|
+
logger.debug(`Resolved trigger.source ${connectionPath} to ID ${connectionData._id}`);
|
|
197
|
+
} else {
|
|
198
|
+
throw new Error('Connection file missing _id field');
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
throw new Error(`Connection file not found: ${fullConnectionPath}`);
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
logger.error(`Failed to resolve trigger.source reference ${connectionPath}: ${error.message}`);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Resolve target.source if it's a $refId object
|
|
210
|
+
if (updated.target?.source && typeof updated.target.source === 'object' && updated.target.source.$refId) {
|
|
211
|
+
const connectionPath = updated.target.source.$refId;
|
|
212
|
+
const fullConnectionPath = join(basePath, connectionPath);
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
if (fs.existsSync(fullConnectionPath)) {
|
|
216
|
+
const connectionData = JSON.parse(fs.readFileSync(fullConnectionPath, 'utf-8'));
|
|
217
|
+
if (connectionData._id) {
|
|
218
|
+
updated.target.source = connectionData._id;
|
|
219
|
+
logger.debug(`Resolved target.source ${connectionPath} to ID ${connectionData._id}`);
|
|
220
|
+
} else {
|
|
221
|
+
throw new Error('Connection file missing _id field');
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
throw new Error(`Connection file not found: ${fullConnectionPath}`);
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
logger.error(`Failed to resolve target.source reference ${connectionPath}: ${error.message}`);
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return updated;
|
|
233
|
+
}
|
|
234
|
+
|
|
104
235
|
async function createIntegration(sdk, payload) {
|
|
105
236
|
return sdk.callJsonApi(INTEGRATIONS_API_PATH, {
|
|
106
237
|
method: 'post',
|
|
@@ -138,13 +269,19 @@ export async function pullIntegrations(sdk, targetPath) {
|
|
|
138
269
|
|
|
139
270
|
logger.info(`Found ${integrations.length} integration(s) to pull`);
|
|
140
271
|
|
|
272
|
+
// Load connection ID mappings before processing integrations
|
|
273
|
+
const connectionMap = loadConnectionIdMap(targetPath);
|
|
274
|
+
|
|
141
275
|
const usedNames = new Set();
|
|
142
276
|
integrations.forEach((integration, index) => {
|
|
143
277
|
const fileName = buildFileName(integration, index, usedNames);
|
|
144
278
|
const filePath = join(targetPath, fileName);
|
|
145
279
|
|
|
280
|
+
// Replace connection IDs with $refId references
|
|
281
|
+
const integrationWithRefs = replaceConnectionIds(integration, connectionMap);
|
|
282
|
+
|
|
146
283
|
// Extract content to files for AI agents
|
|
147
|
-
const processedIntegration = extractIntegrationContent(
|
|
284
|
+
const processedIntegration = extractIntegrationContent(integrationWithRefs, targetPath, fileName);
|
|
148
285
|
|
|
149
286
|
writeIntegrationFile(filePath, sanitizeIntegrationForFile(processedIntegration));
|
|
150
287
|
logger.step(`Pulled: ${getIntegrationDisplayName(integration) || integration._id || fileName}`);
|
|
@@ -178,8 +315,11 @@ export async function pushIntegrations(sdk, path, options = {}) {
|
|
|
178
315
|
const integrationData = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
179
316
|
validateIntegrationPayload(integrationData, file);
|
|
180
317
|
|
|
318
|
+
// Resolve $refId objects to actual connection IDs
|
|
319
|
+
const integrationWithResolvedRefs = resolveConnectionReferences(integrationData, path);
|
|
320
|
+
|
|
181
321
|
// Resolve any $ref references in the integration
|
|
182
|
-
const resolvedIntegration = await resolveReferences(
|
|
322
|
+
const resolvedIntegration = await resolveReferences(integrationWithResolvedRefs, path);
|
|
183
323
|
|
|
184
324
|
const payload = toRequestPayload(resolvedIntegration);
|
|
185
325
|
const displayName = getIntegrationDisplayName(resolvedIntegration) || file.replace(INTEGRATION_FILE_EXTENSION, '');
|
|
@@ -199,8 +339,12 @@ export async function pushIntegrations(sdk, path, options = {}) {
|
|
|
199
339
|
// This ensures pre_messages are stored as prompt md files after pushing
|
|
200
340
|
const processedResponse = extractIntegrationContent(response, path, file);
|
|
201
341
|
|
|
342
|
+
// Replace connection IDs with $refId objects again for the stored file
|
|
343
|
+
const connectionMap = loadConnectionIdMap(path);
|
|
344
|
+
const finalIntegration = replaceConnectionIds(processedResponse, connectionMap);
|
|
345
|
+
|
|
202
346
|
// Persist returned integration (with _id) back to disk
|
|
203
|
-
writeIntegrationFile(filePath, sanitizeIntegrationForFile(
|
|
347
|
+
writeIntegrationFile(filePath, sanitizeIntegrationForFile(finalIntegration));
|
|
204
348
|
|
|
205
349
|
results.push({ status: 'fulfilled' });
|
|
206
350
|
} catch (error) {
|
|
@@ -220,3 +364,6 @@ export async function pushIntegrations(sdk, path, options = {}) {
|
|
|
220
364
|
|
|
221
365
|
logger.info(`Pushed ${integrationFiles.length} integration(s)`);
|
|
222
366
|
}
|
|
367
|
+
|
|
368
|
+
// Export helper functions for testing
|
|
369
|
+
export { loadConnectionIdMap, replaceConnectionIds, resolveConnectionReferences };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as readline from 'node:readline';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create an interactive selection with arrow key support
|
|
5
|
+
* @param {Object} options - Selection options
|
|
6
|
+
* @param {Object} options.values - Key-value pairs of option id and label
|
|
7
|
+
* @param {string} [options.defaultValue] - Default option id
|
|
8
|
+
* @param {string} [options.message] - Message to display
|
|
9
|
+
* @returns {Promise<{id: string, value: string}>} Selected option
|
|
10
|
+
*/
|
|
11
|
+
export async function interactiveSelect({ values, defaultValue, message }) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const rl = readline.createInterface({
|
|
14
|
+
input: process.stdin,
|
|
15
|
+
output: process.stdout
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Hide cursor for cleaner display
|
|
19
|
+
process.stdout.write('\x1B[?25l');
|
|
20
|
+
|
|
21
|
+
const options = Object.entries(values);
|
|
22
|
+
let selectedIndex = defaultValue ? options.findIndex(([id]) => id === defaultValue) : 0;
|
|
23
|
+
|
|
24
|
+
if (message) {
|
|
25
|
+
console.log(message);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const render = () => {
|
|
29
|
+
// Move cursor up to overwrite previous options
|
|
30
|
+
process.stdout.write('\x1B[2K\r');
|
|
31
|
+
for (let i = 0; i < options.length; i++) {
|
|
32
|
+
if (i === 0) {
|
|
33
|
+
process.stdout.write('\x1B[2K\r');
|
|
34
|
+
} else {
|
|
35
|
+
process.stdout.write('\n\x1B[2K\r');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (i === selectedIndex) {
|
|
39
|
+
process.stdout.write(`(•) ${options[i][1]}`);
|
|
40
|
+
} else {
|
|
41
|
+
process.stdout.write(`( ) ${options[i][1]}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Move cursor back to first option
|
|
46
|
+
if (options.length > 1) {
|
|
47
|
+
process.stdout.write(`\x1B[${options.length - 1}A`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const cleanup = () => {
|
|
52
|
+
process.stdout.write('\x1B[?25h'); // Show cursor
|
|
53
|
+
rl.close();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
render();
|
|
57
|
+
|
|
58
|
+
// Handle stdin for arrow keys and Enter
|
|
59
|
+
readline.emitKeypressEvents(process.stdin);
|
|
60
|
+
process.stdin.setRawMode(true);
|
|
61
|
+
|
|
62
|
+
process.stdin.on('keypress', (str, key) => {
|
|
63
|
+
if (key.name === 'up') {
|
|
64
|
+
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
65
|
+
render();
|
|
66
|
+
} else if (key.name === 'down') {
|
|
67
|
+
selectedIndex = Math.min(options.length - 1, selectedIndex + 1);
|
|
68
|
+
render();
|
|
69
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
70
|
+
cleanup();
|
|
71
|
+
console.log(); // Add newline after selection
|
|
72
|
+
resolve({ id: options[selectedIndex][0], value: options[selectedIndex][1] });
|
|
73
|
+
} else if (key.name === 'escape' || key.ctrl && key.name === 'c') {
|
|
74
|
+
cleanup();
|
|
75
|
+
console.log('\nOperation cancelled.');
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create a yes/no confirmation dialog
|
|
84
|
+
* @param {string} message - Message to display
|
|
85
|
+
* @param {boolean} [defaultValue=false] - Default value
|
|
86
|
+
* @param {Object} [options] - Additional options
|
|
87
|
+
* @param {string} [options.noLabel='No'] - Label for No option
|
|
88
|
+
* @param {string} [options.yesLabel='Yes'] - Label for Yes option
|
|
89
|
+
* @returns {Promise<boolean>} User's choice
|
|
90
|
+
*/
|
|
91
|
+
export async function confirmDialog(message, defaultValue = false, { noLabel = 'No', yesLabel = 'Yes' } = {}) {
|
|
92
|
+
const result = await interactiveSelect({
|
|
93
|
+
values: {
|
|
94
|
+
no: noLabel,
|
|
95
|
+
yes: yesLabel
|
|
96
|
+
},
|
|
97
|
+
defaultValue: defaultValue ? 'yes' : 'no',
|
|
98
|
+
message
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return result.id === 'yes';
|
|
102
|
+
}
|