@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 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
  }
@@ -1,5 +1,5 @@
1
1
  import follow from "follow-redirects";
2
- import cliSelect from "cli-select";
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 cliSelect({
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 cliSelect({
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") {
@@ -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) {
@@ -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
- const sdk = await initializeSdk();
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
- // moduleFileExtensions: [
75
- // "js",
76
- // "jsx",
77
- // "ts",
78
- // "tsx",
79
- // "json",
80
- // "node"
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
- // moduleNameMapper: {},
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
- // modulePathIgnorePatterns: [],
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
- // resetModules: false,
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
- // testEnvironment: "jest-environment-node",
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
- // testMatch: [
150
- // "**/__tests__/**/*.[jt]s?(x)",
151
- // "**/?(*.)+(spec|test).[tj]s?(x)"
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
- // transform: undefined,
177
-
178
- // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
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
- // Indicates whether each individual test should be reported during the run
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.0",
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
  }
@@ -186,3 +186,5 @@ export async function pullComponents(sdk, targetPath) {
186
186
  logger.info(`Saved components.json with metadata`);
187
187
  logger.info(`Pulled ${filteredComponentsInformation.length} component(s)`);
188
188
  }
189
+
190
+ export { listVueFilesRecursively };
@@ -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(integration, targetPath, fileName);
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(integrationData, path);
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(processedResponse));
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
+ }