@qelos/plugins-cli 0.0.30 → 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/cli.mjs CHANGED
@@ -11,6 +11,7 @@ import pushCommand from './commands/push.mjs';
11
11
  import pullCommand from './commands/pull.mjs';
12
12
  import generateCommand from './commands/generate.mjs';
13
13
  import blueprintsCommand from './commands/blueprints.mjs';
14
+ import getCommand from './commands/get.mjs';
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
 
16
17
  const program = yargs(hideBin(process.argv));
@@ -28,5 +29,6 @@ pushCommand(program)
28
29
  pullCommand(program)
29
30
  generateCommand(program)
30
31
  blueprintsCommand(program)
32
+ getCommand(program)
31
33
 
32
34
  program.help().argv;
@@ -0,0 +1,26 @@
1
+ import getController from "../controllers/get.mjs";
2
+
3
+ export default function getCommand(program) {
4
+ program
5
+ .command('get [type] [path]', 'get files from git without pushing. Ability to view components, blueprints, configurations, plugins, blocks, committed files, or staged files.',
6
+ (yargs) => {
7
+ return yargs
8
+ .positional('type', {
9
+ describe: 'Type of the resource to get. Can be components, blueprints, configurations, plugins, blocks, integrations, connections, committed, staged, or all.',
10
+ type: 'string',
11
+ choices: ['components', 'blueprints', 'configs', 'plugins', 'blocks', 'integrations', 'connections', 'committed', 'staged', 'all', '*'],
12
+ required: true
13
+ })
14
+ .positional('path', {
15
+ describe: 'Path to search for resources.',
16
+ type: 'string',
17
+ required: true
18
+ })
19
+ .option('json', {
20
+ alias: 'j',
21
+ type: 'boolean',
22
+ description: 'Output in JSON format'
23
+ })
24
+ },
25
+ getController)
26
+ }
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") {
@@ -0,0 +1,54 @@
1
+ import { logger } from '../services/logger.mjs';
2
+ import { getGitFiles } from '../services/git-files.mjs';
3
+ import { green, blue, yellow, red } from '../utils/colors.mjs';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ export default async function getController(argv) {
8
+ const { type, path: basePath, json, verbose } = argv;
9
+
10
+ if (verbose) {
11
+ process.env.VERBOSE = 'true';
12
+ }
13
+
14
+ try {
15
+ // For git-based types (committed, staged)
16
+ if (type === 'committed' || type === 'staged') {
17
+ const classified = getGitFiles(type, basePath || '.');
18
+
19
+ if (json) {
20
+ console.log(JSON.stringify(classified, null, 2));
21
+ return;
22
+ }
23
+
24
+ // Display results
25
+ console.log(blue(`\n=== ${type.toUpperCase()} FILES ===\n`));
26
+
27
+ Object.entries(classified).forEach(([fileType, files]) => {
28
+ if (files.length > 0) {
29
+ console.log(yellow(`${fileType.toUpperCase()} (${files.length}):`));
30
+ files.forEach(file => {
31
+ const relativePath = path.relative(basePath || '.', file);
32
+ console.log(` - ${relativePath}`);
33
+ });
34
+ console.log('');
35
+ }
36
+ });
37
+
38
+ return;
39
+ }
40
+
41
+ // For other types, we would need to implement similar logic to push
42
+ // For now, just show what would be searched for
43
+ console.log(blue(`\n=== SEARCH FOR ${type.toUpperCase()} ===\n`));
44
+ console.log(yellow(`Base path: ${basePath || '.'}`));
45
+ console.log(red(`Note: Only 'committed' and 'staged' types are currently supported for preview.`));
46
+
47
+ } catch (error) {
48
+ logger.error(error.message);
49
+ if (verbose) {
50
+ console.error(error.stack);
51
+ }
52
+ process.exit(1);
53
+ }
54
+ }
@@ -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.0.30",
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
+ };
@@ -73,13 +73,17 @@ function getCommittedFiles() {
73
73
  }
