Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,21 @@ wf sync
**Use when**: You modified existing components and want to update them

```bash
wf update # Process changed files in Git (CSX + JSON)
wf update --all # Update all (asks for confirmation)
wf update --file x.json # Process a single file
wf update # Process changed files in Git (CSX + JSON)
wf update --all # Update all (asks for confirmation)
wf update --file x.json # Process a single file
wf update --folder person # Process every component under a feature folder, ignoring Git
```

**`--folder <name>` (`-d`)**: Updates every component belonging to a feature, across all component types, regardless of Git status. It resolves `<name>` in two ways:
- **Feature name** (e.g. `person`): matches `<name>` under every component-type root (`Workflows/person`, `Tasks/person`, `Views/person`, `Schemas/person`, …) and updates all of them together.
- **Exact path** (e.g. `Workflows/person` or an absolute path): updates only that specific folder.

If nothing matches, the command lists the feature folder names it did find so you can correct a typo. `--file` takes precedence over `--folder`, which takes precedence over `--all`.

```bash
wf update --folder person # Every "person" folder across all component types
wf update -d Workflows/person # Only Workflows/person
```

---
Expand Down Expand Up @@ -424,6 +436,15 @@ wf reset
wf csx
```

### 5b. Update a Whole Feature Folder (No Git Needed)
```bash
# Update every "person" component across Workflows/, Tasks/, Views/, Schemas/, ...
wf update --folder person

# Or target one exact folder
wf update --folder Workflows/person
```

### 6. Multidomain Workflow
```bash
# Add domains (one-time setup)
Expand Down Expand Up @@ -454,6 +475,7 @@ wf domain list
|---------|----------|-----------------|------------|----------|
| `sync` | Yes | Skip | Publish | Add missing components |
| `update` | Yes | Delete + Publish | Publish | Update changed components |
| `update --folder <name>` | Yes | Delete + Publish | Publish | Update every component in a feature folder (ignores Git) |
| `reset` | Yes | Delete + Publish | Publish | Force reset components |
| `csx` | No | N/A | N/A | Only update CSX in JSONs |

Expand Down
11 changes: 11 additions & 0 deletions bin/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ program
.description('Update workflows')
.option('-a, --all', 'Update all workflows')
.option('-f, --file <path>', 'Update a specific workflow')
.option('-d, --folder <name>', 'Update all components under a feature folder (across all component types), ignoring git')
.addHelpText('after', `
Examples:
wf update Update git-changed components (default)
wf update --all Update all components
wf update --file Views/x.json Update a single component file
wf update --folder person Update every component under the "person" feature (Tasks/person, Workflows/person, Views/person, ...)
wf update -d Workflows/person Update only the components in that exact folder

Note: --file takes precedence over --folder, which takes precedence over --all.
`)
.action(updateCommand);

// Sync command
Expand Down
74 changes: 70 additions & 4 deletions src/commands/update.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ const chalk = require('chalk');
const ora = require('ora');
const path = require('path');
const inquirer = require('inquirer');
const { glob } = require('glob');
const config = require('../lib/config');
const { discoverComponents, findAllJsonFiles } = require('../lib/discover');
const { discoverComponents, findAllJsonFiles, resolveFeatureFolders, listFeatureFolders, toGlobPattern } = require('../lib/discover');
const { getDomain, getComponentTypes } = require('../lib/vnextConfig');
const { publishComponent, reinitializeSystem } = require('../lib/api');
const { getInstanceId, deleteWorkflow } = require('../lib/db');
Expand Down Expand Up @@ -44,12 +45,57 @@ async function updateCommand(options) {
version: config.get('API_VERSION'),
domain: domain
};


// Folder mode: resolve the feature folder name to a set of directories.
// --file wins over --folder if both are given (most specific).
const ignorePatterns = [
'**/.meta/**',
'**/.meta',
'**/*.diagram.json',
'**/package*.json',
'**/*config*.json'
];
let folderDirs = [];
const useFolder = !!options.folder && !options.file;

if (useFolder) {
folderDirs = await resolveFeatureFolders(projectRoot, options.folder);

if (folderDirs.length === 0) {
LOG.error(`No folder matched "${options.folder}"`);
const available = await listFeatureFolders(projectRoot);
if (available.length > 0) {
console.log(chalk.dim(`\n Available folders: ${available.join(', ')}\n`));
}
return;
}

console.log(chalk.blue(`\n Folder: ${options.folder} → ${folderDirs.length} folder(s)\n`));
}

// FIRST: Update changed CSX files
let csxFiles = [];
const csxResults = { success: 0, failed: 0, errors: [] };

