@ngocsangairvds/vsaf 3.2.11 → 3.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngocsangairvds/vsaf",
3
- "version": "3.2.11",
3
+ "version": "3.2.13",
4
4
  "description": "improve confluence format",
5
5
  "keywords": ["claude", "claude-code", "ai", "sdlc", "framework", "bmad", "gitnexus", "superpowers"],
6
6
  "bin": {
package/src/global.js CHANGED
@@ -140,10 +140,13 @@ async function setupVdsScriptsMcp() {
140
140
  ok('Đã sao chép vds-scripts.');
141
141
 
142
142
  info('Đang khởi tạo môi trường Python (uv sync)...');
143
- if (!exec(`uv sync --frozen --project "${destDir}"`, { silent: true })) {
144
- exec(`uv sync --project "${destDir}"`, { silent: true });
143
+ const syncOk = exec(`uv sync --frozen --package vds-cli --project "${destDir}"`, { silent: true })
144
+ || exec(`uv sync --package vds-cli --project "${destDir}"`, { silent: true });
145
+ if (syncOk) {
146
+ ok('Môi trường Python đã sẵn sàng.');
147
+ } else {
148
+ warn('uv sync thất bại — vds-cli có thể chưa sẵn sàng');
145
149
  }
146
- ok('Môi trường Python đã sẵn sàng.');
147
150
 
148
151
  installVdsCliwrapper(destDir);
149
152
 
@@ -160,11 +163,13 @@ async function setupVdsScriptsMcp() {
160
163
  const jiraToken = await promptSecret(' Nhập JIRA_TOKEN: ');
161
164
  const confluenceToken = await promptSecret(' Nhập INTERNAL_CONFLUENCE_TOKEN: ');
162
165
  const extConfluenceToken = await promptSecret(' Nhập EXTERNAL_CONFLUENCE_TOKEN: ');
166
+ const vidpUsername = (await promptInput(' Nhập VDS_VIDP_USERNAME: '));
167
+ const vidpPassword = (await promptSecret(' Nhập VDS_VIDP_PASSWORD: '));
163
168
 
164
169
  const confluenceInternalUrl = (await promptInput(' Nhập CONFLUENCE_INTERNAL_URL (ex: http://10.254.136.35:8090): ')) || 'http://10.254.136.35:8090';
165
- const confluenceExternalUrl = (await promptInput(' Nhập CONFLUENCE_EXTERNAL_URL (ex: https://atlassian.digital.vn): ')) || 'https://atlassian.digital.vn';
170
+ const confluenceExternalUrl = (await promptInput(' Nhập CONFLUENCE_EXTERNAL_URL (ex: https://atlassian.digital.vn): '));
166
171
 
167
- const envContent = [
172
+ const envLines = [
168
173
  `VDS_USERNAME=${vdsUsername}`,
169
174
  `VDS_PASSWORD=${vdsPassword}`,
170
175
  `BITBUCKET_TOKEN=${bitbucketToken}`,
@@ -174,13 +179,17 @@ async function setupVdsScriptsMcp() {
174
179
  'JIRA_BASE_URL=https://jira.viettelmoney.vn/',
175
180
  `CONFLUENCE_INTERNAL_URL=${confluenceInternalUrl}`,
176
181
  `CONFLUENCE_EXTERNAL_URL=${confluenceExternalUrl}`,
177
- 'BITBUCKET_BASE_URL=http://bitbucket.digital.vn'
178
- ].join('\n') + '\n';
182
+ 'BITBUCKET_BASE_URL=http://bitbucket.digital.vn',
183
+ 'VDS_VIDP_BASE_URL=http://cloud-idp.digital.vn/api/idp-gw',
184
+ `VDS_VIDP_USERNAME=${vidpUsername}`,
185
+ `VDS_VIDP_PASSWORD=${vidpPassword}`,
186
+ ];
179
187
 
180
- fs.writeFileSync(envFile, envContent);
188
+ fs.writeFileSync(envFile, envLines.join('\n') + '\n');
181
189
  ok('Đã lưu cấu hình an toàn vào ~/.vds/.env');
182
190
  } else {
183
191
  ok('Đã tìm thấy file cấu hình ~/.vds/.env');
192
+ await ensureVidpEnv(envFile);
184
193
  }
185
194
 
186
195
  exec('claude mcp remove vds-orchestrator', { silent: true });
@@ -193,6 +202,33 @@ async function setupVdsScriptsMcp() {
193
202
  }
194
203
  }
195
204
 
205
+ async function ensureVidpEnv(envFile) {
206
+ const current = fs.readFileSync(envFile, 'utf8');
207
+ if (/^\s*(VDS_)?VIDP_BASE_URL\s*=/m.test(current)) return;
208
+
209
+ const readKey = (key) => {
210
+ const match = current.match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, 'm'));
211
+ return match ? match[1].trim() : '';
212
+ };
213
+ const vdsUsername = readKey('VDS_USERNAME');
214
+ const vdsPassword = readKey('VDS_PASSWORD');
215
+
216
+ info('VIDP third-party gateway chưa được cấu hình — sẽ dùng VDS credentials (Enter để chấp nhận, nhập giá trị khác để override):');
217
+ const vidpUsername = (await promptInput(` Nhập VDS_VIDP_USERNAME (Enter để dùng "${vdsUsername}"): `)) || vdsUsername;
218
+ const vidpPassword = (await promptSecret(' Nhập VDS_VIDP_PASSWORD (Enter để dùng VDS_PASSWORD): ')) || vdsPassword;
219
+
220
+ const suffix = [
221
+ '',
222
+ 'VDS_VIDP_BASE_URL=http://cloud-idp.digital.vn/api/idp-gw',
223
+ `VDS_VIDP_USERNAME=${vidpUsername}`,
224
+ `VDS_VIDP_PASSWORD=${vidpPassword}`,
225
+ '',
226
+ ].join('\n');
227
+ const sep = current.endsWith('\n') ? '' : '\n';
228
+ fs.appendFileSync(envFile, sep + suffix);
229
+ ok('Đã thêm cấu hình VIDP vào ~/.vds/.env');
230
+ }
231
+
196
232
  function promptInput(question) {
197
233
  return new Promise((resolve) => {
198
234
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -78,6 +78,7 @@ vds-cli git --help
78
78
  - `vds-cli elastic ...`
79
79
  - `vds-cli grafana ...`
80
80
  - `vds-cli sonarqube ...`
81
+ - `vds-cli vidp ...` — VIDP third-party gateway (trigger workflows, poll job status)
81
82
 
82
83
  ### Documentation and validation
83
84
  - `vds-cli openapi ...`
@@ -17,6 +17,7 @@ from .tools import (
17
17
  register_confluence_tools,
18
18
  register_git_tools,
19
19
  register_jira_tools,
20
+ register_vidp_tools,
20
21
  )
21
22
 
22
23
  # Initialize FastMCP server
@@ -51,6 +52,7 @@ register_jira_tools(mcp, execute_vds_cli)
51
52
  register_confluence_tools(mcp, execute_vds_cli)
52
53
  register_bitbucket_tools(mcp, execute_vds_cli)
53
54
  register_git_tools(mcp, execute_vds_cli)
55
+ register_vidp_tools(mcp, execute_vds_cli)
54
56
 
55
57
 
56
58
  try:
@@ -4,10 +4,12 @@ from .bitbucket_tools import register_bitbucket_tools
4
4
  from .confluence_tools import register_confluence_tools
5
5
  from .git_tools import register_git_tools
6
6
  from .jira_tools import register_jira_tools
7
+ from .vidp_tools import register_vidp_tools
7
8
 
8
9
  __all__ = [
9
10
  "register_bitbucket_tools",
10
11
  "register_confluence_tools",
11
12
  "register_git_tools",
12
13
  "register_jira_tools",
14
+ "register_vidp_tools",
13
15
  ]
@@ -0,0 +1,64 @@
1
+ """VIDP tools for MCP server.
2
+
3
+ Thin wrappers around ``vds-cli vidp ...`` so the VIDP gateway is reachable
4
+ from any MCP client that talks to the ``vds-orchestrator`` server.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections.abc import Callable
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ if TYPE_CHECKING:
14
+ from mcp.server.fastmcp import FastMCP
15
+
16
+
17
+ def register_vidp_tools(mcp: FastMCP, execute_cli: Callable[..., Any]) -> None:
18
+ """Register VIDP tools with the MCP server."""
19
+
20
+ @mcp.tool()
21
+ async def vidp_trigger(workflow: str, payload: Any | None = None) -> str:
22
+ """Trigger a VIDP third-party action by workflow UUID or alias.
23
+
24
+ Maps to ``POST /third-party/v2/start/{workflow_id}``.
25
+
26
+ Args:
27
+ workflow: VIDP workflow UUID (e.g. ``4afa9a1d-d36c-441a-b2fd-ba9612a854c1``)
28
+ OR a friendly alias defined in ``~/.vds/vidp-workflows.json``
29
+ (e.g. ``build-jenkins-by-job-name``). Aliases are case-insensitive.
30
+ payload: Optional JSON payload. Any JSON-serialisable object;
31
+ does not need to start with ``tenantToken``.
32
+
33
+ Returns:
34
+ JSON string with endpoint, alias used (if any), workflowId, request
35
+ payload, and full response.
36
+ """
37
+ args: list[str] = ["vidp", "trigger", workflow]
38
+ if payload is not None:
39
+ args.extend(["--payload", json.dumps(payload)])
40
+ result = await execute_cli(*args)
41
+ if result["returncode"] != 0:
42
+ return f"Error: {result['stderr'] or result['stdout']}"
43
+ return result["stdout"]
44
+
45
+ @mcp.tool()
46
+ async def vidp_status(job_id: str, raw: bool = False) -> str:
47
+ """Get full workflow/stage detail for a VIDP job.
48
+
49
+ Maps to ``GET /third-party/v2/status/{job_id}?full=true``.
50
+
51
+ Args:
52
+ job_id: VIDP job id.
53
+ raw: If true, return only the raw VIDP response.
54
+
55
+ Returns:
56
+ JSON string with normalized job state and the raw VIDP response.
57
+ """
58
+ args: list[str] = ["vidp", "status", job_id]
59
+ if raw:
60
+ args.append("--raw")
61
+ result = await execute_cli(*args)
62
+ if result["returncode"] != 0:
63
+ return f"Error: {result['stderr'] or result['stdout']}"
64
+ return result["stdout"]
@@ -51,6 +51,7 @@ members = [
51
51
  # "telegram_bridge", # disabled 2026-04-30 — aiogram 3.27 pydantic<2.13 vs memory-orchestrator pydantic>=2.13.3; restore when aiogram >=3.28.0 ships PR #1795
52
52
  "vds_sync_orchestrator",
53
53
  "text_utils_orchestrator",
54
+ "vidp_orchestrator",
54
55
  ]
55
56
 
56
57
  [tool.pytest.ini_options]
@@ -451,6 +451,12 @@ def public_interface(ctx: typer.Context) -> None:
451
451
  _run_service("public_interface", list(ctx.args))
452
452
 
453
453
 
454
+ @app.command(context_settings=PASS_THRU_CONTEXT)
455
+ def vidp(ctx: typer.Context) -> None:
456
+ """VIDP third-party gateway operations (trigger workflows, poll job status)."""
457
+ _run_service("vidp", list(ctx.args))
458
+
459
+
454
460
  @app.command(context_settings=PASS_THRU_CONTEXT)
455
461
  def scheduler(ctx: typer.Context) -> None:
456
462
  """Ecosystem scheduler operations (schedule/event/chain/workflow); requires VDS_SCHEDULER_ENABLED=true."""
@@ -175,6 +175,11 @@ ORCHESTRATORS = {
175
175
  "path": "public_interface_boundary_orchestrator",
176
176
  "entry": "vds-public-interface",
177
177
  },
178
+ "vidp": {
179
+ "module": "vds_vidp_orchestrator.cli",
180
+ "path": "vidp_orchestrator",
181
+ "entry": "vds-vidp",
182
+ },
178
183
  "scheduler": {
179
184
  "module": "vds_scheduler_orchestrator.cli",
180
185
  "path": "scheduler_orchestrator",
@@ -0,0 +1,31 @@
1
+ # vds-vidp-orchestrator
2
+
3
+ VIDP third-party gateway orchestrator for the VDS scripts ecosystem.
4
+
5
+ Wraps two VIDP endpoints:
6
+
7
+ - `POST /third-party/v2/start/{workflowId}` — trigger a workflow action
8
+ - `GET /third-party/v2/status/{jobId}?full=true` — fetch full job/stage detail
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ # CLI
14
+ vds-cli vidp trigger <workflowId> --payload '{"key":"value"}'
15
+ vds-cli vidp status <jobId>
16
+
17
+ # Direct
18
+ uv run vds-vidp trigger <workflowId> --payload-file ./payload.json
19
+ ```
20
+
21
+ ## Environment
22
+
23
+ Loaded from the shared VDS env file (`~/.vds/.env`). Settings use prefix
24
+ `VDS_VIDP_` but legacy `VIDP_*` names are accepted as fallbacks.
25
+
26
+ | Variable | Required | Notes |
27
+ | --------------------------------- | -------- | -------------------------------------- |
28
+ | `VDS_VIDP_BASE_URL` / `VIDP_BASE_URL` | yes | Base URL of the VIDP gateway |
29
+ | `VDS_VIDP_USERNAME` / `VIDP_USERNAME` | yes | Basic-auth username |
30
+ | `VDS_VIDP_PASSWORD` / `VIDP_PASSWORD` | yes | Basic-auth password |
31
+ | `VDS_VIDP_TIMEOUT_MS` | no | Request timeout in ms (default `30000`) |
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "vds-vidp-orchestrator"
3
+ version = "0.1.0"
4
+ description = "VDS VIDP Orchestrator — trigger third-party workflows and poll job status via VIDP gateway"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "VDS Team" }]
9
+ keywords = ["vidp", "idp", "third-party", "vds", "orchestrator"]
10
+
11
+ dependencies = [
12
+ "httpx>=0.28.1",
13
+ "typer>=0.24.1",
14
+ "pydantic>=2.12.5",
15
+ "pydantic-settings>=2.12.0",
16
+ "structlog>=25.5.0",
17
+ "rich>=14.3.3",
18
+ "vds-cli-common",
19
+ "vds-platform-core",
20
+ ]
21
+
22
+ [project.scripts]
23
+ vds-vidp = "vds_vidp_orchestrator.cli:app"
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=8.0.0",
28
+ "pytest-httpx>=0.35.0",
29
+ "pytest-asyncio>=0.24.0",
30
+ "ruff>=0.11.12",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["hatchling>=1.20.0"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/vds_vidp_orchestrator"]
39
+
40
+ [tool.uv]
41
+ package = true
42
+
43
+ [tool.uv.sources]
44
+ vds_vidp_orchestrator = { workspace = true }
45
+ vds-cli-common = { workspace = true }
46
+ vds-platform-core = { workspace = true }
47
+
48
+ [tool.ruff]
49
+ line-length = 100
50
+ target-version = "py311"
@@ -0,0 +1,26 @@
1
+ """VDS VIDP Orchestrator package."""
2
+
3
+ from vds_vidp_orchestrator.client import VidpClient
4
+ from vds_vidp_orchestrator.config import VidpSettings, load_settings
5
+ from vds_vidp_orchestrator.workflows import (
6
+ load_aliases,
7
+ load_package_aliases,
8
+ load_user_aliases,
9
+ package_workflows_file,
10
+ resolve_workflow_id,
11
+ save_user_aliases,
12
+ user_workflows_file,
13
+ )
14
+
15
+ __all__ = [
16
+ "VidpClient",
17
+ "VidpSettings",
18
+ "load_settings",
19
+ "load_aliases",
20
+ "load_package_aliases",
21
+ "load_user_aliases",
22
+ "package_workflows_file",
23
+ "user_workflows_file",
24
+ "resolve_workflow_id",
25
+ "save_user_aliases",
26
+ ]
@@ -0,0 +1,246 @@
1
+ """CLI for the VIDP orchestrator.
2
+
3
+ Examples:
4
+ vds-vidp trigger <workflowId>
5
+ vds-vidp trigger <workflowId> --payload '{"foo": "bar"}'
6
+ vds-vidp trigger <workflowId> --payload-file ./payload.json
7
+ vds-vidp status <jobId>
8
+ vds-vidp status <jobId> --raw
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Annotated
16
+
17
+ import typer
18
+ from vds_cli_common.app import make_vds_app
19
+ from vds_cli_common.context import CLIContext
20
+ from vds_cli_common.output import OutputManager
21
+ from vds_platform_core.errors import SettingsError
22
+ from vds_platform_core.logging import configure_structlog
23
+
24
+ from vds_vidp_orchestrator.client import VidpClient
25
+ from vds_vidp_orchestrator.config import load_settings
26
+ from vds_vidp_orchestrator.workflows import (
27
+ load_aliases,
28
+ load_package_aliases,
29
+ load_user_aliases,
30
+ package_workflows_file,
31
+ resolve_workflow_id,
32
+ save_user_aliases,
33
+ user_workflows_file,
34
+ )
35
+
36
+ configure_structlog()
37
+ app = make_vds_app(
38
+ name="vds-vidp",
39
+ help="VDS VIDP CLI — trigger third-party workflows and poll job status.",
40
+ settings_factory=load_settings,
41
+ )
42
+
43
+
44
+ def _resolve_payload(payload: str | None, payload_file: Path | None) -> object | None:
45
+ if payload and payload_file:
46
+ raise typer.BadParameter("Use either --payload or --payload-file, not both.")
47
+ if payload:
48
+ try:
49
+ return json.loads(payload)
50
+ except json.JSONDecodeError as exc:
51
+ raise typer.BadParameter(f"--payload is not valid JSON: {exc}") from exc
52
+ if payload_file:
53
+ try:
54
+ return json.loads(payload_file.read_text(encoding="utf-8"))
55
+ except json.JSONDecodeError as exc:
56
+ raise typer.BadParameter(f"--payload-file is not valid JSON: {exc}") from exc
57
+ return None
58
+
59
+
60
+ @app.command()
61
+ def trigger(
62
+ ctx: typer.Context,
63
+ workflow: Annotated[
64
+ str,
65
+ typer.Argument(
66
+ help="Workflow UUID OR alias from ~/.vds/vidp-workflows.json (e.g. 'build-jenkins-by-job-name').",
67
+ ),
68
+ ],
69
+ payload: Annotated[
70
+ str | None,
71
+ typer.Option("--payload", "-p", help="Inline JSON payload"),
72
+ ] = None,
73
+ payload_file: Annotated[
74
+ Path | None,
75
+ typer.Option(
76
+ "--payload-file",
77
+ "-f",
78
+ exists=True,
79
+ readable=True,
80
+ dir_okay=False,
81
+ help="Path to a JSON file containing the payload",
82
+ ),
83
+ ] = None,
84
+ ) -> None:
85
+ """Trigger a VIDP third-party action by workflow id or alias."""
86
+ out = OutputManager(CLIContext.from_typer_context(ctx))
87
+ try:
88
+ workflow_id, alias_used = resolve_workflow_id(workflow)
89
+ body = _resolve_payload(payload, payload_file)
90
+ settings = load_settings()
91
+ with VidpClient(settings) as client:
92
+ result = client.trigger(workflow_id, body)
93
+ out.output_json(
94
+ {
95
+ "tool": "vidp.trigger",
96
+ "endpoint": f"/third-party/v2/start/{workflow_id}",
97
+ "aliasUsed": alias_used,
98
+ "workflowId": workflow_id,
99
+ "requestPayload": body if body is not None else {},
100
+ "response": result,
101
+ }
102
+ )
103
+ except KeyError as exc:
104
+ out.output_error(str(exc))
105
+ raise typer.Exit(1) from None
106
+ except SettingsError as exc:
107
+ out.output_error(f"Configuration error: {exc}", hint="Check VDS_VIDP_* / VIDP_* env vars.")
108
+ raise typer.Exit(1) from None
109
+ except Exception as exc: # noqa: BLE001 — surface as JSON for AI consumers
110
+ out.output_error(str(exc))
111
+ raise typer.Exit(1) from None
112
+
113
+
114
+ @app.command()
115
+ def status(
116
+ ctx: typer.Context,
117
+ job_id: Annotated[
118
+ str,
119
+ typer.Argument(help="Job id (the {jobId} segment of the VIDP status endpoint)"),
120
+ ],
121
+ raw: Annotated[
122
+ bool,
123
+ typer.Option("--raw", help="Emit the raw VIDP response only (skip normalization)."),
124
+ ] = False,
125
+ ) -> None:
126
+ """Fetch full workflow/stage detail for a VIDP job."""
127
+ out = OutputManager(CLIContext.from_typer_context(ctx))
128
+ try:
129
+ settings = load_settings()
130
+ with VidpClient(settings) as client:
131
+ response = client.status(job_id)
132
+ if raw:
133
+ out.output_json(response)
134
+ return
135
+ normalized = VidpClient.normalize_status(response)
136
+ out.output_json(
137
+ {
138
+ "tool": "vidp.status",
139
+ "endpoint": f"/third-party/v2/status/{job_id}?full=true",
140
+ "normalized": normalized,
141
+ "response": response,
142
+ }
143
+ )
144
+ except SettingsError as exc:
145
+ out.output_error(f"Configuration error: {exc}", hint="Check VDS_VIDP_* / VIDP_* env vars.")
146
+ raise typer.Exit(1) from None
147
+ except Exception as exc: # noqa: BLE001
148
+ out.output_error(str(exc))
149
+ raise typer.Exit(1) from None
150
+
151
+
152
+ workflows_app = typer.Typer(
153
+ name="workflows",
154
+ help=(
155
+ "Manage workflow alias registry. Package defaults ship with vds-scripts "
156
+ "(read-only); add/remove writes to the user override file "
157
+ "(~/.vds/vidp-workflows.json or $VDS_VIDP_WORKFLOWS_FILE)."
158
+ ),
159
+ )
160
+ app.add_typer(workflows_app, name="workflows")
161
+
162
+
163
+ @workflows_app.command("list")
164
+ def workflows_list(ctx: typer.Context) -> None:
165
+ """List alias → workflow-id mappings, separated by source."""
166
+ out = OutputManager(CLIContext.from_typer_context(ctx))
167
+ package = load_package_aliases()
168
+ user = load_user_aliases()
169
+ overridden = {k for k in user if k in package and user[k] != package[k]}
170
+ out.output_json(
171
+ {
172
+ "packageFile": str(package_workflows_file()),
173
+ "userFile": str(user_workflows_file()),
174
+ "package": package,
175
+ "user": user,
176
+ "userOverridesPackage": sorted(overridden),
177
+ "merged": load_aliases(),
178
+ }
179
+ )
180
+
181
+
182
+ @workflows_app.command("add")
183
+ def workflows_add(
184
+ ctx: typer.Context,
185
+ name: Annotated[str, typer.Argument(help="Alias name (e.g. 'build-jenkins-by-job-name')")],
186
+ workflow_id: Annotated[str, typer.Argument(help="Workflow UUID")],
187
+ force: Annotated[
188
+ bool,
189
+ typer.Option("--force", help="Overwrite if alias already exists in user file."),
190
+ ] = False,
191
+ ) -> None:
192
+ """Add or update an alias in the USER override file.
193
+
194
+ Package-shipped aliases are read-only; use ``--force`` to override them
195
+ locally via the user file.
196
+ """
197
+ out = OutputManager(CLIContext.from_typer_context(ctx))
198
+ user = load_user_aliases()
199
+ package = load_package_aliases()
200
+ if name in user and not force:
201
+ out.output_error(
202
+ f"Alias '{name}' already in user file → '{user[name]}'. Use --force to overwrite."
203
+ )
204
+ raise typer.Exit(1) from None
205
+ user[name] = workflow_id
206
+ path = save_user_aliases(user)
207
+ out.output_json(
208
+ {
209
+ "saved": str(path),
210
+ "alias": name,
211
+ "workflowId": workflow_id,
212
+ "overridesPackage": name in package and package[name] != workflow_id,
213
+ }
214
+ )
215
+
216
+
217
+ @workflows_app.command("remove")
218
+ def workflows_remove(
219
+ ctx: typer.Context,
220
+ name: Annotated[str, typer.Argument(help="Alias name to remove from user file")],
221
+ ) -> None:
222
+ """Remove an alias from the USER override file.
223
+
224
+ Package-shipped aliases cannot be removed via CLI — edit the package
225
+ workflows.json directly to change the curated catalogue.
226
+ """
227
+ out = OutputManager(CLIContext.from_typer_context(ctx))
228
+ user = load_user_aliases()
229
+ if name not in user:
230
+ package = load_package_aliases()
231
+ if name in package:
232
+ out.output_error(
233
+ f"Alias '{name}' is shipped with the package (read-only). "
234
+ f"Edit {package_workflows_file()} to change it, or use "
235
+ f"'workflows add {name} <other-uuid> --force' to override locally."
236
+ )
237
+ else:
238
+ out.output_error(f"Alias '{name}' not found.")
239
+ raise typer.Exit(1) from None
240
+ removed = user.pop(name)
241
+ path = save_user_aliases(user)
242
+ out.output_json({"saved": str(path), "removed": name, "wasMappedTo": removed})
243
+
244
+
245
+ if __name__ == "__main__":
246
+ app()
@@ -0,0 +1,104 @@
1
+ """HTTP client for the VIDP third-party gateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+ from urllib.parse import quote
7
+
8
+ import httpx
9
+ from vds_platform_core.http.errors import map_http_error
10
+
11
+ if TYPE_CHECKING:
12
+ from vds_vidp_orchestrator.config import VidpSettings
13
+
14
+
15
+ class VidpClient:
16
+ """Thin synchronous client around the two VIDP endpoints we expose."""
17
+
18
+ def __init__(self, settings: VidpSettings) -> None:
19
+ self.settings = settings
20
+ self._client: httpx.Client | None = None
21
+
22
+ def _get_client(self) -> httpx.Client:
23
+ if self._client is None:
24
+ self._client = httpx.Client(
25
+ base_url=self.settings.normalized_base_url,
26
+ headers={"Accept": "application/json", **self.settings.auth_header},
27
+ timeout=self.settings.timeout_seconds,
28
+ )
29
+ return self._client
30
+
31
+ def _request(
32
+ self,
33
+ method: str,
34
+ path: str,
35
+ *,
36
+ json_body: Any | None = None,
37
+ ) -> dict[str, Any]:
38
+ client = self._get_client()
39
+ response = client.request(method, path, json=json_body)
40
+ try:
41
+ data: Any = response.json()
42
+ except ValueError:
43
+ data = {"raw": response.text}
44
+
45
+ if not response.is_success:
46
+ raise map_http_error(response.status_code, data, service="vidp")
47
+ return data if isinstance(data, dict) else {"data": data}
48
+
49
+ def trigger(self, workflow_id: str, payload: Any | None = None) -> dict[str, Any]:
50
+ """Trigger a third-party action by workflow id.
51
+
52
+ Mirrors ``POST /third-party/v2/start/{workflowId}``.
53
+ """
54
+ body = payload if payload is not None else {}
55
+ path = f"third-party/v2/start/{quote(workflow_id, safe='')}"
56
+ return self._request("POST", path, json_body=body)
57
+
58
+ def status(self, job_id: str) -> dict[str, Any]:
59
+ """Fetch full workflow/stage status for a VIDP job.
60
+
61
+ Mirrors ``GET /third-party/v2/status/{jobId}?full=true``.
62
+ """
63
+ path = f"third-party/v2/status/{quote(job_id, safe='')}?full=true"
64
+ return self._request("GET", path)
65
+
66
+ @staticmethod
67
+ def normalize_status(raw: dict[str, Any]) -> dict[str, Any]:
68
+ """Flatten the nested status response into the shape the original MCP returned."""
69
+ data = raw.get("data") or {}
70
+ workflows_raw = data.get("workflows") or []
71
+ workflows = []
72
+ for wf in workflows_raw:
73
+ steps = (wf or {}).get("steps") or {}
74
+ stages_raw = steps.get("stages") or []
75
+ workflows.append(
76
+ {
77
+ "name": (wf or {}).get("name"),
78
+ "status": (wf or {}).get("status"),
79
+ "stepStatus": steps.get("status"),
80
+ "stages": [
81
+ {"name": (s or {}).get("name"), "status": (s or {}).get("status")}
82
+ for s in stages_raw
83
+ ],
84
+ }
85
+ )
86
+ return {
87
+ "jobName": data.get("name"),
88
+ "jobStatus": data.get("status"),
89
+ "workflows": workflows,
90
+ }
91
+
92
+ def close(self) -> None:
93
+ if self._client is not None:
94
+ self._client.close()
95
+ self._client = None
96
+
97
+ def __enter__(self) -> VidpClient:
98
+ return self
99
+
100
+ def __exit__(self, *_: object) -> None:
101
+ self.close()
102
+
103
+
104
+ __all__ = ["VidpClient"]
@@ -0,0 +1,82 @@
1
+ """Strongly-typed VIDP settings using pydantic-settings.
2
+
3
+ Loads from the shared VDS env file (~/.vds/.env) with prefix `VDS_VIDP_`.
4
+ Accepts legacy `VIDP_*` env variables as aliases so existing VIDP MCP
5
+ deployments keep working without re-configuring credentials.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pydantic import AliasChoices, Field, SecretStr, ValidationError
15
+ from pydantic_settings import BaseSettings, SettingsConfigDict
16
+ from vds_cli_common.env import load_shared_env
17
+ from vds_platform_core.credentials import resolve_secret
18
+ from vds_platform_core.errors import SettingsError
19
+
20
+
21
+ class VidpSettings(BaseSettings):
22
+ """VIDP gateway settings."""
23
+
24
+ model_config = SettingsConfigDict(
25
+ env_file=None,
26
+ env_file_encoding="utf-8",
27
+ case_sensitive=True,
28
+ extra="ignore",
29
+ populate_by_name=False,
30
+ )
31
+
32
+ base_url: str = Field(
33
+ validation_alias=AliasChoices("VDS_VIDP_BASE_URL", "VIDP_BASE_URL"),
34
+ description="VIDP base URL (e.g. https://idp.example.com/)",
35
+ )
36
+ username: str = Field(
37
+ validation_alias=AliasChoices("VDS_VIDP_USERNAME", "VIDP_USERNAME"),
38
+ description="Basic-auth username",
39
+ )
40
+ password_secret: SecretStr = Field(
41
+ validation_alias=AliasChoices("VDS_VIDP_PASSWORD", "VIDP_PASSWORD"),
42
+ description="Basic-auth password",
43
+ )
44
+ timeout_ms: int = Field(
45
+ default=30_000,
46
+ ge=1,
47
+ validation_alias=AliasChoices("VDS_VIDP_TIMEOUT_MS", "VIDP_TIMEOUT_MS"),
48
+ description="HTTP request timeout in milliseconds",
49
+ )
50
+
51
+ @property
52
+ def password(self) -> str:
53
+ return resolve_secret(self.password_secret) or ""
54
+
55
+ @property
56
+ def timeout_seconds(self) -> float:
57
+ return self.timeout_ms / 1000.0
58
+
59
+ @property
60
+ def normalized_base_url(self) -> str:
61
+ return self.base_url.rstrip("/") + "/"
62
+
63
+ @property
64
+ def auth_header(self) -> dict[str, str]:
65
+ token = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
66
+ return {"Authorization": f"Basic {token}"}
67
+
68
+
69
+ def load_settings(
70
+ *,
71
+ env_path: Path | None = None,
72
+ overrides: dict[str, Any] | None = None,
73
+ ) -> VidpSettings:
74
+ """Load VIDP settings from shared env + environment variables."""
75
+ load_shared_env(path=env_path)
76
+ try:
77
+ return VidpSettings(**(overrides or {}))
78
+ except ValidationError as exc:
79
+ raise SettingsError(str(exc)) from exc
80
+
81
+
82
+ __all__ = ["VidpSettings", "load_settings", "SettingsError"]
@@ -0,0 +1,3 @@
1
+ {
2
+ "build-jenkins-by-job-name": "4afa9a1d-d36c-441a-b2fd-ba9612a854c1"
3
+ }
@@ -0,0 +1,130 @@
1
+ """Workflow alias registry — map friendly names to VIDP workflow UUIDs.
2
+
3
+ Two layers, merged at lookup time:
4
+
5
+ 1. **Package-shipped defaults** at ``<pkg>/workflows.json`` — curated by the
6
+ maintainer, distributed with the npm package, read-only at runtime.
7
+ 2. **User overrides** at ``~/.vds/vidp-workflows.json`` (or
8
+ ``$VDS_VIDP_WORKFLOWS_FILE``) — optional, user-editable, takes precedence
9
+ over package defaults. CLI ``add``/``remove`` only touch this file.
10
+
11
+ ``resolve_workflow_id()`` accepts a UUID or an alias and returns the canonical
12
+ UUID. Alias lookup is case-insensitive.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ from pathlib import Path
21
+
22
+ UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
23
+
24
+
25
+ def package_workflows_file() -> Path:
26
+ """Path to the package-shipped alias registry (read-only)."""
27
+ return Path(__file__).resolve().parent / "workflows.json"
28
+
29
+
30
+ def user_workflows_file() -> Path:
31
+ """Path to the user-override alias registry (mutable; CLI writes here)."""
32
+ override = os.environ.get("VDS_VIDP_WORKFLOWS_FILE", "").strip()
33
+ if override:
34
+ return Path(override).expanduser()
35
+ return Path.home() / ".vds" / "vidp-workflows.json"
36
+
37
+
38
+ # Backwards-compatible name used by the CLI before the split.
39
+ def workflows_file() -> Path:
40
+ """Deprecated alias for :func:`user_workflows_file`."""
41
+ return user_workflows_file()
42
+
43
+
44
+ def _read_json(path: Path) -> dict[str, str]:
45
+ if not path.exists():
46
+ return {}
47
+ try:
48
+ data = json.loads(path.read_text(encoding="utf-8"))
49
+ except json.JSONDecodeError:
50
+ return {}
51
+ if not isinstance(data, dict):
52
+ return {}
53
+ return {str(k).strip(): str(v).strip() for k, v in data.items() if isinstance(v, str)}
54
+
55
+
56
+ def load_package_aliases() -> dict[str, str]:
57
+ """Aliases shipped with the package."""
58
+ return _read_json(package_workflows_file())
59
+
60
+
61
+ def load_user_aliases() -> dict[str, str]:
62
+ """Aliases set by the user (override layer)."""
63
+ return _read_json(user_workflows_file())
64
+
65
+
66
+ def load_aliases() -> dict[str, str]:
67
+ """Merged view: package defaults overlaid with user overrides."""
68
+ merged = load_package_aliases()
69
+ merged.update(load_user_aliases())
70
+ return merged
71
+
72
+
73
+ def save_user_aliases(aliases: dict[str, str]) -> Path:
74
+ """Write the user-override registry, creating parent dir if needed."""
75
+ path = user_workflows_file()
76
+ path.parent.mkdir(parents=True, exist_ok=True)
77
+ path.write_text(json.dumps(aliases, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
78
+ return path
79
+
80
+
81
+ # Backwards-compatible name used by older callers.
82
+ def save_aliases(aliases: dict[str, str]) -> Path:
83
+ """Deprecated alias for :func:`save_user_aliases`."""
84
+ return save_user_aliases(aliases)
85
+
86
+
87
+ def is_uuid(value: str) -> bool:
88
+ return bool(UUID_RE.match(value.strip()))
89
+
90
+
91
+ def resolve_workflow_id(name_or_id: str) -> tuple[str, str | None]:
92
+ """Return ``(workflow_id, alias_used)``.
93
+
94
+ - UUID input → returned as-is with ``alias_used=None``.
95
+ - Otherwise look up case-insensitively in the merged registry.
96
+ - Raises ``KeyError`` with a helpful message if no match.
97
+ """
98
+ value = name_or_id.strip()
99
+ if is_uuid(value):
100
+ return value, None
101
+
102
+ aliases = load_aliases()
103
+ if value in aliases:
104
+ return aliases[value], value
105
+ lower_map = {k.lower(): (k, v) for k, v in aliases.items()}
106
+ if value.lower() in lower_map:
107
+ canonical, wf_id = lower_map[value.lower()]
108
+ return wf_id, canonical
109
+
110
+ raise KeyError(
111
+ f"No workflow id found for alias '{value}'. "
112
+ f"Maintainer can add it to {package_workflows_file()} "
113
+ f"(shipped with package); user can add a private alias via "
114
+ f"'vds-cli vidp workflows add {value} <uuid>' "
115
+ f"(writes to {user_workflows_file()})."
116
+ )
117
+
118
+
119
+ __all__ = [
120
+ "package_workflows_file",
121
+ "user_workflows_file",
122
+ "workflows_file", # backwards-compat
123
+ "load_package_aliases",
124
+ "load_user_aliases",
125
+ "load_aliases",
126
+ "save_user_aliases",
127
+ "save_aliases", # backwards-compat
128
+ "is_uuid",
129
+ "resolve_workflow_id",
130
+ ]