From bd879f9b84741ca59fb10ffdb4f52f069b00aebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tayfun=20Y=C4=B1lmaz?= Date: Sat, 5 Sep 2026 23:21:01 +0300 Subject: [PATCH] feat: multi-solution (multi-domain) workspace support A workspace can now hold several solution files side by side: vnext.config.json (default) plus vnext.{domain}.config.json per extra domain, each with its own componentsRoot. Workspace commands (check, csx, sync, update, reset) run once per solution, sequentially, using the CLI domain profile that matches the solution's `domain` field. - New src/lib/solutions.js: solution-file discovery, solution objects, --file ownership by componentsRoot, and the shared runForEachSolution loop with per-domain banners and a WORKSPACE SUMMARY. - Global --domain option (forwarded by the preAction hook) to restrict any command to one solution. - config.js: getDomainConfig/buildDbConfig/buildApiConfig; the resolveWorkspaceDomain side effect that persisted ACTIVE_DOMAIN on every run is removed. ACTIVE_DOMAIN now only affects `wf domain` and `wf config`. - vnextConfig.js becomes a stateless, file-name-aware parser (cache removed); discover/workflow/csx take a solution object instead of projectRoot; git-changed detection is scoped to the solution's componentsRoot. - Components must declare `domain` equal to their solution's domain; mismatches fail with DOMAIN_MISMATCH and are skipped. - Solutions whose domain has no CLI profile are skipped with a `wf domain add` hint (csx does not need a profile). - update --all confirms once for all domains; reset asks which domain first when several exist. - Shared JSON/CSX ignore-pattern constants; dead processComponent removed; README and CLAUDE.md updated. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 22 +-- README.md | 149 ++++++++++++------ bin/workflow.js | 33 ++-- src/commands/check.js | 120 +++++++------- src/commands/csx.js | 74 ++++----- src/commands/reset.js | 160 +++++++++---------- src/commands/sync.js | 136 ++++++++-------- src/commands/update.js | 233 +++++++++++++-------------- src/lib/config.js | 86 +++++----- src/lib/csx.js | 64 ++++---- src/lib/discover.js | 124 ++++++++------- src/lib/solutions.js | 347 +++++++++++++++++++++++++++++++++++++++++ src/lib/ui.js | 49 ++++-- src/lib/vnextConfig.js | 132 +++++++++------- src/lib/workflow.js | 164 ++++++++----------- 15 files changed, 1148 insertions(+), 745 deletions(-) create mode 100644 src/lib/solutions.js diff --git a/CLAUDE.md b/CLAUDE.md index cb77bf6..16eb68f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,27 +19,30 @@ node bin/workflow.js # run the CLI directly (same as `npm run dev` / `npm star - **`build` is a no-op** (`echo 'Build not needed for now'`) — nothing to compile. - **There is no test suite, linter, or formatter configured.** Do not invent `npm test`/`npm run lint` commands; they will fail. Verify changes by running the CLI against a real vNext project directory. -The CLI always treats `process.cwd()` as the project root and requires a `vnext.config.json` in that directory. To exercise it, `cd` into a vNext workspace (not this repo) before running. +The CLI always treats `process.cwd()` as the project root and requires at least one solution file (`vnext.config.json` or `vnext.{domain}.config.json`) in that directory. To exercise it, `cd` into a vNext workspace (not this repo) before running. A two-solution testbed lives at `../vnext-example` (`core` + `partner`); copy it into a scratch directory before running write-mode commands against it. ## Two distinct config systems (do not conflate) -1. **`vnext.config.json`** — lives in the *user's project root*, read fresh each run. Defines `domain` and `paths` (which folders hold which component types). Handled by [src/lib/vnextConfig.js](src/lib/vnextConfig.js) with a single-entry cache. +1. **Solution files** — `vnext.config.json` (default) and any `vnext.{domain}.config.json`, all in the *user's project root*, read fresh each run. Each defines a `domain` and `paths` (which folders hold which component types); every solution has its **own `componentsRoot`**. [src/lib/vnextConfig.js](src/lib/vnextConfig.js) is a stateless parser; [src/lib/solutions.js](src/lib/solutions.js) discovers the files and turns each into a **solution object** (`domain`, `fileName`, `projectRoot`, `componentsRoot`, `componentTypes`, `profile`, `warnings`). The `domain` *field* is authoritative; the `{domain}` in the file name is only used for discovery (mismatch → warning). 2. **CLI config** — global, stored in `~/.config/vnext-workflow-cli/config.json` via the `conf` package. Holds API/DB connection settings, structured as **domain profiles** (`ACTIVE_DOMAIN` + `DOMAINS[]`). Handled by [src/lib/config.js](src/lib/config.js). `config.js` auto-migrates the old flat config format to the domain-aware format on module load (`migrateConfig`), and exposes a virtual `PROJECT_ROOT` key that always returns `process.cwd()` (it is never persisted). `DOMAIN_NAME`, `ACTIVE_DOMAIN`, and `PROJECT_ROOT` are reserved and cannot be set via `config.set` — they have dedicated domain commands. -**Auto domain resolution:** the `preAction` hook in [bin/workflow.js](bin/workflow.js) calls `config.resolveWorkspaceDomain(cwd)` before every command *except* `domain`. It reads the `domain` field from the project's `vnext.config.json` and, if a matching CLI domain profile exists, silently switches `ACTIVE_DOMAIN` to it. This is why running a command in a project folder uses that project's connection settings without a manual `wf domain use`. +**Solution → profile resolution (run-scoped, never persisted):** `solutions.loadSolutions` attaches `profile = config.getDomainConfig(solution.domain)` to every solution. Commands build their connection settings from that profile via `config.buildDbConfig(profile)` / `config.buildApiConfig(profile)` — they never call `config.get('API_BASE_URL')` etc. `ACTIVE_DOMAIN` is **not** touched by workspace commands; it only matters for `wf domain use/active` and `wf config get/set`. A solution whose domain has no profile is skipped with a `wf domain add` hint (except by `csx`, which needs no profile). + +**`--domain `** is a *global* Commander option on `program`; the `preAction` hook in [bin/workflow.js](bin/workflow.js) forwards it to the subcommand (`actionCommand.setOptionValue('domain', …)`) so every handler reads `options.domain`. It has no short flag (`-d` belongs to `update --folder`). ## Architecture -`bin/workflow.js` wires up Commander, registers commands, and installs the `preAction` domain-resolution + banner hook. Each command in `src/commands/` orchestrates the shared libraries in `src/lib/`: +`bin/workflow.js` wires up Commander, registers commands, and installs the `preAction` hook that forwards the global `--domain`. Each command in `src/commands/` orchestrates the shared libraries in `src/lib/`: -- **discover.js** — given `vnext.config.json` `paths`, locates component folders under `componentsRoot` and globs their JSON/CSX files. Crucially, it **only scans folders declared in `paths`** and always ignores `.meta/`, `*.diagram.json`, `package*.json`, and `*config*.json`. +- **solutions.js** — the multi-solution layer. `loadWorkspace(options)` discovers/loads/filters solution files and reports fatal problems (no solution file, unknown `--domain`); `runForEachSolution(options, runOpts, fn)` is the shared loop every workspace command uses: it prints a banner per solution (`ui.printSolutionBanner`), skips profile-less solutions when `requireProfile` is set, routes `--file` to the owning solution (`findSolutionForPath`, by `componentsRoot` prefix), calls `fn(solution)` sequentially, stamps `domain` onto returned error rows and prints a `WORKSPACE SUMMARY` when more than one solution ran. `update --all` (single confirmation) and `reset` (domain picker) pre-load the workspace with `loadWorkspace` and hand it to the runner via `runOpts.loaded`. +- **discover.js** — given a solution's `componentsRoot`/`componentTypes`, locates component folders and globs their JSON/CSX files. Crucially, it **only scans folders declared in `paths`** and always ignores `.meta/`, `*.diagram.json`, `package*.json`, and `*config*.json` (the shared `JSON_IGNORE_PATTERNS` / `CSX_IGNORE_PATTERNS` constants — use them instead of inlining the list). `resolveFeatureFolders` accepts an exact path only when it lies inside that solution's `componentsRoot`. - **csx.js** — embeds `.csx` content into the JSON files that reference it. A `.csx` file is matched to JSON by its `location` string (e.g. `./src/Rules/MyRule.csx`), and the JSON's per-reference `encoding` field decides the form: `NAT` writes plain text, `B64`/absent writes Base64 (default). It updates *every* matching `location` in the JSON tree recursively. **CSX→JSON matching is scoped to the CSX file's own component directory** (the parent of its `src/` folder) so that same-named `.csx` files in sibling components don't cross-contaminate. -- **workflow.js** — per-component publish logic: read JSON `key`/`version`/`flow`, map the component type to a `sys-*` flow name (`workflows`→`sys-flows`, `tasks`→`sys-tasks`, etc.), check the DB, delete if present, publish. +- **workflow.js** — per-component helpers: `getJsonMetadata` reads `key`/`version`/`flow`/`domain`; `checkComponentDomain(metadata, solution)` enforces that a component declares `domain` equal to its solution's domain (commands record failures as `DOMAIN_MISMATCH` and skip the component); `detectComponentType` maps the folder to a `sys-*` flow name (`workflows`→`sys-flows`, `tasks`→`sys-tasks`, etc.); `getGitChangedJson(solution)` lists changed files inside that solution's `componentsRoot`. - **db.js** — PostgreSQL access with two interchangeable backends selected by `USE_DOCKER`: a direct `pg.Client` connection, or `docker exec ... psql` shelling into a container. Queries target `""."Instances"` where schema = the flow name with `-`→`_`. Lookups are by `Key` only (version is ignored), newest `CreatedAt` first. - **api.js** — axios calls: `GET /health`, `POST /api/v1/definitions/publish`, `GET /api/{version}/definitions/re-initialize`. `publishComponent` carefully unwraps RFC 7807 Problem Details (`detail`, `title`, `errors`, `errorCode`, `traceId`) into a structured `apiError` for rich error display. -- **ui.js** — all console output (chalk). `LOG` helpers, the active-domain banner, and two error renderers: `printApiError` (single component, tree-style) and `printErrorSummaryTable` (batch, table with expanded validation errors). Route user-facing output through this module rather than ad-hoc `console.log`. +- **ui.js** — all console output (chalk). `LOG` helpers, the per-solution banner (`printSolutionBanner`), and two error renderers: `printApiError` (single component, tree-style) and `printErrorSummaryTable` (batch, table with expanded validation errors; grows a `Domain` column when rows carry `domain`, and shows `errorCode` from either the row or the API error). Route user-facing output through this module rather than ad-hoc `console.log`. ## Command semantics @@ -52,10 +55,11 @@ The four sync commands differ only in their DB/existing-component behavior — k | `reset` | interactively-chosen folder | delete + publish | publish | | `csx` | CSX files only | n/a (no DB/API) | n/a | -`update` and `reset` re-initialize the system (`reinitializeSystem`) after a successful batch. `update`/`csx` default to git-changed files: `getGitChangedJson` / `getGitChangedCsx` run `git status --porcelain` from the **git root** (not project root), then filter results back down to `PROJECT_ROOT`. +Every workspace command runs the table above **once per solution, sequentially** (via `runForEachSolution`); `reset` first asks which domain when several exist and no `--domain` is given, and `update --all` confirms once for all domains. `update` and `reset` re-initialize the system (`reinitializeSystem`) after a successful batch, per solution. `update`/`csx` default to git-changed files: `getGitChangedJson` / `getGitChangedCsx` run `git status --porcelain` from the **git root** (not project root), then filter results down to the solution's **`componentsRoot`** — JSONs elsewhere in the repo are never published. ## Conventions - CommonJS only (`require`/`module.exports`), Node >= 14. No TypeScript, no ESM. -- Library functions take an explicit `projectRoot` argument rather than reading cwd directly; commands resolve it once via `config.get('PROJECT_ROOT')`. +- Library functions (`discover.js`, `workflow.js`, `csx.js`) take a **solution object** (`projectRoot`, `componentsRoot`, `componentTypes`, …) rather than a bare `projectRoot` or reading cwd directly; commands receive it from `runForEachSolution`. Only `config.get('PROJECT_ROOT')` (inside `solutions.loadWorkspace`) reads cwd. +- Commands are split into a thin `xxxCommand(options)` (header, prompts that must happen once, then `runForEachSolution`) and an `xxxSolution(solution, options)` body that returns `{ success, failed, errors }` so the workspace summary can aggregate. - DB and API helpers swallow connection errors and return `false`/`null` rather than throwing — callers treat a missing instance as "not in DB". diff --git a/README.md b/README.md index 2e18444..5db14b7 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ npm link ## 📄 vnext.config.json (Required) -Every vNext project must have a `vnext.config.json` file in the **project root**. This file defines the domain and component paths. +Every vNext workspace must have at least one **solution file** in the **project root**. The default solution file is `vnext.config.json`; a workspace that hosts several domains adds one `vnext.{domain}.config.json` per extra domain (see [Multiple solutions in one workspace](#multiple-solutions-in-one-workspace)). A solution file defines the domain and component paths. ### Example Configuration @@ -81,7 +81,7 @@ Every vNext project must have a `vnext.config.json` file in the **project root** | Property | Description | |----------|-------------| -| `domain` | Domain name used for API calls (replaces config's API_DOMAIN) | +| `domain` | Domain name of this solution. Must match a CLI domain profile (`wf domain add `) and the `domain` field of every component under this solution | | `paths.componentsRoot` | Root folder where all components are located | | `paths.tasks` | Tasks folder name under componentsRoot | | `paths.workflows` | Workflows folder name under componentsRoot | @@ -99,6 +99,32 @@ The CLI scans `componentsRoot` recursively and: - Ignores `*.diagram.json` files - Ignores `package*.json` and `*config*.json` files +### Multiple solutions in one workspace + +A single workspace can hold several domains side by side. Each domain gets its own solution file in the project root and its own `componentsRoot`: + +``` +my-workspace/ +├── vnext.config.json # domain "core" → componentsRoot "core" +├── vnext.partner.config.json # domain "partner" → componentsRoot "partner" +├── core/ +│ ├── Workflows/ … +│ └── Tasks/ … +└── partner/ + ├── Workflows/ … + └── Views/ … +``` + +Rules: + +- **File name:** `vnext.config.json` (default) or `vnext.{domain}.config.json`. The `{domain}` part is only used to find the file; the **`domain` field inside the file is authoritative**. If the two differ, a warning is printed and the field wins. +- **Separate folders:** every solution points at its own `paths.componentsRoot`. Discovery, `--file`, `--folder` and Git-changed detection are all scoped to that folder. +- **Profiles:** each solution's `domain` is looked up in the CLI domain profiles (`wf domain list`). A solution with no matching profile is **skipped with a warning** by `check`/`sync`/`update`/`reset` (`csx` needs no profile and always runs). +- **Component domain check:** every component JSON must carry a `domain` equal to its solution's `domain`. A missing or different value fails that component with `DOMAIN_MISMATCH`; the rest of the batch continues. +- **Sequential processing:** with no `--domain` option, every workspace command runs once per solution, in order, each under its own banner, followed by a `WORKSPACE SUMMARY` and a combined error table with a `Domain` column. +- **`--domain `:** global option that restricts any command to one solution. Works before or after the command name (`wf update --domain partner`, `wf --domain partner update`). +- **Duplicate domains:** two solution files declaring the same `domain` are both rejected. + --- ## ⚡ Quick Start @@ -143,18 +169,21 @@ wf reset ## 📖 Commands +All workspace commands (`check`, `csx`, `sync`, `update`, `reset`) accept the global `--domain ` option. Without it they run once per solution file found in the project root. + ### `wf check` **Purpose**: System health check -Checks and displays: -- vnext.config.json status and domain info -- API connection status +For every solution, checks and displays: +- Solution file status, domain and components root +- API connection status (skipped with a `wf domain add` hint when the domain has no CLI profile) - Database connection status - Component folders found ```bash -wf check +wf check # every solution in the workspace +wf check --domain partner # one solution ``` --- @@ -194,12 +223,15 @@ 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 --folder person # Process every component under a feature folder, ignoring Git +wf update # Process changed files in Git (CSX + JSON), every solution +wf update --all # Update all (asks for confirmation once, for all domains) +wf update --file x.json # Process a single file (its solution is derived from the path) +wf update --folder person # Process every component under a feature folder, ignoring Git +wf update --domain partner --all # Only the "partner" solution ``` +In a multi-solution workspace `--file` is routed to the solution whose `componentsRoot` contains the file; combining it with a different `--domain` is an error. `--folder` is resolved inside each solution separately (an exact path is only accepted inside that solution's `componentsRoot`). + **`--folder ` (`-d`)**: Updates every component belonging to a feature, across all component types, regardless of Git status. It resolves `` in two ways: - **Feature name** (e.g. `person`): matches `` 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. @@ -228,9 +260,12 @@ wf update -d Workflows/person # Only Workflows/person **Use when**: You need to force reset components regardless of changes ```bash -wf reset # Select folder from interactive menu +wf reset # Select folder from interactive menu +wf reset --domain core # Skip the domain picker in a multi-solution workspace ``` +In a workspace with several solution files, `wf reset` first asks **which domain** to reset (unless `--domain` is given), then shows the folder menu for that solution. + **Menu Options**: ``` ? Which folder should be reset? @@ -260,11 +295,14 @@ wf reset # Select folder from interactive menu **Use when**: You only want to update CSX content in JSONs without publishing to API ```bash -wf csx # Process changed files in Git -wf csx --all # Process all CSX files -wf csx --file x.csx # Process a single file +wf csx # Process changed files in Git, every solution +wf csx --all # Process all CSX files +wf csx --file x.csx # Process a single file +wf csx --domain partner # Only the "partner" solution ``` +`csx` does not need a CLI domain profile, so it also runs for solutions whose domain has no profile yet. + --- ### `wf config [key] [value]` @@ -285,7 +323,7 @@ wf config set DB_PASSWORD pass # Change a setting (on active domain) **Purpose**: Multidomain management -Manage multiple domain configurations. Switch between domains with a single command. All CLI commands automatically use the active domain's settings. +Manage multiple domain configurations (API/DB connection profiles). Workspace commands pick the profile whose name equals each solution's `domain`; `wf config get/set` operate on the **active** domain (`wf domain use`). ```bash # Show active domain name @@ -447,23 +485,21 @@ wf update --folder Workflows/person ### 6. Multidomain Workflow ```bash -# Add domains (one-time setup) -wf domain add domain-a --API_BASE_URL http://localhost:4201 --DB_NAME vNext_DomainA -wf domain add domain-b --API_BASE_URL http://localhost:4221 --DB_NAME vNext_DomainB +# Add domain profiles (one-time setup) +wf domain add core --API_BASE_URL http://localhost:4201 --DB_NAME vNext_Core +wf domain add partner --API_BASE_URL http://localhost:4221 --DB_NAME vNext_Partner -# Option A: Auto-switch via vnext.config.json (recommended) -# Just cd into the project - domain profile switches automatically -cd ~/projects/domain-a-app # vnext.config.json has "domain": "domain-a" -wf update # auto-switches to domain-a profile +# One workspace, two solution files +cd ~/projects/my-workspace # vnext.config.json ("core") + vnext.partner.config.json ("partner") +wf check # both domains, each with its own banner +wf update # git-changed files of core, then of partner +wf update --domain partner # only partner -cd ~/projects/domain-b-app # vnext.config.json has "domain": "domain-b" -wf update # auto-switches to domain-b profile +# Separate workspaces still work exactly as before +cd ~/projects/core-app # vnext.config.json has "domain": "core" +wf update # uses the "core" profile -# Option B: Manual switch (still works) -wf domain use domain-a -wf update - -# See all domains +# See all profiles wf domain list ``` @@ -485,36 +521,33 @@ wf domain list ### Overview -The CLI supports managing multiple domain configurations. Each domain has its own `API_BASE_URL`, `DB_NAME`, and other settings. Switch between domains with a single command. +The CLI supports managing multiple domain configurations. Each domain has its own `API_BASE_URL`, `DB_NAME`, and other settings, stored as a **domain profile**. A workspace may contain one or many solution files, and each solution is processed with the profile that matches its `domain`. -### Auto Domain Resolution +### Solution → Profile Resolution -When you run any command inside a vNext workspace that contains a `vnext.config.json`, the CLI **automatically** switches to the matching domain profile based on the `domain` field in the config file. This eliminates the need to manually run `wf domain use ` every time you switch between projects. +Before a workspace command runs, the CLI: -**How it works:** -1. Before each command (except `wf domain`), the CLI checks if `vnext.config.json` exists in the current directory. -2. If found, it reads the `domain` field and looks for a matching CLI domain profile (`DOMAINS[].DOMAIN_NAME`). -3. If a match is found and it differs from the current active domain, it silently switches and shows a dim log message: +1. Lists the solution files in the current directory: `vnext.config.json` plus every `vnext.{domain}.config.json`. +2. Reads the `domain` field of each one. +3. Looks up a CLI domain profile with the same name (`DOMAINS[].DOMAIN_NAME`). +4. Runs the command once per solution, sequentially, with that profile's API/DB settings. A solution without a profile is skipped with a hint: ``` - [auto] Domain switched to "onboarding" (from vnext.config.json) + ⚠ No CLI domain profile for "partner" — skipped. + Run: wf domain add partner --API_BASE_URL --DB_NAME ``` -4. If no `vnext.config.json` is found or no matching profile exists, the current active domain is kept (no error). -**Example:** You have two projects and two domain profiles: -```bash -# Add domain profiles once -wf domain add core --DB_NAME vNext_Core -wf domain add onboarding --DB_NAME vNext_Onboarding +`ACTIVE_DOMAIN` is **not** changed by running commands in a workspace. It only affects `wf domain active` and `wf config get/set`. Use `--domain ` to restrict a command to a single solution. -# Now just cd into the project and run commands - domain switches automatically -cd ~/projects/core-app # has vnext.config.json with "domain": "core" -wf update # auto-switches to "core" profile +> **Upgrading from 1.x:** earlier versions rewrote `ACTIVE_DOMAIN` to the workspace's domain on every command ("auto domain resolution"). That side effect is gone; `wf config get` now always shows the domain you last selected with `wf domain use`. -cd ~/projects/onboarding-app # has vnext.config.json with "domain": "onboarding" -wf update # auto-switches to "onboarding" profile -``` +**Example:** two profiles, two solutions in one workspace: +```bash +wf domain add core --DB_NAME vNext_Core +wf domain add partner --DB_NAME vNext_Partner -> **Note:** The `wf domain` command is excluded from auto-resolution so that manual domain management is never interfered with. +cd ~/projects/my-workspace # vnext.config.json (core) + vnext.partner.config.json (partner) +wf update # core with the "core" profile, then partner with the "partner" profile +``` ### Backward Compatibility @@ -618,6 +651,19 @@ wf check **Note:** No need to set PROJECT_ROOT - just `cd` into your project folder. +### "No CLI domain profile for … — skipped" +The solution's `domain` has no matching profile. Create one: +```bash +wf domain add --API_BASE_URL http://localhost:4201 --DB_NAME +wf domain list +``` + +### "DOMAIN_MISMATCH" in the error table +A component's `domain` field is missing or differs from the `domain` of the solution file whose `componentsRoot` contains it. Fix the component's `domain` (or move the file to the right solution folder) and re-run. + +### "Domain "x" not found in this workspace" +`--domain` names a domain that no solution file in the current directory declares. The message lists the available domains. + ### "Cannot connect to API" ```bash # Check API @@ -741,7 +787,8 @@ vnext-workflow-cli/ │ ├── csx.js # CSX processing │ ├── db.js # Database operations │ ├── discover.js # Component discovery -│ ├── vnextConfig.js # vnext.config.json reader +│ ├── solutions.js # Solution-file discovery + per-domain runner +│ ├── vnextConfig.js # Solution file (vnext*.config.json) parser │ └── workflow.js # Workflow processing ├── .github/ │ └── workflows/ # GitHub Actions workflows diff --git a/bin/workflow.js b/bin/workflow.js index 6dd4312..615ad84 100755 --- a/bin/workflow.js +++ b/bin/workflow.js @@ -1,13 +1,8 @@ #!/usr/bin/env node const { program, Argument } = require('commander'); -const chalk = require('chalk'); const pkg = require('../package.json'); -// Config -const config = require('../src/lib/config'); -const { printActiveDomainBanner } = require('../src/lib/ui'); - // Commands const checkCommand = require('../src/commands/check'); const csxCommand = require('../src/commands/csx'); @@ -20,18 +15,25 @@ const domainCommand = require('../src/commands/domain'); program .name('workflow') .description('vNext Workflow Manager CLI') - .version(pkg.version); - -// Auto-resolve domain and show banner before each command + .version(pkg.version) + .option('--domain ', 'Only process the solution whose domain is (default: every vnext*.config.json in the workspace)') + .addHelpText('after', ` +Multi-solution workspaces: + A workspace may hold several solution files side by side: + vnext.config.json (default) + vnext..config.json (one per additional domain) + Each declares its own "domain" and "paths.componentsRoot". Workspace commands + (check, csx, sync, update, reset) run once per solution, sequentially, using + the CLI domain profile that matches the solution's domain (see "wf domain"). + Pass --domain to work on a single solution. +`); + +// Forward the global --domain option to the subcommand so every handler sees options.domain. program.hook('preAction', (thisCommand, actionCommand) => { - if (actionCommand.name() === 'domain') return; - - const result = config.resolveWorkspaceDomain(process.cwd()); - if (result.resolved && result.switched) { - console.log(chalk.dim(` [auto] Domain switched to "${result.domain}" (from vnext.config.json)`)); + const { domain } = thisCommand.opts(); + if (domain) { + actionCommand.setOptionValue('domain', domain); } - - printActiveDomainBanner(); }); // Check command @@ -62,6 +64,7 @@ Examples: 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 + wf update --domain partner --all Update all components of the "partner" solution only Note: --file takes precedence over --folder, which takes precedence over --all. `) diff --git a/src/commands/check.js b/src/commands/check.js index 947f2a3..2ebc407 100644 --- a/src/commands/check.js +++ b/src/commands/check.js @@ -1,82 +1,77 @@ const chalk = require('chalk'); const ora = require('ora'); -const config = require('../lib/config'); +const { buildDbConfig } = require('../lib/config'); const { discoverComponents, listDiscovered } = require('../lib/discover'); -const { getDomain, getComponentTypes, getComponentsRoot } = require('../lib/vnextConfig'); +const { runForEachSolution } = require('../lib/solutions'); const { testApiConnection } = require('../lib/api'); const { testDbConnection } = require('../lib/db'); const { LOG } = require('../lib/ui'); -async function checkCommand() { +async function checkCommand(options) { LOG.header('SYSTEM CHECK'); - - const projectRoot = config.get('PROJECT_ROOT'); - const autoDiscover = config.get('AUTO_DISCOVER'); - - // vnext.config.json check + + // Profiles are not required: a missing profile is exactly what check should report. + const outcomes = await runForEachSolution(options, { requireProfile: false }, checkSolution); + if (!outcomes) return; + + LOG.separator(); + console.log(chalk.green.bold('\n ✓ Check completed\n')); +} + +async function checkSolution(solution) { + const profile = solution.profile; + + // Solution file console.log(chalk.white.bold('\n Configuration:\n')); - - let domain, componentTypes, componentsRoot; - try { - domain = getDomain(projectRoot); - componentTypes = getComponentTypes(projectRoot); - componentsRoot = getComponentsRoot(projectRoot); - - LOG.success(`vnext.config.json found`); - console.log(chalk.dim(` Domain: ${domain}`)); - console.log(chalk.dim(` Components Root: ${componentsRoot}`)); - } catch (error) { - LOG.error(`vnext.config.json: ${error.message}`); - componentTypes = {}; - } - - // API check + LOG.success(`${solution.fileName} found`); + console.log(chalk.dim(` Domain: ${solution.domain}`)); + console.log(chalk.dim(` Components Root: ${solution.componentsRoot}`)); + + // Connection checks (need a CLI profile) console.log(chalk.white.bold('\n Connection Status:\n')); - - let apiSpinner = ora(' Checking API...').start(); - try { - const apiUrl = config.get('API_BASE_URL'); - const isApiOk = await testApiConnection(apiUrl); - if (isApiOk) { - apiSpinner.succeed(chalk.green(` API: Accessible (${apiUrl})`)); - } else { - apiSpinner.fail(chalk.red(` API: Not accessible (${apiUrl})`)); + + if (!profile) { + LOG.warning(`No CLI domain profile for "${solution.domain}" — API/DB checks skipped`); + console.log(chalk.dim(` Run: wf domain add ${solution.domain} --API_BASE_URL --DB_NAME `)); + } else { + let apiSpinner = ora(' Checking API...').start(); + try { + const apiUrl = profile.API_BASE_URL; + const isApiOk = await testApiConnection(apiUrl); + if (isApiOk) { + apiSpinner.succeed(chalk.green(` API: Accessible (${apiUrl})`)); + } else { + apiSpinner.fail(chalk.red(` API: Not accessible (${apiUrl})`)); + } + } catch (error) { + apiSpinner.fail(chalk.red(` API: Error - ${error.message}`)); } - } catch (error) { - apiSpinner.fail(chalk.red(` API: Error - ${error.message}`)); - } - - // DB check - let dbSpinner = ora(' Checking database...').start(); - try { - const useDockerValue = config.get('USE_DOCKER'); - const isDbOk = await testDbConnection({ - host: config.get('DB_HOST'), - port: config.get('DB_PORT'), - database: config.get('DB_NAME'), - user: config.get('DB_USER'), - password: config.get('DB_PASSWORD'), - useDocker: useDockerValue === true || useDockerValue === 'true', - dockerContainer: config.get('DOCKER_POSTGRES_CONTAINER') - }); - if (isDbOk) { - dbSpinner.succeed(chalk.green(` DB: Connected (${config.get('DB_HOST')}:${config.get('DB_PORT')})`)); - } else { - dbSpinner.fail(chalk.red(' DB: Cannot connect')); + + let dbSpinner = ora(' Checking database...').start(); + try { + const isDbOk = await testDbConnection(buildDbConfig(profile)); + if (isDbOk) { + dbSpinner.succeed(chalk.green(` DB: Connected (${profile.DB_HOST}:${profile.DB_PORT})`)); + } else { + dbSpinner.fail(chalk.red(' DB: Cannot connect')); + } + } catch (error) { + dbSpinner.fail(chalk.red(` DB: Error - ${error.message}`)); } - } catch (error) { - dbSpinner.fail(chalk.red(` DB: Error - ${error.message}`)); } - + // Folder scan + const autoDiscover = profile ? profile.AUTO_DISCOVER : true; + const componentTypes = solution.componentTypes; + if (autoDiscover && Object.keys(componentTypes).length > 0) { console.log(chalk.white.bold('\n Component Folders:\n')); - + let discoverSpinner = ora(' Scanning folders...').start(); try { - const discovered = await discoverComponents(projectRoot); + const discovered = await discoverComponents(solution); discoverSpinner.stop(); - + const list = listDiscovered(discovered, componentTypes); for (const item of list) { if (item.found) { @@ -91,9 +86,8 @@ async function checkCommand() { } else if (!autoDiscover) { console.log(chalk.yellow('\n ⚠ AUTO_DISCOVER is disabled')); } - - LOG.separator(); - console.log(chalk.green.bold('\n ✓ Check completed\n')); + + console.log(); } module.exports = checkCommand; diff --git a/src/commands/csx.js b/src/commands/csx.js index 2269429..b031e24 100644 --- a/src/commands/csx.js +++ b/src/commands/csx.js @@ -1,39 +1,37 @@ const chalk = require('chalk'); const ora = require('ora'); const path = require('path'); -const config = require('../lib/config'); -const { getDomain } = require('../lib/vnextConfig'); +const { runForEachSolution } = require('../lib/solutions'); const { processCsxFile, getGitChangedCsx, findAllCsx } = require('../lib/csx'); const { LOG } = require('../lib/ui'); async function csxCommand(options) { LOG.header('CSX UPDATE'); - - const projectRoot = config.get('PROJECT_ROOT'); - - // Check domain - try { - getDomain(projectRoot); - } catch (error) { - LOG.error(`Failed to read vnext.config.json: ${error.message}`); - return; - } - + + // csx touches only local files, so no CLI domain profile is needed. + await runForEachSolution( + options, + { requireProfile: false, forFile: options.file }, + (solution) => csxSolution(solution, options) + ); +} + +async function csxSolution(solution, options) { let csxFiles = []; - + // Which CSX files to process? if (options.file) { // Specific file - const filePath = path.isAbsolute(options.file) - ? options.file - : path.join(projectRoot, options.file); + const filePath = path.isAbsolute(options.file) + ? options.file + : path.join(solution.projectRoot, options.file); csxFiles = [filePath]; console.log(chalk.blue(` File: ${path.basename(filePath)}\n`)); } else if (options.all) { // All CSX files const spinner = ora(' Finding all CSX files...').start(); try { - csxFiles = await findAllCsx(projectRoot); + csxFiles = await findAllCsx(solution); spinner.succeed(chalk.green(` ${csxFiles.length} CSX files found`)); } catch (error) { spinner.fail(chalk.red(` CSX scan error: ${error.message}`)); @@ -43,33 +41,33 @@ async function csxCommand(options) { // Changed files in Git (default) const spinner = ora(' Finding changed CSX files in Git...').start(); try { - csxFiles = await getGitChangedCsx(projectRoot); - + csxFiles = await getGitChangedCsx(solution); + if (csxFiles.length === 0) { spinner.info(chalk.yellow(' No changed CSX files in Git')); console.log(chalk.green('\n ✓ All CSX files up to date\n')); return; } - + spinner.succeed(chalk.green(` ${csxFiles.length} changed CSX files found`)); } catch (error) { spinner.fail(chalk.red(` CSX scan error: ${error.message}`)); return; } } - + // Process each CSX file const results = { success: 0, failed: 0, errors: [] }; const updatedFiles = []; - + console.log(chalk.blue('\n Writing CSX files to JSONs...\n')); - + for (const csxFile of csxFiles) { const fileName = path.basename(csxFile); - + try { - const result = await processCsxFile(csxFile, projectRoot); - + const result = await processCsxFile(csxFile, solution); + if (result.success) { LOG.component('CSX', fileName, 'success', `→ ${result.updatedJsonCount} JSON, ${result.totalUpdates} refs`); results.success++; @@ -85,26 +83,26 @@ async function csxCommand(options) { } catch (error) { LOG.component('CSX', fileName, 'error', error.message); results.failed++; - results.errors.push({ file: fileName, error: error.message }); + results.errors.push({ type: 'CSX', file: fileName, error: error.message }); } } - + // SUMMARY REPORT LOG.header('CSX UPDATE SUMMARY'); - + // Results console.log(chalk.white.bold('\n Results:\n')); - + const successLabel = results.success > 0 ? chalk.green(`${results.success} success`) : chalk.dim('0 success'); const failedLabel = results.failed > 0 ? chalk.red(`, ${results.failed} failed`) : ''; console.log(` ${chalk.cyan('CSX Files'.padEnd(16))} : ${successLabel}${failedLabel}`); - + // Updated JSON details if (updatedFiles.length > 0) { console.log(); LOG.subSeparator(); console.log(chalk.white.bold('\n Updated JSON Files:\n')); - + for (const item of updatedFiles) { console.log(chalk.green(` ${item.file}:`)); for (const json of item.jsonFiles) { @@ -112,26 +110,28 @@ async function csxCommand(options) { } } } - + // Errors if (results.errors.length > 0) { console.log(); LOG.subSeparator(); console.log(chalk.red.bold('\n ERRORS:\n')); - + for (const err of results.errors) { console.log(chalk.red(` [CSX] ${err.file}`)); console.log(chalk.dim(` └─ ${err.error}`)); } } - + LOG.separator(); - + if (results.success > 0 && results.failed === 0) { console.log(chalk.green.bold('\n ✓ CSX update completed\n')); } else if (results.failed > 0) { console.log(chalk.yellow.bold(`\n ⚠ CSX update completed (${results.failed} errors)\n`)); } + + return results; } module.exports = csxCommand; diff --git a/src/commands/reset.js b/src/commands/reset.js index 7df3765..721e82f 100644 --- a/src/commands/reset.js +++ b/src/commands/reset.js @@ -3,59 +3,56 @@ const ora = require('ora'); const inquirer = require('inquirer'); const path = require('path'); const { glob } = require('glob'); -const config = require('../lib/config'); -const { discoverComponents, toGlobPattern } = require('../lib/discover'); -const { getDomain, getComponentTypes } = require('../lib/vnextConfig'); -const { getJsonMetadata, findAllJson, detectComponentType } = require('../lib/workflow'); +const { buildDbConfig, buildApiConfig } = require('../lib/config'); +const { discoverComponents, toGlobPattern, JSON_IGNORE_PATTERNS } = require('../lib/discover'); +const { loadWorkspace, runForEachSolution } = require('../lib/solutions'); +const { getJsonMetadata, findAllJson, detectComponentType, checkComponentDomain } = require('../lib/workflow'); const { publishComponent, reinitializeSystem } = require('../lib/api'); const { getInstanceId, deleteWorkflow } = require('../lib/db'); const { LOG, printApiError, printErrorSummaryTable } = require('../lib/ui'); async function resetCommand(options) { LOG.header('COMPONENT RESET (Force Update)'); - - const projectRoot = config.get('PROJECT_ROOT'); - - // Get domain from vnext.config.json - let domain, componentTypes; - try { - domain = getDomain(projectRoot); - componentTypes = getComponentTypes(projectRoot); - } catch (error) { - LOG.error(`Failed to read vnext.config.json: ${error.message}`); - return; + + const loaded = loadWorkspace(options); + if (!loaded) return; + + // reset is interactive: with several solutions and no --domain, pick one first. + if (loaded.solutions.length > 1) { + const { domain } = await inquirer.prompt([{ + type: 'list', + name: 'domain', + message: 'Which domain to reset?', + choices: loaded.solutions.map(s => ({ + name: `${s.domain} (${s.fileName})${s.profile ? '' : chalk.dim(' — no CLI profile')}`, + value: s.domain + })) + }]); + + loaded.solutions = loaded.solutions.filter(s => s.domain === domain); + loaded.requestedDomain = domain; } - - // DB Config - const useDockerValue = config.get('USE_DOCKER'); - const dbConfig = { - host: config.get('DB_HOST'), - port: config.get('DB_PORT'), - database: config.get('DB_NAME'), - user: config.get('DB_USER'), - password: config.get('DB_PASSWORD'), - useDocker: useDockerValue === true || useDockerValue === 'true', - dockerContainer: config.get('DOCKER_POSTGRES_CONTAINER') - }; - - // API Config - const apiConfig = { - baseUrl: config.get('API_BASE_URL'), - version: config.get('API_VERSION'), - domain: domain - }; - + + await runForEachSolution(options, { loaded }, resetSolution); +} + +async function resetSolution(solution) { + const profile = solution.profile; + const componentTypes = solution.componentTypes; + const dbConfig = buildDbConfig(profile); + const apiConfig = buildApiConfig(profile); + // Discover folders const spinner = ora(' Scanning folders...').start(); let discovered; try { - discovered = await discoverComponents(projectRoot); + discovered = await discoverComponents(solution); spinner.succeed(chalk.green(' Folders discovered')); } catch (error) { spinner.fail(chalk.red(` Folder scan error: ${error.message}`)); return; } - + // Build choices dynamically const choices = []; for (const [type, folderName] of Object.entries(componentTypes)) { @@ -63,10 +60,10 @@ async function resetCommand(options) { choices.push({ name: `${type} (${folderName}/)`, value: type }); } } - + choices.push(new inquirer.Separator()); choices.push({ name: 'ALL (All folders)', value: 'ALL' }); - + // User selection const { selected } = await inquirer.prompt([{ type: 'list', @@ -74,15 +71,15 @@ async function resetCommand(options) { message: 'Which folder to reset?', choices: choices }]); - + // Find files let jsonFiles = []; - + if (selected === 'ALL') { const files = await findAllJson(discovered); jsonFiles = files.map(f => ({ path: f, - type: detectComponentType(f, projectRoot), + type: detectComponentType(f, solution), fileName: path.basename(f) })); } else { @@ -91,78 +88,79 @@ async function resetCommand(options) { LOG.error(`${selected} folder not found`); return; } - + // Find JSONs in this folder only const pattern = toGlobPattern(dir, '**/*.json'); - const files = await glob(pattern, { - ignore: [ - '**/.meta/**', - '**/.meta', - '**/*.diagram.json', - '**/package*.json', - '**/*config*.json' - ] - }); - + const files = await glob(pattern, { ignore: JSON_IGNORE_PATTERNS }); + jsonFiles = files.map(f => ({ path: f, type: selected, fileName: path.basename(f) })); } - + if (jsonFiles.length === 0) { LOG.warning('No JSON files found'); console.log(); return; } - + // Final confirmation LOG.warning(`${jsonFiles.length} components will be reset!`); console.log(); - + const { confirm } = await inquirer.prompt([{ type: 'confirm', name: 'confirm', message: 'Continue?', default: false }]); - + if (!confirm) { LOG.warning('Operation cancelled.'); console.log(); return; } - + // Group by component type const componentStats = {}; const errors = []; - + console.log(chalk.blue('\n Resetting components...\n')); - + for (const jsonInfo of jsonFiles) { const { path: jsonPath, type, fileName } = jsonInfo; - + // Initialize stats if (!componentStats[type]) { componentStats[type] = { success: 0, failed: 0, skipped: 0, deleted: 0 }; } - + try { const metadata = await getJsonMetadata(jsonPath); - + if (!metadata.key || !metadata.version) { LOG.component(type, fileName, 'skip', 'no key/version'); componentStats[type].skipped++; continue; } - + + // The component must belong to this solution's domain + const domainError = checkComponentDomain(metadata, solution); + if (domainError) { + LOG.component(type, fileName, 'error', domainError); + componentStats[type].failed++; + errors.push({ type, file: fileName, error: domainError, errorCode: 'DOMAIN_MISMATCH' }); + continue; + } + // Detect flow type - const flow = metadata.flow || detectComponentType(jsonPath, projectRoot); - + const flow = metadata.flow || detectComponentType(jsonPath, solution); + // Check if exists in DB const existingId = await getInstanceId(dbConfig, flow, metadata.key, metadata.version); - + // If exists, delete first (force reset) let wasDeleted = false; if (existingId) { @@ -170,10 +168,10 @@ async function resetCommand(options) { wasDeleted = true; componentStats[type].deleted++; } - + // Publish to API const result = await publishComponent(apiConfig.baseUrl, metadata.data); - + if (result.success) { const action = wasDeleted ? 'reset' : 'created'; LOG.component(type, fileName, 'success', `→ ${action}`); @@ -190,54 +188,56 @@ async function resetCommand(options) { errors.push({ type, file: fileName, error: errorMsg }); } } - + // Re-initialize const totalSuccess = Object.values(componentStats).reduce((sum, s) => sum + s.success, 0); - + if (totalSuccess > 0) { console.log(); const reinitSpinner = ora(' Re-initializing system...').start(); const reinitSuccess = await reinitializeSystem(apiConfig.baseUrl, apiConfig.version); - + if (reinitSuccess) { reinitSpinner.succeed(chalk.green(' System re-initialized')); } else { reinitSpinner.warn(chalk.yellow(' System re-initialization failed (continuing)')); } } - + // SUMMARY REPORT LOG.header('RESET SUMMARY'); - + // Component statistics console.log(chalk.white.bold('\n Component Reset Results:\n')); - + for (const [type, stats] of Object.entries(componentStats)) { const successLabel = stats.success > 0 ? chalk.green(`${stats.success} reset`) : ''; const deletedLabel = stats.deleted > 0 ? chalk.yellow(`${stats.deleted} deleted`) : ''; const failedLabel = stats.failed > 0 ? chalk.red(`${stats.failed} failed`) : ''; const skippedLabel = stats.skipped > 0 ? chalk.dim(`${stats.skipped} skipped`) : ''; - + const parts = [successLabel, deletedLabel, failedLabel, skippedLabel].filter(Boolean); console.log(` ${chalk.cyan(type.padEnd(12))} : ${parts.join(', ') || chalk.dim('0')}`); } - + // Errors if (errors.length > 0) { console.log(); LOG.subSeparator(); printErrorSummaryTable(errors); } - + LOG.separator(); - + const totalFailed = Object.values(componentStats).reduce((sum, s) => sum + s.failed, 0); - + if (totalSuccess > 0 && totalFailed === 0) { console.log(chalk.green.bold('\n ✓ Reset completed\n')); } else if (totalFailed > 0) { console.log(chalk.yellow.bold(`\n ⚠ Reset completed (${totalFailed} errors)\n`)); } + + return { success: totalSuccess, failed: totalFailed, errors }; } module.exports = resetCommand; diff --git a/src/commands/sync.js b/src/commands/sync.js index fb8ca72..fc37e9f 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -1,89 +1,67 @@ const chalk = require('chalk'); const ora = require('ora'); const path = require('path'); -const config = require('../lib/config'); +const { buildDbConfig, buildApiConfig } = require('../lib/config'); const { discoverComponents, findAllJsonFiles } = require('../lib/discover'); -const { getDomain, getComponentTypes } = require('../lib/vnextConfig'); +const { runForEachSolution } = require('../lib/solutions'); const { publishComponent, reinitializeSystem } = require('../lib/api'); const { getInstanceId, deleteWorkflow } = require('../lib/db'); -const { getJsonMetadata, detectComponentType } = require('../lib/workflow'); +const { getJsonMetadata, detectComponentType, checkComponentDomain } = require('../lib/workflow'); const { processCsxFile, findAllCsx } = require('../lib/csx'); const { LOG, printApiError, printErrorSummaryTable } = require('../lib/ui'); -async function syncCommand() { +async function syncCommand(options) { LOG.header('SYSTEM SYNC - Add Missing Components'); - - const projectRoot = config.get('PROJECT_ROOT'); - const autoDiscover = config.get('AUTO_DISCOVER'); - - if (!autoDiscover) { + + await runForEachSolution(options, {}, syncSolution); +} + +async function syncSolution(solution) { + const profile = solution.profile; + + if (!profile.AUTO_DISCOVER) { LOG.warning('AUTO_DISCOVER is disabled. To enable:'); console.log(chalk.dim(' workflow config set AUTO_DISCOVER true\n')); return; } - - // Get domain from vnext.config.json - let domain; - try { - domain = getDomain(projectRoot); - } catch (error) { - LOG.error(`Failed to read vnext.config.json: ${error.message}`); - return; - } - - // DB Config - const useDockerValue = config.get('USE_DOCKER'); - const dbConfig = { - host: config.get('DB_HOST'), - port: config.get('DB_PORT'), - database: config.get('DB_NAME'), - user: config.get('DB_USER'), - password: config.get('DB_PASSWORD'), - useDocker: useDockerValue === true || useDockerValue === 'true', - dockerContainer: config.get('DOCKER_POSTGRES_CONTAINER') - }; - - // API Config - const apiConfig = { - baseUrl: config.get('API_BASE_URL'), - version: config.get('API_VERSION'), - domain: domain - }; - + + const dbConfig = buildDbConfig(profile); + const apiConfig = buildApiConfig(profile); + // Discover folders const discoverSpinner = ora('Scanning folders...').start(); let discovered; try { - discovered = await discoverComponents(projectRoot); + discovered = await discoverComponents(solution); discoverSpinner.succeed(chalk.green('Folders discovered')); } catch (error) { discoverSpinner.fail(chalk.red(`Folder scan error: ${error.message}`)); return; } - + // FIRST: Update all CSX files const csxSpinner = ora('Finding CSX files...').start(); let csxFiles; try { - csxFiles = await findAllCsx(projectRoot); + csxFiles = await findAllCsx(solution); csxSpinner.succeed(chalk.green(`${csxFiles.length} CSX files found`)); } catch (error) { csxSpinner.warn(chalk.yellow(`CSX scan error: ${error.message}`)); csxFiles = []; } - + // Update CSX files const csxResults = { success: 0, failed: 0, errors: [] }; - + if (csxFiles.length > 0) { console.log(chalk.blue('\n Writing CSX files to JSONs...\n')); - + for (const csxFile of csxFiles) { const fileName = path.basename(csxFile); - + try { - const result = await processCsxFile(csxFile, projectRoot); - + const result = await processCsxFile(csxFile, solution); + if (result.success) { LOG.component('CSX', fileName, 'success', `→ ${result.updatedJsonCount} JSON, ${result.totalUpdates} refs`); csxResults.success++; @@ -97,7 +75,7 @@ async function syncCommand() { } } } - + // Find all JSON files const findSpinner = ora('Finding JSON files...').start(); let allJsonFiles; @@ -108,46 +86,55 @@ async function syncCommand() { findSpinner.fail(chalk.red(`JSON scan error: ${error.message}`)); return; } - + // Group by component type const componentStats = {}; const errors = []; - + console.log(chalk.blue('\n Publishing components...\n')); - + for (const jsonInfo of allJsonFiles) { const { path: jsonPath, type, fileName } = jsonInfo; - + // Initialize stats if (!componentStats[type]) { componentStats[type] = { success: 0, failed: 0, skipped: 0, existing: 0 }; } - + try { const metadata = await getJsonMetadata(jsonPath); - + if (!metadata.key || !metadata.version) { LOG.component(type, fileName, 'skip', 'no key/version'); componentStats[type].skipped++; continue; } - + + // The component must belong to this solution's domain + const domainError = checkComponentDomain(metadata, solution); + if (domainError) { + LOG.component(type, fileName, 'error', domainError); + componentStats[type].failed++; + errors.push({ type, file: fileName, error: domainError, errorCode: 'DOMAIN_MISMATCH' }); + continue; + } + // Detect flow type - const flow = metadata.flow || detectComponentType(jsonPath, projectRoot); - + const flow = metadata.flow || detectComponentType(jsonPath, solution); + // Check if exists in DB const existingId = await getInstanceId(dbConfig, flow, metadata.key, metadata.version); - + if (existingId) { // Already exists, skip LOG.component(type, fileName, 'skip', 'already exists'); componentStats[type].existing++; continue; } - + // Not in DB, publish to API const result = await publishComponent(apiConfig.baseUrl, metadata.data); - + if (result.success) { LOG.component(type, fileName, 'success', '→ published'); componentStats[type].success++; @@ -163,42 +150,41 @@ async function syncCommand() { errors.push({ type, file: fileName, error: errorMsg }); } } - + // Re-initialize const totalSuccess = Object.values(componentStats).reduce((sum, s) => sum + s.success, 0); - + if (totalSuccess > 0) { console.log(); const reinitSpinner = ora('Re-initializing system...').start(); const reinitSuccess = await reinitializeSystem(apiConfig.baseUrl, apiConfig.version); - + if (reinitSuccess) { reinitSpinner.succeed(chalk.green('System re-initialized')); } else { reinitSpinner.warn(chalk.yellow('System re-initialization failed')); } } - + // SUMMARY REPORT LOG.header('SYNC SUMMARY'); - + // Component statistics console.log(chalk.white.bold('\n Component Publish Results:\n')); - - const componentTypes = getComponentTypes(projectRoot); - for (const [type, folderName] of Object.entries(componentTypes)) { + + for (const [type, folderName] of Object.entries(solution.componentTypes)) { const stats = componentStats[type]; if (stats) { const successLabel = stats.success > 0 ? chalk.green(`${stats.success} added`) : ''; const existingLabel = stats.existing > 0 ? chalk.dim(`${stats.existing} existing`) : ''; const failedLabel = stats.failed > 0 ? chalk.red(`${stats.failed} failed`) : ''; const skippedLabel = stats.skipped > 0 ? chalk.dim(`${stats.skipped} skipped`) : ''; - + const parts = [successLabel, existingLabel, failedLabel, skippedLabel].filter(Boolean); console.log(` ${chalk.cyan(type.padEnd(12))} : ${parts.join(', ') || chalk.dim('0')}`); } } - + // CSX summary if (csxFiles.length > 0) { console.log(); @@ -206,7 +192,7 @@ async function syncCommand() { const csxFailedLabel = csxResults.failed > 0 ? chalk.red(`, ${csxResults.failed} failed`) : ''; console.log(` ${chalk.cyan('CSX'.padEnd(12))} : ${csxSuccessLabel}${csxFailedLabel}`); } - + // Errors const allErrors = [ ...errors, @@ -217,11 +203,11 @@ async function syncCommand() { LOG.subSeparator(); printErrorSummaryTable(allErrors); } - + LOG.separator(); - + const totalFailed = Object.values(componentStats).reduce((sum, s) => sum + s.failed, 0) + csxResults.failed; - + if (totalSuccess === 0 && totalFailed === 0) { console.log(chalk.green.bold('\n ✓ System up to date - All records exist\n')); } else if (totalFailed === 0) { @@ -229,6 +215,8 @@ async function syncCommand() { } else { console.log(chalk.yellow.bold(`\n ⚠ Sync completed (${totalFailed} errors)\n`)); } + + return { success: totalSuccess + csxResults.success, failed: totalFailed, errors: allErrors }; } module.exports = syncCommand; diff --git a/src/commands/update.js b/src/commands/update.js index 74f209d..99cc59c 100644 --- a/src/commands/update.js +++ b/src/commands/update.js @@ -3,67 +3,79 @@ const ora = require('ora'); const path = require('path'); const inquirer = require('inquirer'); const { glob } = require('glob'); -const config = require('../lib/config'); -const { discoverComponents, findAllJsonFiles, resolveFeatureFolders, listFeatureFolders, toGlobPattern } = require('../lib/discover'); -const { getDomain, getComponentTypes } = require('../lib/vnextConfig'); +const { buildDbConfig, buildApiConfig } = require('../lib/config'); +const { + discoverComponents, + resolveFeatureFolders, + listFeatureFolders, + toGlobPattern, + JSON_IGNORE_PATTERNS, + CSX_IGNORE_PATTERNS +} = require('../lib/discover'); +const { loadWorkspace, runForEachSolution } = require('../lib/solutions'); const { publishComponent, reinitializeSystem } = require('../lib/api'); const { getInstanceId, deleteWorkflow } = require('../lib/db'); -const { getJsonMetadata, getGitChangedJson, findAllJson, detectComponentType } = require('../lib/workflow'); +const { getJsonMetadata, getGitChangedJson, findAllJson, detectComponentType, checkComponentDomain } = require('../lib/workflow'); const { processCsxFile, getGitChangedCsx, findAllCsx } = require('../lib/csx'); const { LOG, printApiError, printErrorSummaryTable } = require('../lib/ui'); async function updateCommand(options) { LOG.header('COMPONENT UPDATE'); - - const projectRoot = config.get('PROJECT_ROOT'); - const autoDiscover = config.get('AUTO_DISCOVER'); - - // Get domain from vnext.config.json - let domain; - try { - domain = getDomain(projectRoot); - } catch (error) { - LOG.error(`Failed to read vnext.config.json: ${error.message}`); - return; + + // Precedence: --file > --folder > --all > git-changed + const useFolder = !!options.folder && !options.file; + const useAll = !!options.all && !options.file && !useFolder; + + // --all asks ONCE for every domain that will actually run, before the loop starts. + let loaded = null; + if (useAll) { + loaded = loadWorkspace(options); + if (!loaded) return; + + const targets = loaded.solutions.filter(s => s.profile).map(s => s.domain); + if (targets.length > 0) { + LOG.warning(`ALL components in domain(s) ${targets.join(', ')} will be updated!`); + console.log(); + + const { confirm } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: 'Continue?', + default: false + }]); + + if (!confirm) { + LOG.warning('Operation cancelled.'); + console.log(); + return; + } + } } - - // DB Config - const useDockerValue = config.get('USE_DOCKER'); - const dbConfig = { - host: config.get('DB_HOST'), - port: config.get('DB_PORT'), - database: config.get('DB_NAME'), - user: config.get('DB_USER'), - password: config.get('DB_PASSWORD'), - useDocker: useDockerValue === true || useDockerValue === 'true', - dockerContainer: config.get('DOCKER_POSTGRES_CONTAINER') - }; - - // API Config - const apiConfig = { - baseUrl: config.get('API_BASE_URL'), - version: config.get('API_VERSION'), - domain: domain - }; + + await runForEachSolution( + options, + { forFile: options.file, loaded }, + (solution) => updateSolution(solution, options) + ); +} + +async function updateSolution(solution, options) { + const profile = solution.profile; + const autoDiscover = profile.AUTO_DISCOVER; + const dbConfig = buildDbConfig(profile); + const apiConfig = buildApiConfig(profile); // 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); + folderDirs = await resolveFeatureFolders(solution, options.folder); if (folderDirs.length === 0) { - LOG.error(`No folder matched "${options.folder}"`); - const available = await listFeatureFolders(projectRoot); + LOG.warning(`No folder matched "${options.folder}" in domain "${solution.domain}"`); + const available = await listFeatureFolders(solution); if (available.length > 0) { console.log(chalk.dim(`\n Available folders: ${available.join(', ')}\n`)); } @@ -82,9 +94,7 @@ async function updateCommand(options) { 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/**'] - }); + const found = await glob(toGlobPattern(dir, '**/*.csx'), { ignore: CSX_IGNORE_PATTERNS }); csxFiles.push(...found); } if (csxFiles.length > 0) { @@ -99,7 +109,7 @@ async function updateCommand(options) { // Find all CSX files const csxSpinner = ora('Finding all CSX files...').start(); try { - csxFiles = await findAllCsx(projectRoot); + csxFiles = await findAllCsx(solution); csxSpinner.succeed(chalk.green(`${csxFiles.length} CSX files found`)); } catch (error) { csxSpinner.warn(chalk.yellow(`CSX scan error: ${error.message}`)); @@ -108,8 +118,8 @@ async function updateCommand(options) { // Find changed CSX files in Git const csxSpinner = ora('Finding changed CSX files in Git...').start(); try { - csxFiles = await getGitChangedCsx(projectRoot); - + csxFiles = await getGitChangedCsx(solution); + if (csxFiles.length > 0) { csxSpinner.succeed(chalk.green(`${csxFiles.length} changed CSX files found`)); } else { @@ -119,17 +129,17 @@ async function updateCommand(options) { csxSpinner.warn(chalk.yellow(`CSX scan error: ${error.message}`)); } } - + // Update CSX files if (csxFiles.length > 0) { console.log(chalk.blue('\n Writing CSX files to JSONs...\n')); - + for (const csxFile of csxFiles) { const fileName = path.basename(csxFile); - + try { - const result = await processCsxFile(csxFile, projectRoot); - + const result = await processCsxFile(csxFile, solution); + if (result.success) { LOG.component('CSX', fileName, 'success', `→ ${result.updatedJsonCount} JSON, ${result.totalUpdates} refs`); csxResults.success++; @@ -143,26 +153,26 @@ async function updateCommand(options) { } } } - + let jsonFiles = []; - + // Which JSON files to process? if (options.file) { // Specific file - const filePath = path.isAbsolute(options.file) - ? options.file - : path.join(projectRoot, options.file); - jsonFiles = [{ path: filePath, type: detectComponentType(filePath, projectRoot), fileName: path.basename(filePath) }]; + const filePath = path.isAbsolute(options.file) + ? options.file + : path.join(solution.projectRoot, options.file); + jsonFiles = [{ path: filePath, type: detectComponentType(filePath, solution), 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 }); + const files = await glob(toGlobPattern(dir, '**/*.json'), { ignore: JSON_IGNORE_PATTERNS }); jsonFiles.push(...files.map(f => ({ path: f, - type: detectComponentType(f, projectRoot), + type: detectComponentType(f, solution), fileName: path.basename(f) }))); } @@ -175,95 +185,88 @@ async function updateCommand(options) { spinner.succeed(chalk.green(`${jsonFiles.length} JSON files found`)); } else if (options.all) { - // All JSON files - LOG.warning('ALL components will be updated!'); - console.log(); - - const { confirm } = await inquirer.prompt([{ - type: 'confirm', - name: 'confirm', - message: 'Continue?', - default: false - }]); - - if (!confirm) { - LOG.warning('Operation cancelled.'); - console.log(); - return; - } - + // All JSON files (confirmation already given in updateCommand) const spinner = ora('Finding all JSON files...').start(); - + if (autoDiscover) { - const discovered = await discoverComponents(projectRoot); + const discovered = await discoverComponents(solution); const files = await findAllJson(discovered); jsonFiles = files.map(f => ({ path: f, - type: detectComponentType(f, projectRoot), + type: detectComponentType(f, solution), fileName: path.basename(f) })); } - + spinner.succeed(chalk.green(`${jsonFiles.length} JSON files found`)); } else { // Changed files in Git (default) const spinner = ora('Finding changed JSON files in Git...').start(); - const changedFiles = await getGitChangedJson(projectRoot); - + const changedFiles = await getGitChangedJson(solution); + if (changedFiles.length === 0) { spinner.info(chalk.yellow('No changed JSON files in Git')); console.log(chalk.green('\n ✓ All components up to date\n')); - return; + return { success: csxResults.success, failed: csxResults.failed, errors: csxResults.errors.map(e => ({ type: 'CSX', ...e })) }; } - + jsonFiles = changedFiles.map(f => ({ path: f, - type: detectComponentType(f, projectRoot), + type: detectComponentType(f, solution), fileName: path.basename(f) })); - + spinner.succeed(chalk.green(`${jsonFiles.length} changed JSON files found`)); } - + // Group by component type const componentStats = {}; const errors = []; - + console.log(chalk.blue('\n Publishing components...\n')); - + for (const jsonInfo of jsonFiles) { const { path: jsonPath, type, fileName } = jsonInfo; - + // Initialize stats if (!componentStats[type]) { componentStats[type] = { success: 0, failed: 0, skipped: 0, updated: 0, created: 0 }; } - + try { const metadata = await getJsonMetadata(jsonPath); - + if (!metadata.key || !metadata.version) { LOG.component(type, fileName, 'skip', 'no key/version'); componentStats[type].skipped++; continue; } - + + // The component must belong to this solution's domain + const domainError = checkComponentDomain(metadata, solution); + if (domainError) { + LOG.component(type, fileName, 'error', domainError); + componentStats[type].failed++; + errors.push({ type, file: fileName, error: domainError, errorCode: 'DOMAIN_MISMATCH' }); + continue; + } + // Detect flow type - const flow = metadata.flow || detectComponentType(jsonPath, projectRoot); - + const flow = metadata.flow || detectComponentType(jsonPath, solution); + // Check if exists in DB const existingId = await getInstanceId(dbConfig, flow, metadata.key, metadata.version); - + // If exists, delete first let wasDeleted = false; if (existingId) { await deleteWorkflow(dbConfig, flow, existingId); wasDeleted = true; } - + // Publish to API const result = await publishComponent(apiConfig.baseUrl, metadata.data); - + if (result.success) { if (wasDeleted) { LOG.component(type, fileName, 'success', '→ updated'); @@ -285,38 +288,38 @@ async function updateCommand(options) { errors.push({ type, file: fileName, error: errorMsg }); } } - + // Re-initialize const totalSuccess = Object.values(componentStats).reduce((sum, s) => sum + s.success, 0); - + if (totalSuccess > 0) { console.log(); const reinitSpinner = ora('Re-initializing system...').start(); const reinitSuccess = await reinitializeSystem(apiConfig.baseUrl, apiConfig.version); - + if (reinitSuccess) { reinitSpinner.succeed(chalk.green('System re-initialized')); } else { reinitSpinner.warn(chalk.yellow('System re-initialization failed (continuing)')); } } - + // SUMMARY REPORT LOG.header('UPDATE SUMMARY'); - + // Component statistics console.log(chalk.white.bold('\n Component Update Results:\n')); - + for (const [type, stats] of Object.entries(componentStats)) { const updatedLabel = stats.updated > 0 ? chalk.green(`${stats.updated} updated`) : ''; const createdLabel = stats.created > 0 ? chalk.green(`${stats.created} created`) : ''; const failedLabel = stats.failed > 0 ? chalk.red(`${stats.failed} failed`) : ''; const skippedLabel = stats.skipped > 0 ? chalk.dim(`${stats.skipped} skipped`) : ''; - + const parts = [updatedLabel, createdLabel, failedLabel, skippedLabel].filter(Boolean); console.log(` ${chalk.cyan(type.padEnd(12))} : ${parts.join(', ') || chalk.dim('0')}`); } - + // CSX summary if (csxFiles.length > 0) { console.log(); @@ -324,7 +327,7 @@ async function updateCommand(options) { const csxFailedLabel = csxResults.failed > 0 ? chalk.red(`, ${csxResults.failed} failed`) : ''; console.log(` ${chalk.cyan('CSX'.padEnd(12))} : ${csxSuccessLabel}${csxFailedLabel}`); } - + // Errors const allErrors = [ ...errors, @@ -335,16 +338,18 @@ async function updateCommand(options) { LOG.subSeparator(); printErrorSummaryTable(allErrors); } - + LOG.separator(); - + const totalFailed = Object.values(componentStats).reduce((sum, s) => sum + s.failed, 0) + csxResults.failed; - + if (totalSuccess > 0 && totalFailed === 0) { console.log(chalk.green.bold('\n ✓ Update completed\n')); } else if (totalFailed > 0) { console.log(chalk.yellow.bold(`\n ⚠ Update completed (${totalFailed} errors)\n`)); } + + return { success: totalSuccess + csxResults.success, failed: totalFailed, errors: allErrors }; } module.exports = updateCommand; diff --git a/src/lib/config.js b/src/lib/config.js index f15edb4..ea82a65 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -1,6 +1,4 @@ const Conf = require('conf'); -const fs = require('fs'); -const path = require('path'); // Default config values for a domain const DEFAULT_DOMAIN_CONFIG = { @@ -77,6 +75,49 @@ function getActiveDomainConfig() { return domain; } +/** + * Returns the config profile of a specific domain, merged over the defaults + * so that profiles persisted before a key existed still carry every key. + * Does NOT touch ACTIVE_DOMAIN. + * @param {string} name - Domain name (DOMAIN_NAME) + * @returns {Object|null} Domain profile, or null if no such profile exists + */ +function getDomainConfig(name) { + const domains = config.get('DOMAINS') || []; + const domain = domains.find(d => d.DOMAIN_NAME === name); + return domain ? { ...DEFAULT_DOMAIN_CONFIG, ...domain } : null; +} + +/** + * Builds the dbConfig object expected by lib/db.js from a domain profile. + * @param {Object} profile - Domain profile (see getDomainConfig) + * @returns {Object} dbConfig + */ +function buildDbConfig(profile) { + const useDockerValue = profile.USE_DOCKER; + return { + host: profile.DB_HOST, + port: profile.DB_PORT, + database: profile.DB_NAME, + user: profile.DB_USER, + password: profile.DB_PASSWORD, + useDocker: useDockerValue === true || useDockerValue === 'true', + dockerContainer: profile.DOCKER_POSTGRES_CONTAINER + }; +} + +/** + * Builds the apiConfig object used by the commands from a domain profile. + * @param {Object} profile - Domain profile (see getDomainConfig) + * @returns {Object} { baseUrl, version } + */ +function buildApiConfig(profile) { + return { + baseUrl: profile.API_BASE_URL, + version: profile.API_VERSION + }; +} + /** * Gets a config value from the active domain. * PROJECT_ROOT always returns process.cwd(). @@ -226,43 +267,6 @@ function removeDomain(name) { } } -/** - * Resolves the active domain from vnext.config.json in the given project root. - * If a matching CLI domain profile exists, silently switches to it. - * @param {string} projectRoot - Project root folder (typically cwd) - * @returns {Object} Resolution result with { resolved, switched, domain, previous, reason } - */ -function resolveWorkspaceDomain(projectRoot) { - try { - const configPath = path.join(projectRoot, 'vnext.config.json'); - if (!fs.existsSync(configPath)) { - return { resolved: false, reason: 'no-config-file' }; - } - - const content = JSON.parse(fs.readFileSync(configPath, 'utf8')); - const domain = content.domain; - if (!domain) { - return { resolved: false, reason: 'no-domain-field' }; - } - - const domains = config.get('DOMAINS') || []; - const match = domains.find(d => d.DOMAIN_NAME === domain); - if (!match) { - return { resolved: false, reason: 'no-matching-profile', domain }; - } - - const currentActive = config.get('ACTIVE_DOMAIN'); - if (currentActive === domain) { - return { resolved: true, switched: false, domain }; - } - - config.set('ACTIVE_DOMAIN', domain); - return { resolved: true, switched: true, domain, previous: currentActive }; - } catch { - return { resolved: false, reason: 'error' }; - } -} - module.exports = { get, set, @@ -274,6 +278,8 @@ module.exports = { listDomains, removeDomain, getActiveDomainConfig, - resolveWorkspaceDomain, + getDomainConfig, + buildDbConfig, + buildApiConfig, DEFAULT_DOMAIN_CONFIG }; diff --git a/src/lib/csx.js b/src/lib/csx.js index e6dce7e..e6f85d2 100644 --- a/src/lib/csx.js +++ b/src/lib/csx.js @@ -1,7 +1,7 @@ const fs = require('fs').promises; const path = require('path'); const { glob } = require('glob'); -const { discoverComponents, findAllJsonFiles, findJsonInComponent } = require('./discover'); +const { discoverComponents, findAllJsonFiles, findJsonInComponent, findAllCsxInComponents } = require('./discover'); /** * Encodes CSX file to Base64 @@ -15,12 +15,12 @@ async function encodeToBase64(csxPath) { /** * Finds JSON files that reference the CSX file - * Only searches in paths defined in vnext.config.json + * Only searches in paths defined by the solution * @param {string} csxPath - CSX file path - * @param {string} projectRoot - Project root folder + * @param {Object} solution - Solution object (see lib/solutions.js) * @returns {Promise} Matching JSON file paths */ -async function findJsonFilesForCsx(csxPath, projectRoot) { +async function findJsonFilesForCsx(csxPath, solution) { // Derive the component directory from the CSX path: // CSX files always live under a "src/" subfolder of their component directory. // e.g. core/Workflows/contract/src/AlwaysTrueRule.csx @@ -35,9 +35,9 @@ async function findJsonFilesForCsx(csxPath, projectRoot) { componentDir = parts.slice(0, srcIndex).join(path.sep); } else { // Fallback: search all discovered components (original behaviour) - const discovered = await discoverComponents(projectRoot); + const discovered = await discoverComponents(solution); const jsonFileInfos = await findAllJsonFiles(discovered); - const csxLocation = getCsxLocation(csxPath, projectRoot); + const csxLocation = getCsxLocation(csxPath); const matchingJsons = []; for (const jsonInfo of jsonFileInfos) { try { @@ -53,7 +53,7 @@ async function findJsonFilesForCsx(csxPath, projectRoot) { } // Scan only the JSON files inside this component's directory - const csxLocation = getCsxLocation(csxPath, projectRoot); + const csxLocation = getCsxLocation(csxPath); const jsonFiles = await findJsonInComponent(componentDir); const matchingJsons = []; @@ -74,10 +74,9 @@ async function findJsonFilesForCsx(csxPath, projectRoot) { /** * Calculates CSX location path * @param {string} csxPath - CSX file path - * @param {string} projectRoot - Project root folder * @returns {string} Location path */ -function getCsxLocation(csxPath, projectRoot) { +function getCsxLocation(csxPath) { // Convert to ./src/Rules/MyRule.csx format const parts = csxPath.split(path.sep); const srcIndex = parts.lastIndexOf('src'); @@ -173,18 +172,18 @@ async function readNativeContent(csxPath) { * Updates ALL referencing JSON files * Supports both NAT (native) and B64 (Base64) encoding * @param {string} csxPath - CSX file path - * @param {string} projectRoot - Project root folder + * @param {Object} solution - Solution object * @returns {Promise} Process result */ -async function processCsxFile(csxPath, projectRoot) { +async function processCsxFile(csxPath, solution) { // Read native content const nativeCode = await readNativeContent(csxPath); // Convert to Base64 const base64Code = Buffer.from(nativeCode).toString('base64'); - // Find ALL related JSONs (only in paths defined in vnext.config.json) - const jsonFiles = await findJsonFilesForCsx(csxPath, projectRoot); + // Find ALL related JSONs (only in paths defined by the solution) + const jsonFiles = await findJsonFilesForCsx(csxPath, solution); if (jsonFiles.length === 0) { return { @@ -197,7 +196,7 @@ async function processCsxFile(csxPath, projectRoot) { } // Calculate CSX location - const csxLocation = getCsxLocation(csxPath, projectRoot); + const csxLocation = getCsxLocation(csxPath); // Update each JSON let updatedJsonCount = 0; @@ -230,43 +229,47 @@ async function processCsxFile(csxPath, projectRoot) { } /** - * Finds changed CSX files in Git - * @param {string} projectRoot - Project root folder + * Finds changed CSX files in Git that belong to ONE solution. + * `git status` runs from the git root (which may be above the project root); + * results are filtered down to the solution's componentsRoot. + * @param {Object} solution - Solution object * @returns {Promise} Changed CSX file paths */ -async function getGitChangedCsx(projectRoot) { +async function getGitChangedCsx(solution) { const { exec } = require('child_process'); const util = require('util'); const execPromise = util.promisify(exec); const fsSync = require('fs'); - + + const rootPrefix = path.normalize(solution.componentsRoot) + path.sep; + try { // Find git root - const { stdout: gitRoot } = await execPromise('git rev-parse --show-toplevel', { cwd: projectRoot }); + const { stdout: gitRoot } = await execPromise('git rev-parse --show-toplevel', { cwd: solution.projectRoot }); const gitRootDir = gitRoot.trim(); - + // Run git status from git root const { stdout } = await execPromise('git status --porcelain', { cwd: gitRootDir }); const lines = stdout.split('\n').filter(Boolean); - + const csxFiles = lines .filter(line => line.includes('.csx')) .map(line => { // Git status output format: "XY filename" const file = line.substring(3).trim(); - + // Git output is relative to git root, not project root const fullPath = path.join(gitRootDir, file); - + return path.normalize(fullPath); }) .filter(file => { - // Only .csx files that exist and are in our project - return file.endsWith('.csx') && + // Only .csx files that exist and are inside this solution's componentsRoot + return file.endsWith('.csx') && fsSync.existsSync(file) && - file.startsWith(path.normalize(projectRoot)); + file.startsWith(rootPrefix); }); - + return csxFiles; } catch (error) { return []; @@ -276,12 +279,11 @@ async function getGitChangedCsx(projectRoot) { /** * Finds all CSX files in discovered components ONLY * Does NOT scan folders outside of paths definition - * @param {string} projectRoot - Project root folder + * @param {Object} solution - Solution object * @returns {Promise} CSX file paths */ -async function findAllCsx(projectRoot) { - const { findAllCsxInComponents } = require('./discover'); - return findAllCsxInComponents(projectRoot); +async function findAllCsx(solution) { + return findAllCsxInComponents(solution); } module.exports = { diff --git a/src/lib/discover.js b/src/lib/discover.js index e671e5c..fc6797b 100644 --- a/src/lib/discover.js +++ b/src/lib/discover.js @@ -1,7 +1,28 @@ const { glob } = require('glob'); const path = require('path'); const fs = require('fs'); -const { getComponentsRoot, getComponentTypes } = require('./vnextConfig'); + +/** + * Glob ignore rules for component JSON files. Shared by every place that + * scans a component folder so the rules cannot drift apart. + */ +const JSON_IGNORE_PATTERNS = [ + '**/.meta/**', + '**/.meta', + '**/*.diagram.json', + '**/package*.json', + '**/*config*.json' +]; + +/** + * Glob ignore rules for CSX files. + */ +const CSX_IGNORE_PATTERNS = [ + '**/.meta/**', + '**/.meta', + '**/node_modules/**', + '**/dist/**' +]; /** * Builds a glob pattern with forward slashes. @@ -17,26 +38,25 @@ function toGlobPattern(dir, suffix) { } /** - * Discovers component folders based on vnext.config.json paths - * Only scans folders defined in paths, ignores everything else - * @param {string} projectRoot - Project root folder (PROJECT_ROOT) - * @returns {Object} Discovered component folders + * Discovers component folders of one solution based on its paths. + * Only scans folders defined in paths, ignores everything else. + * @param {Object} solution - Solution object (see lib/solutions.js) + * @returns {Object} Discovered component folders { type: absoluteDir } */ -async function discoverComponents(projectRoot) { - const componentsRoot = getComponentsRoot(projectRoot); - const componentTypes = getComponentTypes(projectRoot); - +async function discoverComponents(solution) { + const { componentsRoot, componentTypes } = solution; + const discovered = {}; - + // Only look for folders defined in paths for (const [type, folderName] of Object.entries(componentTypes)) { const componentDir = path.join(componentsRoot, folderName); - + if (fs.existsSync(componentDir) && fs.statSync(componentDir).isDirectory()) { discovered[type] = componentDir; } } - + return discovered; } @@ -51,15 +71,9 @@ async function findJsonInComponent(componentDir) { const pattern = toGlobPattern(componentDir, '**/*.json'); const files = await glob(pattern, { - ignore: [ - '**/.meta/**', - '**/.meta', - '**/*.diagram.json', - '**/package*.json', - '**/*config*.json' - ] + ignore: JSON_IGNORE_PATTERNS }); - + return files; } @@ -71,12 +85,12 @@ async function findJsonInComponent(componentDir) { */ async function findAllJsonFiles(discovered) { const allFiles = []; - + // Only scan folders that were discovered from paths for (const [type, componentDir] of Object.entries(discovered)) { if (componentDir) { const files = await findJsonInComponent(componentDir); - + for (const file of files) { allFiles.push({ path: file, @@ -86,36 +100,31 @@ async function findAllJsonFiles(discovered) { } } } - + return allFiles; } /** * Finds all CSX files in discovered components ONLY * Does NOT scan folders outside of paths definition - * @param {string} projectRoot - Project root folder + * @param {Object} solution - Solution object * @returns {Promise} CSX file paths */ -async function findAllCsxInComponents(projectRoot) { - const discovered = await discoverComponents(projectRoot); +async function findAllCsxInComponents(solution) { + const discovered = await discoverComponents(solution); const allCsxFiles = []; - + // Only scan folders that were discovered from paths for (const [type, componentDir] of Object.entries(discovered)) { if (componentDir) { const pattern = toGlobPattern(componentDir, '**/*.csx'); const files = await glob(pattern, { - ignore: [ - '**/.meta/**', - '**/.meta', - '**/node_modules/**', - '**/dist/**' - ] + ignore: CSX_IGNORE_PATTERNS }); allCsxFiles.push(...files); } } - + return allCsxFiles; } @@ -137,7 +146,7 @@ function getComponentDir(discovered, component) { */ function listDiscovered(discovered, componentTypes) { const results = []; - + for (const [type, folderName] of Object.entries(componentTypes)) { results.push({ name: type, @@ -146,50 +155,53 @@ function listDiscovered(discovered, componentTypes) { found: !!discovered[type] }); } - + return results; } /** - * Resolves a folder name to a list of directories to update. + * Resolves a folder name to a list of directories to update, within ONE solution. * * 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. + * relative to projectRoot, or relative to componentsRoot) AND that + * directory lies under this solution's componentsRoot, that single + * directory is returned. A directory outside the componentsRoot belongs + * to another solution and is not accepted here. * b) Feature name: otherwise, `name` is treated as a feature folder name and * matched against every discovered component-type root. Every * `/` that exists as a directory is collected, so a * feature spread across Workflows/, Views/, Schemas/, … is gathered. * - * @param {string} projectRoot - Project root folder + * @param {Object} solution - Solution object * @param {string} name - Folder name or relative/absolute path * @returns {Promise} Absolute directory paths (empty if nothing matched) */ -async function resolveFeatureFolders(projectRoot, name) { +async function resolveFeatureFolders(solution, name) { const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory(); + const root = path.normalize(solution.componentsRoot); + const isInsideRoot = (p) => { + const abs = path.normalize(path.resolve(p)); + return abs === root || abs.startsWith(root + path.sep); + }; - // a) Exact-path resolution + // a) Exact-path resolution (only accepted inside this solution's componentsRoot) 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 - } + candidates.push(path.join(solution.projectRoot, name)); + candidates.push(path.join(solution.componentsRoot, name)); } for (const candidate of candidates) { - if (isDir(candidate)) { + if (isDir(candidate) && isInsideRoot(candidate)) { return [path.resolve(candidate)]; } } // b) Feature-name match across discovered component roots - const discovered = await discoverComponents(projectRoot); + const discovered = await discoverComponents(solution); const dirs = []; for (const componentDir of Object.values(discovered)) { const featureDir = path.join(componentDir, name); @@ -206,11 +218,11 @@ async function resolveFeatureFolders(projectRoot, name) { * 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 + * @param {Object} solution - Solution object * @returns {Promise} Sorted unique feature folder names */ -async function listFeatureFolders(projectRoot) { - const discovered = await discoverComponents(projectRoot); +async function listFeatureFolders(solution) { + const discovered = await discoverComponents(solution); const names = new Set(); for (const componentDir of Object.values(discovered)) { @@ -238,18 +250,20 @@ async function listFeatureFolders(projectRoot) { */ function detectComponentTypeFromPath(filePath, componentTypes) { const normalizedPath = filePath.toLowerCase(); - + for (const [type, folderName] of Object.entries(componentTypes)) { const folderPattern = `/${folderName.toLowerCase()}/`; if (normalizedPath.includes(folderPattern)) { return type; } } - + return 'unknown'; } module.exports = { + JSON_IGNORE_PATTERNS, + CSX_IGNORE_PATTERNS, toGlobPattern, discoverComponents, findJsonInComponent, diff --git a/src/lib/solutions.js b/src/lib/solutions.js new file mode 100644 index 0000000..a902da6 --- /dev/null +++ b/src/lib/solutions.js @@ -0,0 +1,347 @@ +const fs = require('fs'); +const path = require('path'); +const chalk = require('chalk'); +const config = require('./config'); +const { + DEFAULT_SOLUTION_FILE, + SOLUTION_FILE_PATTERN, + loadVnextConfig, + getDomainFromConfig, + getComponentsRootFromConfig, + getComponentTypesFromConfig +} = require('./vnextConfig'); +const { LOG, printSolutionBanner, printErrorSummaryTable } = require('./ui'); + +/** + * A "solution" is one solution file (vnext.config.json or + * vnext.{domain}.config.json) in the workspace root, resolved into everything + * a command needs to operate on that domain: + * + * { + * domain, // authoritative: the `domain` field inside the file + * fileName, // e.g. 'vnext.partner.config.json' + * configPath, // absolute path of the solution file + * fileNameDomain, // domain part of the file name, or null for the default file + * projectRoot, // workspace root (process.cwd()) + * config, // full parsed solution file + * componentsRoot, // absolute path of paths.componentsRoot + * componentTypes, // { workflows: 'Workflows', tasks: 'Tasks', ... } + * profile, // CLI domain profile (API/DB settings) or null if none exists + * warnings // non-fatal notes, printed under the banner + * } + */ + +/** + * Lists the solution file names in a workspace root. The default file (if + * present) always comes first, the rest alphabetically. + * @param {string} projectRoot - Workspace root + * @returns {string[]} Solution file names + */ +function discoverSolutionFiles(projectRoot) { + let entries; + try { + entries = fs.readdirSync(projectRoot); + } catch (error) { + return []; + } + + const files = []; + if (entries.includes(DEFAULT_SOLUTION_FILE)) { + files.push(DEFAULT_SOLUTION_FILE); + } + + const extra = entries + .filter(name => name !== DEFAULT_SOLUTION_FILE) + .filter(name => SOLUTION_FILE_PATTERN.test(name)) + .filter(name => !name.includes('.diagram.')) + .sort(); + + return files.concat(extra); +} + +/** + * Loads a single solution file into a solution object (without profile). + * @param {string} projectRoot - Workspace root + * @param {string} fileName - Solution file name + * @returns {Object} Solution object + */ +function loadSolution(projectRoot, fileName) { + const parsed = loadVnextConfig(projectRoot, fileName); + const domain = getDomainFromConfig(parsed, fileName); + const componentsRoot = getComponentsRootFromConfig(projectRoot, parsed, fileName); + const componentTypes = getComponentTypesFromConfig(parsed, fileName); + + const match = fileName.match(SOLUTION_FILE_PATTERN); + const fileNameDomain = match ? match[1] : null; + + const warnings = []; + if (fileNameDomain && fileNameDomain !== domain) { + warnings.push(`${fileName}: file name says "${fileNameDomain}" but the domain field is "${domain}" — using "${domain}"`); + } + + return { + domain, + fileName, + configPath: path.join(projectRoot, fileName), + fileNameDomain, + projectRoot, + config: parsed, + componentsRoot: path.resolve(componentsRoot), + componentTypes, + profile: null, + warnings + }; +} + +/** + * Loads every solution file in the workspace and attaches CLI profiles. + * @param {string} projectRoot - Workspace root + * @param {Object} [opts] + * @param {string} [opts.domain] - Only keep the solution with this domain + * @returns {Object} { solutions, allSolutions, loadErrors, requestedDomain, projectRoot } + * - solutions: filtered by opts.domain (all if not given) + * - allSolutions: every successfully loaded solution (unfiltered) + * - loadErrors: [{ fileName, error }] for files that could not be used + * @throws {Error} when the workspace has no solution file at all + */ +function loadSolutions(projectRoot, { domain } = {}) { + const files = discoverSolutionFiles(projectRoot); + if (files.length === 0) { + throw new Error(`${DEFAULT_SOLUTION_FILE} not found: ${path.join(projectRoot, DEFAULT_SOLUTION_FILE)}`); + } + + const loadErrors = []; + let all = []; + for (const fileName of files) { + try { + all.push(loadSolution(projectRoot, fileName)); + } catch (error) { + loadErrors.push({ fileName, error: error.message }); + } + } + + // Two files declaring the same domain is ambiguous — drop both. + const byDomain = {}; + for (const s of all) { + (byDomain[s.domain] = byDomain[s.domain] || []).push(s); + } + const duplicated = Object.keys(byDomain).filter(d => byDomain[d].length > 1); + for (const d of duplicated) { + for (const s of byDomain[d]) { + const others = byDomain[d].filter(o => o !== s).map(o => o.fileName).join(', '); + loadErrors.push({ fileName: s.fileName, error: `duplicate domain "${d}" (also declared in ${others}) — skipped` }); + } + } + all = all.filter(s => !duplicated.includes(s.domain)); + + for (const s of all) { + s.profile = config.getDomainConfig(s.domain); + } + + const solutions = domain ? all.filter(s => s.domain === domain) : all; + + return { + solutions, + allSolutions: all, + loadErrors, + requestedDomain: domain || null, + projectRoot + }; +} + +/** + * Finds the solution whose componentsRoot contains the given path. + * With nested roots the deepest (longest) componentsRoot wins. + * @param {Object[]} solutions - Candidate solutions + * @param {string} filePath - Absolute or cwd-relative path + * @returns {Object|null} Owning solution, or null + */ +function findSolutionForPath(solutions, filePath) { + const abs = path.normalize(path.resolve(filePath)); + let best = null; + + for (const s of solutions) { + const root = path.normalize(s.componentsRoot); + if (abs === root || abs.startsWith(root + path.sep)) { + if (!best || root.length > path.normalize(best.componentsRoot).length) { + best = s; + } + } + } + + return best; +} + +/** + * Loads the workspace for a command, printing errors and setting the exit + * code on fatal problems. Non-fatal load errors (one broken file among + * several) are printed and the rest continues. + * @param {Object} options - Command options (uses options.domain) + * @returns {Object|null} loadSolutions() result, or null when nothing can run + */ +function loadWorkspace(options = {}) { + const projectRoot = config.get('PROJECT_ROOT'); + + let loaded; + try { + loaded = loadSolutions(projectRoot, { domain: options.domain }); + } catch (error) { + LOG.error(`Failed to read ${DEFAULT_SOLUTION_FILE}: ${error.message}`); + process.exitCode = 1; + return null; + } + + for (const e of loaded.loadErrors) { + LOG.error(`${e.fileName}: ${e.error}`); + } + + if (loaded.solutions.length === 0) { + if (loaded.requestedDomain) { + const available = loaded.allSolutions.map(s => s.domain); + const hint = available.length > 0 ? ` Available: ${available.join(', ')}` : ''; + LOG.error(`Domain "${loaded.requestedDomain}" not found in this workspace.${hint}`); + } else { + LOG.error('No usable solution file found in this workspace.'); + } + process.exitCode = 1; + return null; + } + + return loaded; +} + +/** + * Narrows the solutions to the one owning `filePath` (for --file modes). + * @returns {Object[]|null} One-element array, or null after printing an error + */ +function resolveSolutionsForFile(loaded, filePath) { + const abs = path.isAbsolute(filePath) ? filePath : path.join(loaded.projectRoot, filePath); + const owner = findSolutionForPath(loaded.allSolutions, abs); + + if (owner) { + if (!loaded.solutions.includes(owner)) { + LOG.error(`"${filePath}" belongs to domain "${owner.domain}" (${owner.fileName}), not "${loaded.requestedDomain}".`); + process.exitCode = 1; + return null; + } + return [owner]; + } + + if (loaded.solutions.length === 1) { + const only = loaded.solutions[0]; + LOG.warning(`"${filePath}" is outside the components root of "${only.domain}" — processing with that domain anyway.`); + return [only]; + } + + LOG.error(`Cannot tell which domain owns "${filePath}": it is outside every solution's components root. Use --domain .`); + process.exitCode = 1; + return null; +} + +/** + * Prints the cross-domain summary shown after a multi-solution run. + */ +function printWorkspaceSummary(outcomes) { + LOG.header('WORKSPACE SUMMARY'); + console.log(); + + const width = Math.max(...outcomes.map(o => o.solution.domain.length), 8); + for (const o of outcomes) { + const name = chalk.cyan(o.solution.domain.padEnd(width)); + if (o.status === 'skipped') { + console.log(` ${name} : ${chalk.yellow(`skipped (${o.reason})`)}`); + } else if (o.status === 'failed') { + console.log(` ${name} : ${chalk.red(`failed (${o.reason})`)}`); + } else if (o.hasCounts) { + const ok = o.success > 0 ? chalk.green(`${o.success} ok`) : chalk.dim('0 ok'); + const failed = o.failed > 0 ? chalk.red(`, ${o.failed} failed`) : ''; + console.log(` ${name} : ${ok}${failed}`); + } else { + console.log(` ${name} : ${chalk.green('done')}`); + } + } + + const allErrors = outcomes.flatMap(o => o.errors || []); + if (allErrors.length > 0) { + console.log(); + LOG.subSeparator(); + printErrorSummaryTable(allErrors); + } + + console.log(); +} + +/** + * Runs `fn(solution)` for every solution in the workspace, sequentially, with + * a banner per solution. This is the shared loop behind check/csx/sync/update/reset. + * + * @param {Object} options - Command options (uses options.domain) + * @param {Object} [runOpts] + * @param {boolean} [runOpts.requireProfile=true] - Skip solutions without a CLI profile + * @param {string} [runOpts.forFile] - A --file argument; narrows to the owning solution + * @param {Object} [runOpts.loaded] - A pre-loaded loadWorkspace() result (e.g. after a prompt) + * @param {Function} fn - async (solution) => { success, failed, errors } | void + * @returns {Promise} Per-solution outcomes, or null on a fatal error + */ +async function runForEachSolution(options, runOpts, fn) { + const { requireProfile = true, forFile = null, loaded: preloaded = null } = runOpts || {}; + + const loaded = preloaded || loadWorkspace(options); + if (!loaded) return null; + + let solutions = loaded.solutions; + if (forFile) { + solutions = resolveSolutionsForFile(loaded, forFile); + if (!solutions) return null; + } + + const outcomes = []; + + for (const solution of solutions) { + printSolutionBanner(solution); + for (const w of solution.warnings) { + LOG.warning(w); + } + + if (requireProfile && !solution.profile) { + LOG.warning(`No CLI domain profile for "${solution.domain}" — skipped.`); + console.log(chalk.dim(` Run: wf domain add ${solution.domain} --API_BASE_URL --DB_NAME `)); + console.log(); + outcomes.push({ solution, status: 'skipped', reason: 'no CLI profile', errors: [] }); + continue; + } + + try { + const result = await fn(solution); + const hasCounts = !!result && (result.success !== undefined || result.failed !== undefined); + const errors = ((result && result.errors) || []).map(e => ({ ...e, domain: solution.domain })); + outcomes.push({ + solution, + status: 'done', + hasCounts, + success: (result && result.success) || 0, + failed: (result && result.failed) || 0, + errors + }); + } catch (error) { + LOG.error(`[${solution.domain}] ${error.message}`); + outcomes.push({ solution, status: 'failed', reason: error.message, errors: [] }); + process.exitCode = 1; + } + } + + if (solutions.length > 1) { + printWorkspaceSummary(outcomes); + } + + return outcomes; +} + +module.exports = { + discoverSolutionFiles, + loadSolution, + loadSolutions, + findSolutionForPath, + loadWorkspace, + runForEachSolution +}; diff --git a/src/lib/ui.js b/src/lib/ui.js index 5e11576..09c274b 100644 --- a/src/lib/ui.js +++ b/src/lib/ui.js @@ -1,5 +1,4 @@ const chalk = require('chalk'); -const config = require('./config'); const LOG = { separator: () => console.log(chalk.cyan('═'.repeat(60))), @@ -81,16 +80,19 @@ function printApiError(result, componentType, fileName) { /** * Prints a two-layer summary table for batch operation errors. * Each row shows component/file/HTTP/errorCode/detail, and if validation - * errors exist they are expanded below the row. + * errors exist they are expanded below the row. A Domain column is added + * when any row carries a `domain` (multi-solution runs). */ function printErrorSummaryTable(errors) { if (!errors || errors.length === 0) return; - const COL = { idx: 3, type: 16, file: 26, http: 5, code: 16, detail: 36 }; - const totalWidth = COL.idx + COL.type + COL.file + COL.http + COL.code + COL.detail + 15; + const withDomain = errors.some(e => e.domain); + const COL = { idx: 3, domain: 12, type: 16, file: 26, http: 5, code: 16, detail: 36 }; + const totalWidth = COL.idx + (withDomain ? COL.domain + 3 : 0) + COL.type + COL.file + COL.http + COL.code + COL.detail + 15; const pad = (str, len) => String(str || '').padEnd(len); const divider = () => console.log(chalk.dim(` ${'─'.repeat(totalWidth)}`)); + const domainCell = (text, style) => withDomain ? style(pad(text, COL.domain)) + chalk.dim(' │ ') : ''; console.log(chalk.red.bold(`\n ERRORS (${errors.length})\n`)); @@ -98,6 +100,7 @@ function printErrorSummaryTable(errors) { console.log( chalk.dim(' ') + chalk.white.bold(pad('#', COL.idx)) + chalk.dim(' │ ') + + domainCell('Domain', chalk.white.bold) + chalk.white.bold(pad('Component', COL.type)) + chalk.dim(' │ ') + chalk.white.bold(pad('File', COL.file)) + chalk.dim(' │ ') + chalk.white.bold(pad('HTTP', COL.http)) + chalk.dim(' │ ') + @@ -110,12 +113,13 @@ function printErrorSummaryTable(errors) { const err = errors[i]; const api = err.apiError; const statusCode = err.statusCode || ''; - const errorCode = api?.errorCode || ''; + const errorCode = err.errorCode || api?.errorCode || ''; const detail = (api?.detail || err.error || '').substring(0, COL.detail); console.log( chalk.dim(' ') + chalk.dim(pad(i + 1, COL.idx)) + chalk.dim(' │ ') + + domainCell(err.domain, chalk.magenta) + chalk.cyan(pad(err.type, COL.type)) + chalk.dim(' │ ') + chalk.white(pad(err.file, COL.file)) + chalk.dim(' │ ') + chalk.red.bold(pad(statusCode, COL.http)) + chalk.dim(' │ ') + @@ -155,21 +159,32 @@ function printErrorSummaryTable(errors) { } /** - * Prints a boxed banner showing the active domain and API URL. - * Called from the preAction hook before every command. + * Prints a boxed banner for one solution: its domain, the solution file it + * came from and the API it will talk to (or a hint that no CLI profile exists). + * Printed once per solution by lib/solutions.js runForEachSolution. + * @param {Object} solution - Solution object (see lib/solutions.js) */ -function printActiveDomainBanner() { - const domain = config.get('ACTIVE_DOMAIN') || 'default'; - const apiUrl = config.get('API_BASE_URL') || '-'; - - const domainLine = `Domain: ${domain}`; - const apiLine = `API: ${apiUrl}`; - const innerWidth = Math.max(domainLine.length, apiLine.length) + 4; +function printSolutionBanner(solution) { + const apiText = solution.profile + ? (solution.profile.API_BASE_URL || '-') + : 'no CLI profile'; + + const lines = [ + { text: `Domain: ${solution.domain}`, style: chalk.white.bold }, + { text: `Solution: ${solution.fileName}`, style: chalk.dim }, + { text: `API: ${apiText}`, style: solution.profile ? chalk.dim : chalk.yellow } + ]; + const innerWidth = Math.max(...lines.map(l => l.text.length)) + 4; console.log(); console.log(chalk.cyan(` ┌${'─'.repeat(innerWidth)}┐`)); - console.log(chalk.cyan(' │') + ` ${chalk.white.bold(domainLine)}${' '.repeat(innerWidth - domainLine.length - 2)}` + chalk.cyan('│')); - console.log(chalk.cyan(' │') + ` ${chalk.dim(apiLine)}${' '.repeat(innerWidth - apiLine.length - 2)}` + chalk.cyan('│')); + for (const line of lines) { + console.log( + chalk.cyan(' │') + + ` ${line.style(line.text)}${' '.repeat(innerWidth - line.text.length - 2)}` + + chalk.cyan('│') + ); + } console.log(chalk.cyan(` └${'─'.repeat(innerWidth)}┘`)); } @@ -177,5 +192,5 @@ module.exports = { LOG, printApiError, printErrorSummaryTable, - printActiveDomainBanner + printSolutionBanner }; diff --git a/src/lib/vnextConfig.js b/src/lib/vnextConfig.js index 9957ce2..e7d9f59 100644 --- a/src/lib/vnextConfig.js +++ b/src/lib/vnextConfig.js @@ -1,124 +1,138 @@ const fs = require('fs'); const path = require('path'); -let cachedConfig = null; -let cachedProjectRoot = null; +/** + * Default solution file name. A workspace may additionally contain + * `vnext.{domain}.config.json` files (see SOLUTION_FILE_PATTERN); each one is + * an independent solution with its own domain and componentsRoot. + */ +const DEFAULT_SOLUTION_FILE = 'vnext.config.json'; /** - * Reads and parses vnext.config.json file + * Matches `vnext.{domain}.config.json`. The captured group is the domain name + * as written in the file name (single segment, no dots). Note that the + * default file name does not match this pattern and is handled explicitly. + */ +const SOLUTION_FILE_PATTERN = /^vnext\.([^.]+)\.config\.json$/; + +/** + * Reads and parses a solution file. Stateless: no caching — callers keep the + * parsed object (see lib/solutions.js) instead of re-reading. * @param {string} projectRoot - Project root folder - * @returns {Object} vnext.config.json content + * @param {string} [fileName] - Solution file name (default: vnext.config.json) + * @returns {Object} Parsed solution file content */ -function loadVnextConfig(projectRoot) { - // Cache check - if (cachedConfig && cachedProjectRoot === projectRoot) { - return cachedConfig; - } +function loadVnextConfig(projectRoot, fileName = DEFAULT_SOLUTION_FILE) { + const configPath = path.join(projectRoot, fileName); - const configPath = path.join(projectRoot, 'vnext.config.json'); - if (!fs.existsSync(configPath)) { - throw new Error(`vnext.config.json not found: ${configPath}`); + throw new Error(`${fileName} not found: ${configPath}`); } try { const content = fs.readFileSync(configPath, 'utf8'); - cachedConfig = JSON.parse(content); - cachedProjectRoot = projectRoot; - return cachedConfig; + return JSON.parse(content); } catch (error) { - throw new Error(`Failed to read vnext.config.json: ${error.message}`); + throw new Error(`Failed to read ${fileName}: ${error.message}`); } } /** - * Returns domain information - * @param {string} projectRoot - Project root folder + * Returns the domain declared in a parsed solution file. + * @param {Object} config - Parsed solution file + * @param {string} [fileName] - Used in error messages * @returns {string} Domain name */ -function getDomain(projectRoot) { - const config = loadVnextConfig(projectRoot); - +function getDomainFromConfig(config, fileName = DEFAULT_SOLUTION_FILE) { if (!config.domain) { - throw new Error('domain not found in vnext.config.json'); + throw new Error(`domain not found in ${fileName}`); } - return config.domain; } /** - * Returns paths information - * @param {string} projectRoot - Project root folder + * Returns the paths object of a parsed solution file. + * @param {Object} config - Parsed solution file + * @param {string} [fileName] - Used in error messages * @returns {Object} Paths object */ -function getPaths(projectRoot) { - const config = loadVnextConfig(projectRoot); - +function getPathsFromConfig(config, fileName = DEFAULT_SOLUTION_FILE) { if (!config.paths) { - throw new Error('paths not found in vnext.config.json'); + throw new Error(`paths not found in ${fileName}`); } - return config.paths; } /** - * Returns components root folder + * Returns the absolute components root of a parsed solution file. * @param {string} projectRoot - Project root folder + * @param {Object} config - Parsed solution file + * @param {string} [fileName] - Used in error messages * @returns {string} Components root folder path */ -function getComponentsRoot(projectRoot) { - const paths = getPaths(projectRoot); - +function getComponentsRootFromConfig(projectRoot, config, fileName = DEFAULT_SOLUTION_FILE) { + const paths = getPathsFromConfig(config, fileName); + if (!paths.componentsRoot) { - throw new Error('paths.componentsRoot not found in vnext.config.json'); + throw new Error(`paths.componentsRoot not found in ${fileName}`); } - + return path.join(projectRoot, paths.componentsRoot); } /** - * Returns component types and folder names - * @param {string} projectRoot - Project root folder + * Returns component types and folder names of a parsed solution file. + * Every key under `paths` except `componentsRoot` is a component type. + * @param {Object} config - Parsed solution file + * @param {string} [fileName] - Used in error messages * @returns {Object} Component type -> folder name mapping */ -function getComponentTypes(projectRoot) { - const paths = getPaths(projectRoot); - - // All paths except componentsRoot are component types +function getComponentTypesFromConfig(config, fileName = DEFAULT_SOLUTION_FILE) { + const paths = getPathsFromConfig(config, fileName); + const componentTypes = {}; - for (const [key, value] of Object.entries(paths)) { if (key !== 'componentsRoot') { componentTypes[key] = value; } } - + return componentTypes; } -/** - * Returns full configuration - * @param {string} projectRoot - Project root folder - * @returns {Object} Full vnext.config.json content - */ -function getFullConfig(projectRoot) { - return loadVnextConfig(projectRoot); +// --- Thin wrappers that read the file on every call (kept for compatibility) --- + +function getDomain(projectRoot, fileName = DEFAULT_SOLUTION_FILE) { + return getDomainFromConfig(loadVnextConfig(projectRoot, fileName), fileName); } -/** - * Clears the cache - */ -function clearCache() { - cachedConfig = null; - cachedProjectRoot = null; +function getPaths(projectRoot, fileName = DEFAULT_SOLUTION_FILE) { + return getPathsFromConfig(loadVnextConfig(projectRoot, fileName), fileName); +} + +function getComponentsRoot(projectRoot, fileName = DEFAULT_SOLUTION_FILE) { + return getComponentsRootFromConfig(projectRoot, loadVnextConfig(projectRoot, fileName), fileName); +} + +function getComponentTypes(projectRoot, fileName = DEFAULT_SOLUTION_FILE) { + return getComponentTypesFromConfig(loadVnextConfig(projectRoot, fileName), fileName); +} + +function getFullConfig(projectRoot, fileName = DEFAULT_SOLUTION_FILE) { + return loadVnextConfig(projectRoot, fileName); } module.exports = { + DEFAULT_SOLUTION_FILE, + SOLUTION_FILE_PATTERN, loadVnextConfig, + getDomainFromConfig, + getPathsFromConfig, + getComponentsRootFromConfig, + getComponentTypesFromConfig, getDomain, getPaths, getComponentsRoot, getComponentTypes, - getFullConfig, - clearCache + getFullConfig }; diff --git a/src/lib/workflow.js b/src/lib/workflow.js index 3cbd2b2..ab4de17 100644 --- a/src/lib/workflow.js +++ b/src/lib/workflow.js @@ -1,61 +1,72 @@ const fs = require('fs').promises; const path = require('path'); const { glob } = require('glob'); -const { publishComponent } = require('./api'); -const { getInstanceId, deleteWorkflow } = require('./db'); -const { getComponentTypes } = require('./vnextConfig'); -const { discoverComponents, findJsonInComponent, toGlobPattern } = require('./discover'); +const { toGlobPattern, JSON_IGNORE_PATTERNS } = require('./discover'); /** - * Gets key and version values from JSON file + * Gets key, version, flow and domain values from a component JSON file * @param {string} jsonPath - JSON file path * @returns {Promise} Metadata object */ async function getJsonMetadata(jsonPath) { const content = await fs.readFile(jsonPath, 'utf8'); const data = JSON.parse(content); - + return { key: data.key || null, version: data.version || null, flow: data.flow || null, + domain: data.domain || null, data: data }; } /** - * Detects component type from file path based on vnext.config.json paths + * Checks that a component belongs to the solution it was found in. + * A component must declare `domain` and it must equal the solution's domain; + * otherwise it would be published with another domain's API/DB settings. + * @param {Object} metadata - Result of getJsonMetadata + * @param {Object} solution - Solution object (see lib/solutions.js) + * @returns {string|null} Error message, or null when the component is fine + */ +function checkComponentDomain(metadata, solution) { + if (!metadata.domain) { + return `component has no "domain" field (solution domain "${solution.domain}")`; + } + if (metadata.domain !== solution.domain) { + return `component domain "${metadata.domain}" does not match solution domain "${solution.domain}"`; + } + return null; +} + +/** + * Detects component type from file path based on the solution's paths * @param {string} jsonPath - JSON file path - * @param {string} projectRoot - Project root folder + * @param {Object} solution - Solution object * @returns {string} Component type (sys-flows, sys-tasks, etc.) */ -function detectComponentType(jsonPath, projectRoot) { +function detectComponentType(jsonPath, solution) { const pathLower = jsonPath.toLowerCase(); - - try { - const componentTypes = getComponentTypes(projectRoot); - - // Check each component type folder - for (const [type, folderName] of Object.entries(componentTypes)) { - const folderPattern = `/${folderName.toLowerCase()}/`; - if (pathLower.includes(folderPattern)) { - // Map to flow type - switch (type.toLowerCase()) { - case 'workflows': return 'sys-flows'; - case 'tasks': return 'sys-tasks'; - case 'schemas': return 'sys-schemas'; - case 'views': return 'sys-views'; - case 'functions': return 'sys-functions'; - case 'extensions': return 'sys-extensions'; - case 'mappings': return 'sys-mappings'; - default: return `sys-${type.toLowerCase()}`; - } + const componentTypes = (solution && solution.componentTypes) || {}; + + // Check each component type folder + for (const [type, folderName] of Object.entries(componentTypes)) { + const folderPattern = `/${folderName.toLowerCase()}/`; + if (pathLower.includes(folderPattern)) { + // Map to flow type + switch (type.toLowerCase()) { + case 'workflows': return 'sys-flows'; + case 'tasks': return 'sys-tasks'; + case 'schemas': return 'sys-schemas'; + case 'views': return 'sys-views'; + case 'functions': return 'sys-functions'; + case 'extensions': return 'sys-extensions'; + case 'mappings': return 'sys-mappings'; + default: return `sys-${type.toLowerCase()}`; } } - } catch (error) { - // Fallback to path-based detection } - + // Fallback: detect from path directly if (pathLower.includes('/workflows/')) return 'sys-flows'; if (pathLower.includes('/tasks/')) return 'sys-tasks'; @@ -69,92 +80,51 @@ function detectComponentType(jsonPath, projectRoot) { } /** - * Processes a single component (DB check → delete if exists → publish) - * @param {string} jsonPath - JSON file path - * @param {Object} dbConfig - Database configuration - * @param {string} baseUrl - API base URL - * @param {string} projectRoot - Project root folder - * @returns {Promise} Process result - */ -async function processComponent(jsonPath, dbConfig, baseUrl, projectRoot) { - const metadata = await getJsonMetadata(jsonPath); - - if (!metadata.key || !metadata.version) { - throw new Error('No key or version found in JSON'); - } - - const componentType = detectComponentType(jsonPath, projectRoot); - const flow = metadata.flow || componentType; - - // 1. Check if exists in DB - const existingId = await getInstanceId(dbConfig, flow, metadata.key, metadata.version); - - // 2. If exists, delete first - let wasDeleted = false; - if (existingId) { - await deleteWorkflow(dbConfig, flow, existingId); - wasDeleted = true; - } - - // 3. Publish to API - const result = await publishComponent(baseUrl, metadata.data); - - if (!result.success) { - throw new Error(result.error); - } - - return { - key: metadata.key, - version: metadata.version, - componentType: componentType, - wasDeleted: wasDeleted, - success: true - }; -} - -/** - * Finds changed JSON files in Git - * Only returns files within PROJECT_ROOT - * @param {string} projectRoot - Project root folder + * Finds changed JSON files in Git that belong to ONE solution. + * `git status` runs from the git root (which may be above the project root); + * results are filtered down to the solution's componentsRoot. + * @param {Object} solution - Solution object * @returns {Promise} Changed JSON file paths */ -async function getGitChangedJson(projectRoot) { +async function getGitChangedJson(solution) { const { exec } = require('child_process'); const util = require('util'); const execPromise = util.promisify(exec); const fsSync = require('fs'); - + + const rootPrefix = path.normalize(solution.componentsRoot) + path.sep; + try { // Find git root - const { stdout: gitRoot } = await execPromise('git rev-parse --show-toplevel', { cwd: projectRoot }); + const { stdout: gitRoot } = await execPromise('git rev-parse --show-toplevel', { cwd: solution.projectRoot }); const gitRootDir = gitRoot.trim(); - + // Run git status from git root const { stdout } = await execPromise('git status --porcelain', { cwd: gitRootDir }); const lines = stdout.split('\n').filter(Boolean); - + const jsonFiles = lines .filter(line => line.includes('.json')) .map(line => { // Git status output format: "XY filename" const file = line.substring(3).trim(); - + // Git output is relative to git root const fullPath = path.join(gitRootDir, file); - + return path.normalize(fullPath); }) .filter(file => { - // Filter workflow JSONs and only those in project + // Filter component JSONs and only those inside this solution's componentsRoot const fileName = path.basename(file); - return file.endsWith('.json') && - !fileName.includes('package') && + return file.endsWith('.json') && + !fileName.includes('package') && !fileName.includes('config') && !fileName.includes('.diagram.') && fsSync.existsSync(file) && - file.startsWith(path.normalize(projectRoot)); + file.startsWith(rootPrefix); }); - + return jsonFiles; } catch (error) { return []; @@ -169,13 +139,7 @@ async function getGitChangedJson(projectRoot) { async function findAllJsonInComponent(componentDir) { const pattern = toGlobPattern(componentDir, '**/*.json'); const files = await glob(pattern, { - ignore: [ - '**/.meta/**', - '**/.meta', - '**/*.diagram.json', - '**/package*.json', - '**/*config*.json' - ] + ignore: JSON_IGNORE_PATTERNS }); return files; } @@ -188,7 +152,7 @@ async function findAllJsonInComponent(componentDir) { */ async function findAllJson(discovered) { const allJsons = []; - + // Only scan folders that were discovered from paths for (const component in discovered) { const componentDir = discovered[component]; @@ -197,14 +161,14 @@ async function findAllJson(discovered) { allJsons.push(...jsons); } } - + return allJsons; } module.exports = { getJsonMetadata, + checkComponentDomain, detectComponentType, - processComponent, getGitChangedJson, findAllJsonInComponent, findAllJson