if (options.all) {

if (useFolder) {
// Find CSX files within the matched feature folders
const csxSpinner = ora('Finding CSX files in folder...').start();
try {
for (const dir of folderDirs) {
const found = await glob(toGlobPattern(dir, '**/*.csx'), {
ignore: ['**/.meta/**', '**/.meta', '**/node_modules/**', '**/dist/**']
});
csxFiles.push(...found);
}
if (csxFiles.length > 0) {
csxSpinner.succeed(chalk.green(`${csxFiles.length} CSX files found`));
} else {
csxSpinner.info(chalk.dim('No CSX files in folder'));
}
} catch (error) {
csxSpinner.warn(chalk.yellow(`CSX scan error: ${error.message}`));
}
} else if (options.all) {
// Find all CSX files
const csxSpinner = ora('Finding all CSX files...').start();
try {
Expand Down Expand Up @@ -108,6 +154,26 @@ async function updateCommand(options) {
: path.join(projectRoot, options.file);
jsonFiles = [{ path: filePath, type: detectComponentType(filePath, projectRoot), fileName: path.basename(filePath) }];
console.log(chalk.blue(`\n File: ${path.basename(filePath)}\n`));
} else if (useFolder) {
// All JSON files within the matched feature folders (git-independent)
const spinner = ora('Finding JSON files in folder...').start();

for (const dir of folderDirs) {
const files = await glob(toGlobPattern(dir, '**/*.json'), { ignore: ignorePatterns });
jsonFiles.push(...files.map(f => ({
path: f,
type: detectComponentType(f, projectRoot),
fileName: path.basename(f)
})));
}
Comment on lines +161 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The globbing operation for finding JSON files in the feature folders is not wrapped in a try-catch block. If glob throws an error (e.g., due to permission issues), the CLI will crash with an unhandled promise rejection, leaving the spinner hanging. Wrapping this in a try-catch block and calling spinner.fail() ensures graceful error handling.

    try {
      for (const dir of folderDirs) {
        const files = await glob(toGlobPattern(dir, '**/*.json'), { ignore: ignorePatterns });
        jsonFiles.push(...files.map(f => ({
          path: f,
          type: detectComponentType(f, projectRoot),
          fileName: path.basename(f)
        })));
      }
    } catch (error) {
      spinner.fail(chalk.red('Error finding JSON files: ' + error.message));
      return;
    }


if (jsonFiles.length === 0) {
spinner.info(chalk.yellow('No JSON files in folder'));
console.log();
return;
}

spinner.succeed(chalk.green(`${jsonFiles.length} JSON files found`));
} else if (options.all) {
// All JSON files
LOG.warning('ALL components will be updated!');
Expand Down
21 changes: 13 additions & 8 deletions src/lib/api.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
const axios = require('axios');
const pkg = require('../../package.json');
const https = require('node:https');
const http = require('node:http');

// Identifies requests as coming from the CLI (e.g. "vnext-workflow-cli/1.0.0")
const USER_AGENT = `vnext-workflow-cli/${pkg.version}`;
// Create axios instance with custom agents for both HTTP and HTTPS
const apiClient = axios.create({
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({
rejectUnauthorized: false // Allow self-signed certificates
Comment on lines +6 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Disabling TLS verification (rejectUnauthorized: false) weakens security and may not be appropriate outside of a controlled environment.

This setting causes the client to trust any certificate, including invalid or malicious ones. If you only need this for local/self‑signed development, please gate it behind configuration and keep strict verification as the default. Alternatively, support a custom CA bundle for self‑signed certs instead of disabling verification entirely.

})
});
Comment on lines +2 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

This block introduces two important issues:

  1. Critical Bug (ReferenceError): The USER_AGENT constant and package.json import were removed, but USER_AGENT is still referenced on line 22 in testApiConnection. This will cause a ReferenceError: USER_AGENT is not defined whenever testApiConnection is called, which silently fails the API health check and always reports the connection as down.
  2. Security Vulnerability (Insecure TLS): Hardcoding rejectUnauthorized: false disables SSL/TLS certificate validation for all HTTPS requests, making the CLI vulnerable to Man-in-the-Middle (MitM) attacks.

Recommendation:

  • Restore the USER_AGENT definition and configure it globally on the apiClient instance.
  • Consider making rejectUnauthorized configurable (e.g., via a CLI configuration option or environment variable) rather than hardcoded to false.
Suggested change
const https = require('node:https');
const http = require('node:http');
// Identifies requests as coming from the CLI (e.g. "vnext-workflow-cli/1.0.0")
const USER_AGENT = `vnext-workflow-cli/${pkg.version}`;
// Create axios instance with custom agents for both HTTP and HTTPS
const apiClient = axios.create({
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({
rejectUnauthorized: false // Allow self-signed certificates
})
});
const https = require('node:https');
const http = require('node:http');
const pkg = require('../../package.json');
const USER_AGENT = 'vnext-workflow-cli/' + pkg.version;
// Create axios instance with custom agents for both HTTP and HTTPS
const apiClient = axios.create({
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({
rejectUnauthorized: false // Allow self-signed certificates (consider making this configurable)
}),
headers: {
'User-Agent': USER_AGENT
}
});


/**
* Tests the API connection
Expand All @@ -11,7 +17,7 @@ const USER_AGENT = `vnext-workflow-cli/${pkg.version}`;
*/
async function testApiConnection(baseUrl) {
try {
const response = await axios.get(`${baseUrl}/health`, {
const response = await apiClient.get(`${baseUrl}/health`, {
timeout: 5000,
headers: { 'User-Agent': USER_AGENT }
Comment on lines 18 to 22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): USER_AGENT is no longer defined, which will throw at runtime before the request is made.

This reference remains from before the package.json import was removed, so testApiConnection will now throw a ReferenceError before the health check runs. Please either restore a USER_AGENT constant (ideally shared with other API calls) or drop this header to align with existing apiClient usage.

});
Expand All @@ -31,11 +37,10 @@ async function publishComponent(baseUrl, componentData) {
const url = `${baseUrl}/api/v1/definitions/publish`;

try {
const response = await axios.post(url, componentData, {
const response = await apiClient.post(url, componentData, {
headers: {
'accept': '*/*',
'Content-Type': 'application/json',
'User-Agent': USER_AGENT
'Content-Type': 'application/json'
},
timeout: 30000
});
Expand Down Expand Up @@ -93,7 +98,7 @@ async function publishComponent(baseUrl, componentData) {
async function reinitializeSystem(baseUrl, version) {
const url = `${baseUrl}/api/${version}/definitions/re-initialize`;
try {
await axios.get(url, { timeout: 10000, headers: { 'User-Agent': USER_AGENT } });
await apiClient.get(url, { timeout: 10000 });
return true;
} catch (error) {
return false;
Expand Down
82 changes: 82 additions & 0 deletions src/lib/discover.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,86 @@ function listDiscovered(discovered, componentTypes) {
return results;
}

/**
* Resolves a folder name to a list of directories to update.
*
* Two resolution modes (in order):
* a) Exact path: if `name` resolves to an existing directory (absolute, or
* relative to projectRoot, or relative to componentsRoot), that single
* directory is returned.
* b) Feature name: otherwise, `name` is treated as a feature folder name and
* matched against every discovered component-type root. Every
* `<componentRoot>/<name>` that exists as a directory is collected, so a
* feature spread across Workflows/, Views/, Schemas/, … is gathered.
*
* @param {string} projectRoot - Project root folder
* @param {string} name - Folder name or relative/absolute path
* @returns {Promise<string[]>} Absolute directory paths (empty if nothing matched)
*/
async function resolveFeatureFolders(projectRoot, name) {
const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory();
Comment on lines +169 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential runtime crashes (e.g., TypeError: Path must be a string), we should add a defensive check at the beginning of resolveFeatureFolders to ensure name is a valid non-empty string before passing it to path utilities.

Suggested change
async function resolveFeatureFolders(projectRoot, name) {
const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory();
async function resolveFeatureFolders(projectRoot, name) {
if (typeof name !== 'string' || !name.trim()) {
return [];
}
const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory();


// a) Exact-path resolution
const candidates = [];
if (path.isAbsolute(name)) {
candidates.push(name);
} else {
candidates.push(path.join(projectRoot, name));
try {
candidates.push(path.join(getComponentsRoot(projectRoot), name));
} catch (error) {
// componentsRoot may be unavailable; ignore and fall through
}
}

for (const candidate of candidates) {
if (isDir(candidate)) {
return [path.resolve(candidate)];
}
}

// b) Feature-name match across discovered component roots
const discovered = await discoverComponents(projectRoot);
const dirs = [];
for (const componentDir of Object.values(discovered)) {
const featureDir = path.join(componentDir, name);
if (isDir(featureDir)) {
dirs.push(path.resolve(featureDir));
}
}

return dirs;
}

/**
* Lists available feature folder names — the union of immediate subdirectory
* names across all discovered component-type roots. Used for error messages
* when a requested folder name does not match anything.
*
* @param {string} projectRoot - Project root folder
* @returns {Promise<string[]>} Sorted unique feature folder names
*/
async function listFeatureFolders(projectRoot) {
const discovered = await discoverComponents(projectRoot);
const names = new Set();

for (const componentDir of Object.values(discovered)) {
let entries = [];
try {
entries = fs.readdirSync(componentDir, { withFileTypes: true });
} catch (error) {
continue;
}
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
names.add(entry.name);
}
}
}

return Array.from(names).sort();
}

/**
* Detects component type from file path
* @param {string} filePath - File path
Expand Down Expand Up @@ -177,5 +257,7 @@ module.exports = {
findAllCsxInComponents,
getComponentDir,
listDiscovered,
resolveFeatureFolders,
listFeatureFolders,
detectComponentTypeFromPath
};
Loading