74
74
 
75
75
  /**
76
- * Get the list of staged files
76
+ * Get the list of staged files (excluding deleted files)
77
77
  * @returns {string[]} Array of file paths
78
78
  */
79
79
  function getStagedFiles() {
80
80
  try {
81
- const output = execSync('git diff --cached --name-only', { encoding: 'utf-8' });
82
- return output.trim().split('\n').filter(file => file);
81
+ // Use --name-status to get file status, then filter out deleted files (status D)
82
+ const output = execSync('git diff --cached --name-status', { encoding: 'utf-8' });
83
+ return output.trim()
84
+ .split('\n')
85
+ .filter(line => line && !line.startsWith('D')) // Skip deleted files
86
+ .map(line => line.substring(1).trim()); // Remove status prefix and get file path
83
87
  } catch (error) {
84
88
  logger.error('Failed to get staged files', error);
85
89
  throw new Error('Unable to retrieve staged files from git');
@@ -130,11 +134,95 @@ function findReferencingConfigs(refPath, basePath) {
130
134
  return referencingConfigs;
131
135
  }
132
136
 
137
+ /**
138
+ * Find plugins that reference a specific HTML file through $ref
139
+ * @param {string} htmlRelativePath - Relative path of the HTML file from the plugin directory
140
+ * @param {string} basePath - Base path to search for plugins
141
+ * @returns {Array} Array of plugin.json file paths that reference the HTML file
142
+ */
143
+ function findReferencingPlugins(htmlRelativePath, basePath) {
144
+ const referencingPlugins = [];
145
+ // Ensure basePath is absolute
146
+ const absoluteBasePath = path.resolve(basePath);
147
+ const pluginsDir = path.join(absoluteBasePath, 'plugins');
148
+
149
+ if (!fs.existsSync(pluginsDir)) {
150
+ return referencingPlugins;
151
+ }
152
+
153
+ // Get all plugin directories and .plugin.json files in the plugins directory
154
+ const pluginDirs = fs.readdirSync(pluginsDir, { withFileTypes: true })
155
+ .filter(dirent => dirent.isDirectory())
156
+ .map(dirent => dirent.name);
157
+
158
+ // Also check for .plugin.json files directly in the plugins directory
159
+ const pluginFiles = fs.readdirSync(pluginsDir)
160
+ .filter(file => file.endsWith('.plugin.json'));
161
+
162
+ // Check plugin files in subdirectories
163
+ for (const pluginDir of pluginDirs) {
164
+ const pluginJsonPath = path.join(pluginsDir, pluginDir, 'plugin.json');
165
+
166
+ if (fs.existsSync(pluginJsonPath)) {
167
+ try {
168
+ const content = fs.readFileSync(pluginJsonPath, 'utf-8');
169
+ const plugin = JSON.parse(content);
170
+
171
+ // Check all $ref references in the plugin
172
+ const refs = findAllRefs(plugin);
173
+
174
+ // Check if any ref matches our target HTML file
175
+ // The ref might be like "./micro-frontends/categories.html"
176
+ for (const ref of refs) {
177
+ if (ref.includes(htmlRelativePath) || ref.endsWith(path.basename(htmlRelativePath))) {
178
+ if (!referencingPlugins.includes(pluginJsonPath)) {
179
+ referencingPlugins.push(pluginJsonPath);
180
+ }
181
+ logger.debug(`Plugin ${pluginDir} references HTML file: ${ref}`);
182
+ break;
183
+ }
184
+ }
185
+ } catch (error) {
186
+ logger.warning(`Error reading plugin.json ${pluginJsonPath}: ${error.message}`);
187
+ }
188
+ }
189
+ }
190
+
191
+ // Check .plugin.json files directly in plugins directory
192
+ for (const pluginFile of pluginFiles) {
193
+ const pluginJsonPath = path.join(pluginsDir, pluginFile);
194
+
195
+ try {
196
+ const content = fs.readFileSync(pluginJsonPath, 'utf-8');
197
+ const plugin = JSON.parse(content);
198
+
199
+ // Check all $ref references in the plugin
200
+ const refs = findAllRefs(plugin);
201
+
202
+ // Check if any ref matches our target HTML file
203
+ // The ref might be like "./micro-frontends/categories.html"
204
+ for (const ref of refs) {
205
+ if (ref.includes(htmlRelativePath) || ref.endsWith(path.basename(htmlRelativePath))) {
206
+ if (!referencingPlugins.includes(pluginJsonPath)) {
207
+ referencingPlugins.push(pluginJsonPath);
208
+ }
209
+ logger.debug(`Plugin ${pluginFile} references HTML file: ${ref}`);
210
+ break;
211
+ }
212
+ }
213
+ } catch (error) {
214
+ logger.warning(`Error reading plugin.json ${pluginJsonPath}: ${error.message}`);
215
+ }
216
+ }
217
+
218
+ return referencingPlugins;
219
+ }
220
+
133
221
  /**
134
222
  * Find integration files that reference a specific file via $ref
135
- * @param {string} refPath - The referenced file path (relative)
223
+ * @param {string} refPath - Reference path to look for
136
224
  * @param {string} basePath - Base path to search for integrations
137
- * @returns {string[]} Array of integration file paths that reference the file
225
+ * @returns {Array} Array of integration file paths that reference the target
138
226
  */
139
227
  function findReferencingIntegrations(refPath, basePath) {
140
228
  const referencingIntegrations = [];
@@ -240,21 +328,23 @@ function classifyFiles(files, basePath) {
240
328
  const basename = path.basename(fullPath, ext);
241
329
 
242
330
  // Check for specific file types
243
- if (relativePath.includes('components/') && ext === '.vue') {
331
+ if (relativePath.startsWith('components/') && ext === '.vue') {
244
332
  classified.components.push(fullPath);
245
- } else if (relativePath.includes('blueprints/') && ext === '.json') {
333
+ } else if (relativePath.startsWith('blueprints/') && ext === '.json') {
246
334
  classified.blueprints.push(fullPath);
247
- } else if (relativePath.includes('configs/') && ext === '.json') {
335
+ } else if (relativePath.startsWith('configs/') && ext === '.json') {
248
336
  classified.configs.push(fullPath);
249
- } else if (relativePath.includes('plugins/') && ext === '.json') {
250
- classified.plugins.push(fullPath);
251
- } else if (relativePath.includes('blocks/') && ext === '.json') {
337
+ } else if (relativePath.startsWith('plugins/') && ext === '.json') {
338
+ if (!classified.plugins.includes(fullPath)) {
339
+ classified.plugins.push(fullPath);
340
+ }
341
+ } else if (relativePath.startsWith('blocks/') && ext === '.json') {
252
342
  classified.blocks.push(fullPath);
253
- } else if (relativePath.includes('integrations/') && ext === '.json') {
343
+ } else if (relativePath.startsWith('integrations/') && ext === '.json') {
254
344
  classified.integrations.push(fullPath);
255
- } else if (relativePath.includes('connections/') && ext === '.json') {
345
+ } else if (relativePath.startsWith('connections/') && ext === '.json') {
256
346
  classified.connections.push(fullPath);
257
- } else if (dir.includes('prompts') && ext === '.md') {
347
+ } else if (relativePath.startsWith('integrations/prompts/') && ext === '.md') {
258
348
  // Find integrations that reference this prompt file
259
349
  classified.prompts.push(fullPath);
260
350
 
@@ -277,7 +367,7 @@ function classifyFiles(files, basePath) {
277
367
  // 2. In configs directory -> These are typically referenced by configs, not pushed directly
278
368
  // 3. Other locations -> treat as micro-frontends
279
369
 
280
- if (relativePath.includes('configs/') || relativePath.includes('configs\\')) {
370
+ if (relativePath.startsWith('configs/') || relativePath.startsWith('configs\\')) {
281
371
  // HTML file in configs directory - these are usually referenced by config files, not pushed directly
282
372
  logger.debug(`Found HTML file in configs directory (will be pushed via referencing config): ${relativePath}`);
283
373
 
@@ -296,44 +386,41 @@ function classifyFiles(files, basePath) {
296
386
  // Find plugins that contain this HTML file (micro-frontends)
297
387
  classified.microFrontends.push(fullPath);
298
388
 
299
- // For HTML files, we need to find which plugin contains them
300
- // HTML files in plugins are typically part of the plugin structure
301
- let pluginDir = path.dirname(fullPath);
302
- let pluginJson = path.join(pluginDir, 'plugin.json');
389
+ // For HTML files, we need to find which plugin references them
390
+ // The HTML file should be in a micro-frontends directory within a plugin
391
+ const htmlBasename = path.basename(fullPath);
303
392
 
304
- // If the file is in a temp path or unusual location, try to find the actual plugin
305
- if (!fs.existsSync(pluginJson)) {
306
- // Check if we're in a micro-frontends subdirectory
307
- if (path.basename(pluginDir) === 'micro-frontends' ||
308
- relativePath.includes('micro-frontends/') ||
309
- relativePath.includes('micro-frontends\\')) {
310
- // Go up one more level to find the plugin directory
311
- pluginDir = path.dirname(pluginDir);
312
- pluginJson = path.join(pluginDir, 'plugin.json');
393
+ // Try to find plugins that reference this HTML file
394
+ // The ref path should be relative to the plugin directory
395
+ // e.g., "./micro-frontends/categories.html"
396
+ let refPath = `./micro-frontends/${htmlBasename}`;
397
+
398
+ // If the file is in a nested path, preserve that structure
399
+ if (relativePath.startsWith('plugins/micro-frontends/')) {
400
+ const parts = relativePath.split('plugins/micro-frontends/');
401
+ if (parts[1]) {
402
+ refPath = `./micro-frontends/${parts[1]}`;
313
403
  }
314
-
315
- // If still not found, try searching for plugin.json in parent directories
316
- if (!fs.existsSync(pluginJson)) {
317
- let searchDir = pluginDir;
318
- for (let i = 0; i < 3; i++) { // Search up to 3 levels up
319
- searchDir = path.dirname(searchDir);
320
- const testPluginJson = path.join(searchDir, 'plugin.json');
321
- if (fs.existsSync(testPluginJson)) {
322
- pluginJson = testPluginJson;
323
- break;
324
- }
325
- }
404
+ } else if (relativePath.includes('micro-frontends/')) {
405
+ // Handle other micro-frontends paths
406
+ const parts = relativePath.split('micro-frontends/');
407
+ if (parts[1]) {
408
+ refPath = `./micro-frontends/${parts[1]}`;
326
409
  }
327
410
  }
328
411
 
329
- if (fs.existsSync(pluginJson)) {
330
- // This HTML file is part of a plugin
331
- if (!classified.plugins.includes(pluginJson)) {
332
- classified.plugins.push(pluginJson);
333
- logger.debug(`Found plugin containing HTML ${relativePath}: ${path.basename(pluginJson)}`);
412
+ const referencingPlugins = findReferencingPlugins(refPath, basePath);
413
+
414
+ // Add all referencing plugins to the classified list
415
+ for (const pluginPath of referencingPlugins) {
416
+ if (!classified.plugins.includes(pluginPath)) {
417
+ classified.plugins.push(pluginPath);
418
+ logger.debug(`Found plugin referencing HTML ${relativePath}: ${path.basename(pluginPath)}`);
334
419
  }
335
- } else {
336
- logger.warning(`Could not find plugin.json for HTML file: ${relativePath}`);
420
+ }
421
+
422
+ if (referencingPlugins.length === 0) {
423
+ logger.warning(`Could not find any plugin referencing HTML file: ${relativePath} (searched for ref: ${refPath})`);
337
424
  }
338
425
  }
339
426
  } else {
@@ -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
+ }