fix(cli): resolve CLI-002..007 — robust response rendering, URL/JSON arg validation, credential env-vars, doc refresh
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 12 |
|
| Open findings | 6 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ now call `ResolveFormat`. Regression tests added in `FormatResolutionTests`.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CLI/Commands/CommandHelpers.cs:59-68`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:78-80` |
|
| Location | `src/ScadaLink.CLI/Commands/CommandHelpers.cs:59-68`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:78-80` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -112,7 +112,13 @@ output" case (print nothing or `(ok)`), and return 0 before attempting to parse.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed against source —
|
||||||
|
`HandleResponse` tested `JsonData != null`, so an empty success body fell through to
|
||||||
|
`WriteAsTable` → `JsonDocument.Parse("")` and threw an uncaught `JsonException`.
|
||||||
|
`HandleResponse` now treats a null-or-whitespace `JsonData` as a "succeeded, no output"
|
||||||
|
case, prints `(ok)`, and returns 0 before any parse. Regression tests added in
|
||||||
|
`ResponseRenderingTests` (`HandleResponse_EmptyBody_TableFormat_DoesNotThrow_ReturnsZero`,
|
||||||
|
`HandleResponse_EmptyBody_JsonFormat_DoesNotThrow_ReturnsZero`).
|
||||||
|
|
||||||
### CLI-003 — Non-JSON success body crashes table rendering
|
### CLI-003 — Non-JSON success body crashes table rendering
|
||||||
|
|
||||||
@@ -120,7 +126,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CLI/Commands/CommandHelpers.cs:80` |
|
| Location | `src/ScadaLink.CLI/Commands/CommandHelpers.cs:80` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -139,7 +145,11 @@ printing the raw body verbatim (as the JSON path already does at line 66).
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed — `WriteAsTable` parsed the
|
||||||
|
body with no `try/catch`. The `JsonDocument.Parse` call is now wrapped in a
|
||||||
|
`try/catch (JsonException)` that prints the raw body verbatim on failure, mirroring the
|
||||||
|
raw-body fallback on the JSON path. Regression test
|
||||||
|
`ResponseRenderingTests.HandleResponse_NonJsonBody_TableFormat_FallsBackToRaw_ReturnsZero`.
|
||||||
|
|
||||||
### CLI-004 — Malformed `--url` throws an unhandled `UriFormatException`
|
### CLI-004 — Malformed `--url` throws an unhandled `UriFormatException`
|
||||||
|
|
||||||
@@ -147,7 +157,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CLI/ManagementHttpClient.cs:13` |
|
| Location | `src/ScadaLink.CLI/ManagementHttpClient.cs:13` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -166,7 +176,12 @@ clean `INVALID_URL` error with exit code 1 on failure.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed — the
|
||||||
|
`ManagementHttpClient` constructor's `new Uri(...)` ran outside the `SendCommandAsync`
|
||||||
|
`try/catch`. Added `CommandHelpers.IsValidManagementUrl`, which checks for an absolute
|
||||||
|
http/https URL via `Uri.TryCreate`. Both `CommandHelpers.ExecuteCommandAsync` and
|
||||||
|
`DebugCommands.BuildStream` now validate the resolved URL up front and emit a clean
|
||||||
|
`INVALID_URL` error with exit code 1. Regression tests in `UrlValidationTests`.
|
||||||
|
|
||||||
### CLI-005 — Malformed `--bindings` / `--overrides` JSON throws unhandled exceptions
|
### CLI-005 — Malformed `--bindings` / `--overrides` JSON throws unhandled exceptions
|
||||||
|
|
||||||
@@ -174,7 +189,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CLI/Commands/InstanceCommands.cs:55-58`, `src/ScadaLink.CLI/Commands/InstanceCommands.cs:181-182` |
|
| Location | `src/ScadaLink.CLI/Commands/InstanceCommands.cs:55-58`, `src/ScadaLink.CLI/Commands/InstanceCommands.cs:181-182` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -195,7 +210,15 @@ code and return 1.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed — both `set-bindings` and
|
||||||
|
`set-overrides` deserialized and indexed JSON inline with no `try/catch`. Extracted the
|
||||||
|
parsing into testable `InstanceCommands.TryParseBindings` / `TryParseOverrides` helpers
|
||||||
|
that catch `JsonException`, guard against null results, and (for bindings) validate pair
|
||||||
|
arity and element kinds (`JsonValueKind`) instead of letting `ArgumentOutOfRangeException`
|
||||||
|
/ `InvalidOperationException` escape. The command actions now emit a clean
|
||||||
|
`INVALID_ARGUMENT` error and return 1 on failure. Regression tests in
|
||||||
|
`InstanceArgumentParsingTests` (8 tests covering valid input, malformed JSON, short pairs,
|
||||||
|
wrong element types, and JSON null).
|
||||||
|
|
||||||
### CLI-006 — Password is passed as a command-line argument with no safer alternative
|
### CLI-006 — Password is passed as a command-line argument with no safer alternative
|
||||||
|
|
||||||
@@ -203,7 +226,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CLI/Program.cs:9`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:36-44` |
|
| Location | `src/ScadaLink.CLI/Program.cs:9`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:36-44` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -225,7 +248,17 @@ supplied.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed — credentials had no
|
||||||
|
non-command-line source. Added `SCADALINK_USERNAME` / `SCADALINK_PASSWORD` environment
|
||||||
|
fallbacks: `CliConfig.Load` now reads them into new `CliConfig.Username` / `Password`
|
||||||
|
properties (credentials are sourced from environment variables only, never the config
|
||||||
|
file, so they are not persisted). `CommandHelpers.ResolveCredential` resolves precedence
|
||||||
|
(explicit `--username`/`--password` → env var); both `ExecuteCommandAsync` and
|
||||||
|
`DebugCommands.BuildStream` use it. The design doc and the in-repo `README.md` now
|
||||||
|
document that `--password` on the command line is discouraged. The `--password-stdin`
|
||||||
|
option / interactive prompt was not added — the env-var fallback fully satisfies the
|
||||||
|
CI/CD safe-credential need; a stdin/prompt variant can be a follow-up if interactive use
|
||||||
|
demands it. Regression tests in `CredentialResolutionTests`.
|
||||||
|
|
||||||
### CLI-007 — `Component-CLI.md` command surface is substantially stale
|
### CLI-007 — `Component-CLI.md` command surface is substantially stale
|
||||||
|
|
||||||
@@ -233,7 +266,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `docs/requirements/Component-CLI.md:51-211` (vs. all files under `src/ScadaLink.CLI/Commands/`) |
|
| Location | `docs/requirements/Component-CLI.md:51-211` (vs. all files under `src/ScadaLink.CLI/Commands/`) |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -267,7 +300,17 @@ authoritative.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Drift confirmed against every file under
|
||||||
|
`src/ScadaLink.CLI/Commands/`. Regenerated the entire "Command Structure" section of
|
||||||
|
`Component-CLI.md` from the actual command tree: all entities are now keyed by integer
|
||||||
|
`--id`; the non-existent `--file` option is removed; create/update commands list their
|
||||||
|
real individual flags; non-existent commands (`template diff`, `instance
|
||||||
|
bind-connections`/`assign-area`, `data-connection assign/unassign`, `security api-key
|
||||||
|
enable/disable`) are removed; previously-omitted commands (`instance alarm-override
|
||||||
|
set/delete/list`, `external-system method` subgroup, `site deploy-artifacts`) are added.
|
||||||
|
A note now points to `src/ScadaLink.CLI/README.md` as the authoritative reference. The
|
||||||
|
Configuration section also documents the new `SCADALINK_USERNAME`/`SCADALINK_PASSWORD`
|
||||||
|
env vars (see CLI-006).
|
||||||
|
|
||||||
### CLI-008 — `--format` value is not validated
|
### CLI-008 — `--format` value is not validated
|
||||||
|
|
||||||
|
|||||||
@@ -48,127 +48,142 @@ The CLI uses a hierarchical subcommand structure mirroring the Management Servic
|
|||||||
scadalink <group> <action> [options]
|
scadalink <group> <action> [options]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All entities are identified by their integer **ID** (via `--id`, `--template-id`,
|
||||||
|
`--site-id`, etc.), not by name. Create/update commands take individual flags — there
|
||||||
|
is no `--file` option. The authoritative, always-current reference is the in-repo
|
||||||
|
`src/ScadaLink.CLI/README.md`; the command lists below mirror the implemented command
|
||||||
|
tree at the time of writing.
|
||||||
|
|
||||||
### Template Commands
|
### Template Commands
|
||||||
```
|
```
|
||||||
scadalink template list [--format json|table]
|
scadalink template list
|
||||||
scadalink template get <name> [--format json|table]
|
scadalink template get --id <id>
|
||||||
scadalink template create --name <name> [--parent <parent>] --file <path>
|
scadalink template create --name <name> [--description <desc>] [--parent-id <id>]
|
||||||
scadalink template update <name> --file <path>
|
scadalink template update --id <id> [--name <name>] [--description <desc>] [--parent-id <id>]
|
||||||
scadalink template delete <name>
|
scadalink template validate --id <id>
|
||||||
scadalink template validate <name>
|
scadalink template delete --id <id>
|
||||||
scadalink template diff <instance-code>
|
scadalink template attribute add --template-id <id> --name <name> --data-type <type> [--value <value>] [--description <desc>] [--data-source <ref>] [--locked <bool>]
|
||||||
scadalink template attribute add --template-id <id> --name <name> --data-type <type> [--default-value <value>] [--tag-path <path>]
|
scadalink template attribute update --id <id> [--name <name>] [--data-type <type>] [--value <value>] [--description <desc>] [--data-source <ref>] [--locked <bool>]
|
||||||
scadalink template attribute update --template-id <id> --name <name> [--data-type <type>] [--default-value <value>] [--tag-path <path>]
|
scadalink template attribute delete --id <id>
|
||||||
scadalink template attribute delete --template-id <id> --name <name>
|
scadalink template alarm add --template-id <id> --name <name> --trigger-type <type> --priority <n> [--description <desc>] [--trigger-config <json>] [--locked <bool>]
|
||||||
scadalink template alarm add --template-id <id> --name <name> --trigger-attribute <attr> --condition <cond> --setpoint <value> [--severity <level>] [--notification-list <name>]
|
scadalink template alarm update --id <id> [--name <name>] [--trigger-type <type>] [--priority <n>] [--description <desc>] [--trigger-config <json>] [--locked <bool>]
|
||||||
scadalink template alarm update --template-id <id> --name <name> [--condition <cond>] [--setpoint <value>] [--severity <level>] [--notification-list <name>]
|
scadalink template alarm delete --id <id>
|
||||||
scadalink template alarm delete --template-id <id> --name <name>
|
scadalink template script add --template-id <id> --name <name> --code <code> --trigger-type <type> [--trigger-config <json>] [--locked <bool>] [--parameters <json>] [--return-def <json>]
|
||||||
scadalink template script add --template-id <id> --name <name> --trigger-type <type> [--trigger-attribute <attr>] [--interval <ms>] --code <code>
|
scadalink template script update --id <id> [--name <name>] [--code <code>] [--trigger-type <type>] [--trigger-config <json>] [--locked <bool>] [--parameters <json>] [--return-def <json>]
|
||||||
scadalink template script update --template-id <id> --name <name> [--trigger-type <type>] [--trigger-attribute <attr>] [--interval <ms>] [--code <code>]
|
scadalink template script delete --id <id>
|
||||||
scadalink template script delete --template-id <id> --name <name>
|
scadalink template composition add --template-id <id> --instance-name <name> --composed-template-id <id>
|
||||||
scadalink template composition add --template-id <id> --module-template-id <id> --instance-name <name>
|
|
||||||
scadalink template composition delete --template-id <id> --instance-name <name>
|
scadalink template composition delete --template-id <id> --instance-name <name>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Instance Commands
|
### Instance Commands
|
||||||
```
|
```
|
||||||
scadalink instance list [--site <site>] [--area <area>] [--format json|table]
|
scadalink instance list [--site-id <id>] [--template-id <id>] [--search <term>]
|
||||||
scadalink instance get <code> [--format json|table]
|
scadalink instance get --id <id>
|
||||||
scadalink instance create --template <name> --site <site> --code <code> [--area <area>]
|
scadalink instance create --name <name> --template-id <id> --site-id <id> [--area-id <id>]
|
||||||
scadalink instance set-overrides <code> --file <path>
|
scadalink instance set-bindings --id <id> --bindings <json>
|
||||||
scadalink instance set-bindings <code> --bindings <json>
|
scadalink instance set-overrides --id <id> --overrides <json>
|
||||||
scadalink instance bind-connections <code> --file <path>
|
scadalink instance alarm-override set --instance-id <id> --alarm <name> [--trigger-config <json>] [--priority <n>]
|
||||||
scadalink instance assign-area <code> --area <area>
|
scadalink instance alarm-override delete --instance-id <id> --alarm <name>
|
||||||
scadalink instance enable <code>
|
scadalink instance alarm-override list --instance-id <id>
|
||||||
scadalink instance disable <code>
|
scadalink instance set-area --id <id> [--area-id <id>]
|
||||||
scadalink instance delete <code>
|
scadalink instance diff --id <id>
|
||||||
|
scadalink instance deploy --id <id>
|
||||||
|
scadalink instance enable --id <id>
|
||||||
|
scadalink instance disable --id <id>
|
||||||
|
scadalink instance delete --id <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--bindings` is a JSON array of `[attributeName, dataConnectionId]` pairs, e.g.
|
||||||
|
`[["Speed", 5], ["Mode", 7]]`. `--overrides` is a JSON object of attribute name to
|
||||||
|
value, e.g. `{"Speed": "100", "Mode": null}`.
|
||||||
|
|
||||||
### Site Commands
|
### Site Commands
|
||||||
```
|
```
|
||||||
scadalink site list [--format json|table]
|
scadalink site list
|
||||||
scadalink site get <site-id> [--format json|table]
|
scadalink site get --id <id>
|
||||||
scadalink site create --name <name> --id <site-id> [--node-a-address <addr>] [--node-b-address <addr>] [--grpc-node-a-address <addr>] [--grpc-node-b-address <addr>]
|
scadalink site create --identifier <id> --name <name> [--description <desc>] [--node-a-address <addr>] [--node-b-address <addr>] [--grpc-node-a-address <addr>] [--grpc-node-b-address <addr>]
|
||||||
scadalink site update <site-id> --file <path>
|
scadalink site update --id <id> [--name <name>] [--description <desc>] [--node-a-address <addr>] [--node-b-address <addr>] [--grpc-node-a-address <addr>] [--grpc-node-b-address <addr>]
|
||||||
scadalink site delete <site-id>
|
scadalink site delete --id <id>
|
||||||
scadalink site area list <site-id>
|
scadalink site area list --site-id <id>
|
||||||
scadalink site area create <site-id> --name <name> [--parent <parent-area>]
|
scadalink site area create --site-id <id> --name <name> [--parent-id <id>]
|
||||||
scadalink site area update <site-id> --name <name> [--new-name <name>] [--parent <parent-area>]
|
scadalink site area update --id <id> --name <name>
|
||||||
scadalink site area delete <site-id> --name <name>
|
scadalink site area delete --id <id>
|
||||||
|
scadalink site deploy-artifacts [--site-id <id>]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deployment Commands
|
### Deployment Commands
|
||||||
```
|
```
|
||||||
scadalink deploy instance <code>
|
scadalink deploy instance --id <id>
|
||||||
scadalink deploy artifacts [--site <site>] [--type <artifact-type>]
|
scadalink deploy artifacts [--site-id <id>]
|
||||||
scadalink deploy status [--format json|table]
|
scadalink deploy status [--instance-id <id>] [--status <status>] [--page <n>] [--page-size <n>]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Connection Commands
|
### Data Connection Commands
|
||||||
```
|
```
|
||||||
scadalink data-connection list [--format json|table]
|
scadalink data-connection list [--site-id <id>]
|
||||||
scadalink data-connection get <name> [--format json|table]
|
scadalink data-connection get --id <id>
|
||||||
scadalink data-connection create --file <path>
|
scadalink data-connection create --site-id <id> --name <name> --protocol <protocol> [--backup-config <json>] [--failover-retry-count <n>]
|
||||||
scadalink data-connection update <name> --file <path>
|
scadalink data-connection update --id <id> [--name <name>] [--protocol <protocol>] [--backup-config <json>] [--failover-retry-count <n>]
|
||||||
scadalink data-connection delete <name>
|
scadalink data-connection delete --id <id>
|
||||||
scadalink data-connection assign <name> --site <site-id>
|
|
||||||
scadalink data-connection unassign <name> --site <site-id>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### External System Commands
|
### External System Commands
|
||||||
```
|
```
|
||||||
scadalink external-system list [--format json|table]
|
scadalink external-system list
|
||||||
scadalink external-system get <name> [--format json|table]
|
scadalink external-system get --id <id>
|
||||||
scadalink external-system create --file <path>
|
scadalink external-system create --name <name> --endpoint-url <url> --auth-type <type> [--auth-config <json>]
|
||||||
scadalink external-system update <name> --file <path>
|
scadalink external-system update --id <id> [--name <name>] [--endpoint-url <url>] [--auth-type <type>] [--auth-config <json>]
|
||||||
scadalink external-system delete <name>
|
scadalink external-system delete --id <id>
|
||||||
|
scadalink external-system method list --external-system-id <id>
|
||||||
|
scadalink external-system method get --id <id>
|
||||||
|
scadalink external-system method create --external-system-id <id> --name <name> --http-method <verb> --path <path> [--params <json>] [--return <json>]
|
||||||
|
scadalink external-system method update --id <id> [--name <name>] [--http-method <verb>] [--path <path>] [--params <json>] [--return <json>]
|
||||||
|
scadalink external-system method delete --id <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Notification Commands
|
### Notification Commands
|
||||||
```
|
```
|
||||||
scadalink notification list [--format json|table]
|
scadalink notification list
|
||||||
scadalink notification get <name> [--format json|table]
|
scadalink notification get --id <id>
|
||||||
scadalink notification create --file <path>
|
scadalink notification create --name <name> --emails <comma-separated>
|
||||||
scadalink notification update <name> --file <path>
|
scadalink notification update --id <id> [--name <name>] [--emails <comma-separated>]
|
||||||
scadalink notification delete <name>
|
scadalink notification delete --id <id>
|
||||||
scadalink notification smtp list [--format json|table]
|
scadalink notification smtp list
|
||||||
scadalink notification smtp update --file <path>
|
scadalink notification smtp update --id <id> --server <host> --port <n> --auth-mode <mode> --from-address <email>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Security Commands
|
### Security Commands
|
||||||
```
|
```
|
||||||
scadalink security api-key list [--format json|table]
|
scadalink security api-key list
|
||||||
scadalink security api-key create --name <name>
|
scadalink security api-key create --name <name>
|
||||||
scadalink security api-key update <name> [--name <new-name>] [--enabled <bool>]
|
scadalink security api-key update --id <id> --enabled <bool>
|
||||||
scadalink security api-key enable <name>
|
scadalink security api-key delete --id <id>
|
||||||
scadalink security api-key disable <name>
|
scadalink security role-mapping list
|
||||||
scadalink security api-key delete <name>
|
scadalink security role-mapping create --ldap-group <group> --role <role>
|
||||||
scadalink security role-mapping list [--format json|table]
|
scadalink security role-mapping update --id <id> [--ldap-group <group>] [--role <role>]
|
||||||
scadalink security role-mapping create --group <ldap-group> --role <role> [--site <site>]
|
scadalink security role-mapping delete --id <id>
|
||||||
scadalink security role-mapping update --id <id> [--group <ldap-group>] [--role <role>]
|
scadalink security scope-rule list [--mapping-id <id>]
|
||||||
scadalink security role-mapping delete --group <ldap-group> --role <role>
|
scadalink security scope-rule add --mapping-id <id> --site-id <id>
|
||||||
scadalink security scope-rule list [--role-mapping-id <id>] [--format json|table]
|
|
||||||
scadalink security scope-rule add --role-mapping-id <id> --site-id <site-id>
|
|
||||||
scadalink security scope-rule delete --id <id>
|
scadalink security scope-rule delete --id <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Audit Log Commands
|
### Audit Log Commands
|
||||||
```
|
```
|
||||||
scadalink audit-log query [--user <username>] [--entity-type <type>] [--from <date>] [--to <date>] [--format json|table]
|
scadalink audit-log query [--user <username>] [--entity-type <type>] [--action <action>] [--from <date>] [--to <date>] [--page <n>] [--page-size <n>]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Health Commands
|
### Health Commands
|
||||||
```
|
```
|
||||||
scadalink health summary [--format json|table]
|
scadalink health summary
|
||||||
scadalink health site <site-id> [--format json|table]
|
scadalink health site --identifier <site-identifier>
|
||||||
scadalink health event-log --site-identifier <site-id> [--from <date>] [--to <date>] [--search <term>] [--page <n>] [--page-size <n>] [--format json|table]
|
scadalink health event-log --site <site-identifier> [--event-type <type>] [--severity <level>] [--keyword <term>] [--from <date>] [--to <date>] [--page <n>] [--page-size <n>] [--instance-name <name>]
|
||||||
scadalink health parked-messages --site-identifier <site-id> [--page <n>] [--page-size <n>] [--format json|table]
|
scadalink health parked-messages --site <site-identifier> [--page <n>] [--page-size <n>]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debug Commands
|
### Debug Commands
|
||||||
```
|
```
|
||||||
scadalink debug snapshot --id <id> [--format json|table]
|
scadalink debug snapshot --id <id>
|
||||||
scadalink debug stream --id <instanceId> [--url ...] [--username ...] [--password ...]
|
scadalink debug stream --id <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
The `debug snapshot` command retrieves a point-in-time snapshot via the HTTP Management API.
|
The `debug snapshot` command retrieves a point-in-time snapshot via the HTTP Management API.
|
||||||
@@ -185,31 +200,33 @@ Unlike `debug snapshot` (which uses the HTTP Management API), `debug stream` use
|
|||||||
|
|
||||||
### Shared Script Commands
|
### Shared Script Commands
|
||||||
```
|
```
|
||||||
scadalink shared-script list [--format json|table]
|
scadalink shared-script list
|
||||||
scadalink shared-script get --id <id> [--format json|table]
|
scadalink shared-script get --id <id>
|
||||||
scadalink shared-script create --name <name> --code <code>
|
scadalink shared-script create --name <name> --code <code> [--parameters <json>] [--return-def <json>]
|
||||||
scadalink shared-script update --id <id> [--name <name>] [--code <code>]
|
scadalink shared-script update --id <id> [--name <name>] [--code <code>] [--parameters <json>] [--return-def <json>]
|
||||||
scadalink shared-script delete --id <id>
|
scadalink shared-script delete --id <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Connection Commands
|
### Database Connection Commands
|
||||||
```
|
```
|
||||||
scadalink db-connection list [--format json|table]
|
scadalink db-connection list
|
||||||
scadalink db-connection get --id <id> [--format json|table]
|
scadalink db-connection get --id <id>
|
||||||
scadalink db-connection create --name <name> --connection-string <string> [--provider <provider>]
|
scadalink db-connection create --name <name> --connection-string <string>
|
||||||
scadalink db-connection update --id <id> [--name <name>] [--connection-string <string>] [--provider <provider>]
|
scadalink db-connection update --id <id> [--name <name>] [--connection-string <string>]
|
||||||
scadalink db-connection delete --id <id>
|
scadalink db-connection delete --id <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Inbound API Method Commands
|
### Inbound API Method Commands
|
||||||
```
|
```
|
||||||
scadalink api-method list [--format json|table]
|
scadalink api-method list
|
||||||
scadalink api-method get --id <id> [--format json|table]
|
scadalink api-method get --id <id>
|
||||||
scadalink api-method create --name <name> --code <code> [--description <desc>]
|
scadalink api-method create --name <name> --script <code> [--timeout <seconds>] [--parameters <json>] [--return-def <json>]
|
||||||
scadalink api-method update --id <id> [--name <name>] [--code <code>] [--description <desc>]
|
scadalink api-method update --id <id> [--script <code>] [--timeout <seconds>] [--parameters <json>] [--return-def <json>]
|
||||||
scadalink api-method delete --id <id>
|
scadalink api-method delete --id <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `--format json|table` option is recursive and accepted on every command above.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configuration is resolved in the following priority order (highest wins):
|
Configuration is resolved in the following priority order (highest wins):
|
||||||
@@ -218,7 +235,10 @@ Configuration is resolved in the following priority order (highest wins):
|
|||||||
2. **Environment variables**:
|
2. **Environment variables**:
|
||||||
- `SCADALINK_MANAGEMENT_URL` — Management API URL (e.g., `http://central-host:5000`).
|
- `SCADALINK_MANAGEMENT_URL` — Management API URL (e.g., `http://central-host:5000`).
|
||||||
- `SCADALINK_FORMAT` — Default output format (`json` or `table`).
|
- `SCADALINK_FORMAT` — Default output format (`json` or `table`).
|
||||||
3. **Configuration file**: `~/.scadalink/config.json` — Persistent defaults for management URL and output format.
|
- `SCADALINK_USERNAME` / `SCADALINK_PASSWORD` — LDAP credentials. Preferred over
|
||||||
|
`--password` on the command line, which is visible in process listings and shell
|
||||||
|
history. Credentials are never read from the config file.
|
||||||
|
3. **Configuration file**: `~/.scadalink/config.json` — Persistent defaults for management URL and output format only (never credentials).
|
||||||
|
|
||||||
### Configuration File Format
|
### Configuration File Format
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ public class CliConfig
|
|||||||
public string? ManagementUrl { get; set; }
|
public string? ManagementUrl { get; set; }
|
||||||
public string DefaultFormat { get; set; } = "json";
|
public string DefaultFormat { get; set; } = "json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LDAP username from the <c>SCADALINK_USERNAME</c> environment variable, if set.
|
||||||
|
/// Credentials are intentionally only sourced from environment variables (or the
|
||||||
|
/// command line) — never from the config file — so they are not persisted to disk.
|
||||||
|
/// </summary>
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LDAP password from the <c>SCADALINK_PASSWORD</c> environment variable, if set.
|
||||||
|
/// Provides a safer alternative to <c>--password</c>, which leaks into process
|
||||||
|
/// listings and shell history.
|
||||||
|
/// </summary>
|
||||||
|
public string? Password { get; set; }
|
||||||
|
|
||||||
public static CliConfig Load()
|
public static CliConfig Load()
|
||||||
{
|
{
|
||||||
var config = new CliConfig();
|
var config = new CliConfig();
|
||||||
@@ -38,6 +52,15 @@ public class CliConfig
|
|||||||
if (!string.IsNullOrEmpty(envFormat))
|
if (!string.IsNullOrEmpty(envFormat))
|
||||||
config.DefaultFormat = envFormat;
|
config.DefaultFormat = envFormat;
|
||||||
|
|
||||||
|
// Credentials from environment variables only (never the config file).
|
||||||
|
var envUsername = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
|
||||||
|
if (!string.IsNullOrEmpty(envUsername))
|
||||||
|
config.Username = envUsername;
|
||||||
|
|
||||||
|
var envPassword = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
|
||||||
|
if (!string.IsNullOrEmpty(envPassword))
|
||||||
|
config.Password = envPassword;
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,14 +31,23 @@ internal static class CommandHelpers
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate credentials
|
if (!IsValidManagementUrl(url))
|
||||||
var username = result.GetValue(usernameOption);
|
{
|
||||||
var password = result.GetValue(passwordOption);
|
OutputFormatter.WriteError(
|
||||||
|
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
|
||||||
|
"INVALID_URL");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve credentials: command-line options take precedence, then the
|
||||||
|
// SCADALINK_USERNAME / SCADALINK_PASSWORD environment variables.
|
||||||
|
var username = ResolveCredential(result.GetValue(usernameOption), config.Username);
|
||||||
|
var password = ResolveCredential(result.GetValue(passwordOption), config.Password);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
{
|
{
|
||||||
OutputFormatter.WriteError(
|
OutputFormatter.WriteError(
|
||||||
"Credentials required. Use --username and --password options.",
|
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
|
||||||
"NO_CREDENTIALS");
|
"NO_CREDENTIALS");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -74,10 +83,40 @@ internal static class CommandHelpers
|
|||||||
return string.IsNullOrWhiteSpace(config.DefaultFormat) ? "json" : config.DefaultFormat;
|
return string.IsNullOrWhiteSpace(config.DefaultFormat) ? "json" : config.DefaultFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a single credential: an explicit command-line value wins, otherwise the
|
||||||
|
/// environment-variable fallback (from <see cref="CliConfig"/>) is used.
|
||||||
|
/// </summary>
|
||||||
|
internal static string? ResolveCredential(string? commandLineValue, string? envValue)
|
||||||
|
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that a management URL is an absolute http/https URL. A malformed URL
|
||||||
|
/// (missing scheme, empty, or a non-http scheme) would otherwise reach
|
||||||
|
/// <c>new Uri(...)</c> in the <see cref="ManagementHttpClient"/> constructor and throw
|
||||||
|
/// an unhandled <see cref="UriFormatException"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsValidManagementUrl(string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
||||||
|
}
|
||||||
|
|
||||||
internal static int HandleResponse(ManagementResponse response, string format)
|
internal static int HandleResponse(ManagementResponse response, string format)
|
||||||
{
|
{
|
||||||
if (response.JsonData != null)
|
if (response.JsonData != null)
|
||||||
{
|
{
|
||||||
|
// A success status with an empty/whitespace body (e.g. a 204 from a delete)
|
||||||
|
// is a "command succeeded, no output" case — do not attempt to parse it.
|
||||||
|
if (string.IsNullOrWhiteSpace(response.JsonData))
|
||||||
|
{
|
||||||
|
Console.WriteLine("(ok)");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
WriteAsTable(response.JsonData);
|
WriteAsTable(response.JsonData);
|
||||||
@@ -98,46 +137,62 @@ internal static class CommandHelpers
|
|||||||
|
|
||||||
private static void WriteAsTable(string json)
|
private static void WriteAsTable(string json)
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(json);
|
JsonDocument doc;
|
||||||
var root = doc.RootElement;
|
try
|
||||||
|
|
||||||
if (root.ValueKind == JsonValueKind.Array)
|
|
||||||
{
|
{
|
||||||
var items = root.EnumerateArray().ToList();
|
doc = JsonDocument.Parse(json);
|
||||||
if (items.Count == 0)
|
}
|
||||||
{
|
catch (JsonException)
|
||||||
Console.WriteLine("(no results)");
|
{
|
||||||
return;
|
// The server returned a success status but a non-JSON body (e.g. a proxy
|
||||||
}
|
// HTML error page, or a plain-text message). Print it verbatim rather than
|
||||||
|
// crashing — mirrors the raw-body fallback on the JSON path.
|
||||||
|
Console.WriteLine(json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var headers = items[0].ValueKind == JsonValueKind.Object
|
using (doc)
|
||||||
? items[0].EnumerateObject().Select(p => p.Name).ToArray()
|
{
|
||||||
: new[] { "Value" };
|
var root = doc.RootElement;
|
||||||
|
|
||||||
var rows = items.Select(item =>
|
if (root.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
if (item.ValueKind == JsonValueKind.Object)
|
var items = root.EnumerateArray().ToList();
|
||||||
|
if (items.Count == 0)
|
||||||
{
|
{
|
||||||
return headers.Select(h =>
|
Console.WriteLine("(no results)");
|
||||||
item.TryGetProperty(h, out var val)
|
return;
|
||||||
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
|
|
||||||
: "").ToArray();
|
|
||||||
}
|
}
|
||||||
return new[] { item.ToString() };
|
|
||||||
});
|
|
||||||
|
|
||||||
OutputFormatter.WriteTable(rows, headers);
|
var headers = items[0].ValueKind == JsonValueKind.Object
|
||||||
}
|
? items[0].EnumerateObject().Select(p => p.Name).ToArray()
|
||||||
else if (root.ValueKind == JsonValueKind.Object)
|
: new[] { "Value" };
|
||||||
{
|
|
||||||
var headers = new[] { "Property", "Value" };
|
var rows = items.Select(item =>
|
||||||
var rows = root.EnumerateObject().Select(p =>
|
{
|
||||||
new[] { p.Name, p.Value.ValueKind == JsonValueKind.Null ? "" : p.Value.ToString() });
|
if (item.ValueKind == JsonValueKind.Object)
|
||||||
OutputFormatter.WriteTable(rows, headers);
|
{
|
||||||
}
|
return headers.Select(h =>
|
||||||
else
|
item.TryGetProperty(h, out var val)
|
||||||
{
|
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
|
||||||
Console.WriteLine(root.ToString());
|
: "").ToArray();
|
||||||
|
}
|
||||||
|
return new[] { item.ToString() };
|
||||||
|
});
|
||||||
|
|
||||||
|
OutputFormatter.WriteTable(rows, headers);
|
||||||
|
}
|
||||||
|
else if (root.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
var headers = new[] { "Property", "Value" };
|
||||||
|
var rows = root.EnumerateObject().Select(p =>
|
||||||
|
new[] { p.Name, p.Value.ValueKind == JsonValueKind.Null ? "" : p.Value.ToString() });
|
||||||
|
OutputFormatter.WriteTable(rows, headers);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine(root.ToString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,13 +57,21 @@ public static class DebugCommands
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
var username = result.GetValue(usernameOption);
|
if (!CommandHelpers.IsValidManagementUrl(url))
|
||||||
var password = result.GetValue(passwordOption);
|
{
|
||||||
|
OutputFormatter.WriteError(
|
||||||
|
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
|
||||||
|
"INVALID_URL");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
|
||||||
|
var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
{
|
{
|
||||||
OutputFormatter.WriteError(
|
OutputFormatter.WriteError(
|
||||||
"Credentials required. Use --username and --password options.",
|
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
|
||||||
"NO_CREDENTIALS");
|
"NO_CREDENTIALS");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,17 +52,106 @@ public static class InstanceCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
var bindingsJson = result.GetValue(bindingsOption)!;
|
var bindingsJson = result.GetValue(bindingsOption)!;
|
||||||
var pairs = System.Text.Json.JsonSerializer.Deserialize<List<List<System.Text.Json.JsonElement>>>(bindingsJson)
|
if (!TryParseBindings(bindingsJson, out var bindings, out var error))
|
||||||
?? throw new InvalidOperationException("Invalid bindings JSON");
|
{
|
||||||
var bindings = pairs.Select(p =>
|
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
|
||||||
(p[0].GetString()!, p[1].GetInt32())).ToList();
|
return 1;
|
||||||
|
}
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
new SetConnectionBindingsCommand(id, bindings));
|
new SetConnectionBindingsCommand(id, bindings!));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the <c>--bindings</c> argument — a JSON array of
|
||||||
|
/// <c>[attributeName, dataConnectionId]</c> pairs — into a typed list.
|
||||||
|
/// Returns <c>false</c> with a descriptive <paramref name="error"/> instead of
|
||||||
|
/// throwing when the JSON is malformed, a pair has the wrong arity, or an element
|
||||||
|
/// has the wrong type.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool TryParseBindings(
|
||||||
|
string json,
|
||||||
|
out List<(string, int)>? bindings,
|
||||||
|
out string? error)
|
||||||
|
{
|
||||||
|
bindings = null;
|
||||||
|
error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pairs = System.Text.Json.JsonSerializer
|
||||||
|
.Deserialize<List<List<System.Text.Json.JsonElement>>>(json);
|
||||||
|
if (pairs == null)
|
||||||
|
{
|
||||||
|
error = "Bindings JSON must be a non-null array of [attributeName, dataConnectionId] pairs.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<(string, int)>(pairs.Count);
|
||||||
|
foreach (var pair in pairs)
|
||||||
|
{
|
||||||
|
if (pair.Count != 2)
|
||||||
|
{
|
||||||
|
error = "Each binding must be a [attributeName, dataConnectionId] pair of exactly two elements.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (pair[0].ValueKind != System.Text.Json.JsonValueKind.String)
|
||||||
|
{
|
||||||
|
error = "The first element of each binding (attributeName) must be a string.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (pair[1].ValueKind != System.Text.Json.JsonValueKind.Number
|
||||||
|
|| !pair[1].TryGetInt32(out var connectionId))
|
||||||
|
{
|
||||||
|
error = "The second element of each binding (dataConnectionId) must be an integer.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
result.Add((pair[0].GetString()!, connectionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings = result;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException ex)
|
||||||
|
{
|
||||||
|
error = $"Invalid bindings JSON: {ex.Message}";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the <c>--overrides</c> argument — a JSON object of
|
||||||
|
/// <c>attributeName -> value</c> pairs — into a typed dictionary. Returns
|
||||||
|
/// <c>false</c> with a descriptive <paramref name="error"/> instead of throwing
|
||||||
|
/// when the JSON is malformed or null.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool TryParseOverrides(
|
||||||
|
string json,
|
||||||
|
out Dictionary<string, string?>? overrides,
|
||||||
|
out string? error)
|
||||||
|
{
|
||||||
|
overrides = null;
|
||||||
|
error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = System.Text.Json.JsonSerializer
|
||||||
|
.Deserialize<Dictionary<string, string?>>(json);
|
||||||
|
if (parsed == null)
|
||||||
|
{
|
||||||
|
error = "Overrides JSON must be a non-null object of attribute name -> value pairs.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
overrides = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException ex)
|
||||||
|
{
|
||||||
|
error = $"Invalid overrides JSON: {ex.Message}";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
{
|
{
|
||||||
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
|
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
|
||||||
@@ -178,11 +267,14 @@ public static class InstanceCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
var overridesJson = result.GetValue(overridesOption)!;
|
var overridesJson = result.GetValue(overridesOption)!;
|
||||||
var overrides = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string?>>(overridesJson)
|
if (!TryParseOverrides(overridesJson, out var overrides, out var error))
|
||||||
?? throw new InvalidOperationException("Invalid overrides JSON");
|
{
|
||||||
|
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
new SetInstanceOverridesCommand(id, overrides));
|
new SetInstanceOverridesCommand(id, overrides!));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ For the Docker test environment, see `docker/README.md` for a ready-to-use confi
|
|||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `SCADALINK_MANAGEMENT_URL` | Management API URL (overrides config file) |
|
| `SCADALINK_MANAGEMENT_URL` | Management API URL (overrides config file) |
|
||||||
| `SCADALINK_FORMAT` | Default output format (overrides config file) |
|
| `SCADALINK_FORMAT` | Default output format (overrides config file) |
|
||||||
|
| `SCADALINK_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
|
||||||
|
| `SCADALINK_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using ScadaLink.CLI;
|
|||||||
|
|
||||||
namespace ScadaLink.CLI.Tests;
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
[Collection("Environment")]
|
||||||
public class CliConfigTests
|
public class CliConfigTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
namespace ScadaLink.CLI.Tests;
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
[Collection("Console")]
|
||||||
public class CommandHelpersTests
|
public class CommandHelpersTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
71
tests/ScadaLink.CLI.Tests/CredentialResolutionTests.cs
Normal file
71
tests/ScadaLink.CLI.Tests/CredentialResolutionTests.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using ScadaLink.CLI;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CLI-006 — credentials could only be supplied via the
|
||||||
|
/// <c>--password</c> command-line option, which leaks into process listings and
|
||||||
|
/// shell history. A <c>SCADALINK_PASSWORD</c> / <c>SCADALINK_USERNAME</c> environment
|
||||||
|
/// fallback gives CI/CD a safer alternative.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Environment")]
|
||||||
|
public class CredentialResolutionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Load_Password_FromEnvironment()
|
||||||
|
{
|
||||||
|
var orig = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", "s3cret");
|
||||||
|
|
||||||
|
var config = CliConfig.Load();
|
||||||
|
|
||||||
|
Assert.Equal("s3cret", config.Password);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", orig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_Username_FromEnvironment()
|
||||||
|
{
|
||||||
|
var orig = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", "ci-user");
|
||||||
|
|
||||||
|
var config = CliConfig.Load();
|
||||||
|
|
||||||
|
Assert.Equal("ci-user", config.Username);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", orig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_NoCredentialEnvVars_LeavesCredentialsNull()
|
||||||
|
{
|
||||||
|
var origUser = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
|
||||||
|
var origPass = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", null);
|
||||||
|
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", null);
|
||||||
|
|
||||||
|
var config = CliConfig.Load();
|
||||||
|
|
||||||
|
Assert.Null(config.Username);
|
||||||
|
Assert.Null(config.Password);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", origUser);
|
||||||
|
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", origPass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
tests/ScadaLink.CLI.Tests/InstanceArgumentParsingTests.cs
Normal file
98
tests/ScadaLink.CLI.Tests/InstanceArgumentParsingTests.cs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
using ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CLI-005 — malformed <c>--bindings</c> / <c>--overrides</c> JSON
|
||||||
|
/// previously threw unhandled exceptions instead of producing a clean validation error.
|
||||||
|
/// </summary>
|
||||||
|
public class InstanceArgumentParsingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParseBindings_ValidJson_ReturnsPairs()
|
||||||
|
{
|
||||||
|
var ok = InstanceCommands.TryParseBindings(
|
||||||
|
"[[\"Speed\", 5], [\"Mode\", 7]]", out var bindings, out var error);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Null(error);
|
||||||
|
Assert.Equal(2, bindings!.Count);
|
||||||
|
Assert.Equal(("Speed", 5), bindings[0]);
|
||||||
|
Assert.Equal(("Mode", 7), bindings[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseBindings_MalformedJson_ReturnsErrorNotException()
|
||||||
|
{
|
||||||
|
var ok = InstanceCommands.TryParseBindings("not json", out var bindings, out var error);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.Null(bindings);
|
||||||
|
Assert.NotNull(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseBindings_ShortPair_ReturnsErrorNotException()
|
||||||
|
{
|
||||||
|
// A pair with fewer than two elements previously threw ArgumentOutOfRangeException.
|
||||||
|
var ok = InstanceCommands.TryParseBindings("[[\"Speed\"]]", out var bindings, out var error);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.Null(bindings);
|
||||||
|
Assert.NotNull(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseBindings_WrongElementTypes_ReturnsErrorNotException()
|
||||||
|
{
|
||||||
|
// A non-string name / non-int id previously threw InvalidOperationException.
|
||||||
|
var ok = InstanceCommands.TryParseBindings("[[5, \"Speed\"]]", out var bindings, out var error);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.Null(bindings);
|
||||||
|
Assert.NotNull(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseBindings_JsonNull_ReturnsErrorNotException()
|
||||||
|
{
|
||||||
|
var ok = InstanceCommands.TryParseBindings("null", out var bindings, out var error);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.Null(bindings);
|
||||||
|
Assert.NotNull(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseOverrides_ValidJson_ReturnsDictionary()
|
||||||
|
{
|
||||||
|
var ok = InstanceCommands.TryParseOverrides(
|
||||||
|
"{\"Speed\": \"100\", \"Mode\": null}", out var overrides, out var error);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Null(error);
|
||||||
|
Assert.Equal(2, overrides!.Count);
|
||||||
|
Assert.Equal("100", overrides["Speed"]);
|
||||||
|
Assert.Null(overrides["Mode"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseOverrides_MalformedJson_ReturnsErrorNotException()
|
||||||
|
{
|
||||||
|
var ok = InstanceCommands.TryParseOverrides("{bad json", out var overrides, out var error);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.Null(overrides);
|
||||||
|
Assert.NotNull(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseOverrides_JsonNull_ReturnsErrorNotException()
|
||||||
|
{
|
||||||
|
var ok = InstanceCommands.TryParseOverrides("null", out var overrides, out var error);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.Null(overrides);
|
||||||
|
Assert.NotNull(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using ScadaLink.CLI;
|
|||||||
|
|
||||||
namespace ScadaLink.CLI.Tests;
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
[Collection("Console")]
|
||||||
public class OutputFormatterTests
|
public class OutputFormatterTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
73
tests/ScadaLink.CLI.Tests/ResponseRenderingTests.cs
Normal file
73
tests/ScadaLink.CLI.Tests/ResponseRenderingTests.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using ScadaLink.CLI;
|
||||||
|
using ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CLI-002 (empty success body) and CLI-003 (non-JSON success
|
||||||
|
/// body) — both previously crashed table rendering with an unhandled exception.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Console")]
|
||||||
|
public class ResponseRenderingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HandleResponse_EmptyBody_TableFormat_DoesNotThrow_ReturnsZero()
|
||||||
|
{
|
||||||
|
// CLI-002: a 200/204 with an empty body must be treated as "succeeded, no output".
|
||||||
|
var writer = new StringWriter();
|
||||||
|
Console.SetOut(writer);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = new ManagementResponse(204, "", null, null);
|
||||||
|
var exitCode = CommandHelpers.HandleResponse(response, "table");
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleResponse_EmptyBody_JsonFormat_DoesNotThrow_ReturnsZero()
|
||||||
|
{
|
||||||
|
var writer = new StringWriter();
|
||||||
|
Console.SetOut(writer);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = new ManagementResponse(200, " ", null, null);
|
||||||
|
var exitCode = CommandHelpers.HandleResponse(response, "json");
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleResponse_NonJsonBody_TableFormat_FallsBackToRaw_ReturnsZero()
|
||||||
|
{
|
||||||
|
// CLI-003: a success status with a non-JSON body (e.g. proxy HTML error page)
|
||||||
|
// must not crash; it should print the raw body verbatim.
|
||||||
|
var writer = new StringWriter();
|
||||||
|
Console.SetOut(writer);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = new ManagementResponse(200, "<html>Service Unavailable</html>", null, null);
|
||||||
|
var exitCode = CommandHelpers.HandleResponse(response, "table");
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Contains("<html>Service Unavailable</html>", writer.ToString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tests/ScadaLink.CLI.Tests/TestCollections.cs
Normal file
16
tests/ScadaLink.CLI.Tests/TestCollections.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// xUnit runs test classes in parallel by default. Several CLI test classes redirect
|
||||||
|
/// the process-global <see cref="System.Console.Out"/> / <see cref="System.Console.Error"/>,
|
||||||
|
/// which races when they run concurrently. Tests in this collection run serially.
|
||||||
|
/// </summary>
|
||||||
|
[CollectionDefinition("Console")]
|
||||||
|
public sealed class ConsoleCollection { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test classes that mutate process-global environment variables run serially so they
|
||||||
|
/// do not observe each other's changes.
|
||||||
|
/// </summary>
|
||||||
|
[CollectionDefinition("Environment")]
|
||||||
|
public sealed class EnvironmentCollection { }
|
||||||
31
tests/ScadaLink.CLI.Tests/UrlValidationTests.cs
Normal file
31
tests/ScadaLink.CLI.Tests/UrlValidationTests.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CLI-004 — a malformed <c>--url</c> previously reached
|
||||||
|
/// <c>new Uri(...)</c> in the <see cref="ScadaLink.CLI.ManagementHttpClient"/> constructor
|
||||||
|
/// and threw an unhandled <see cref="UriFormatException"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class UrlValidationTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("http://localhost:9001")]
|
||||||
|
[InlineData("https://central-host:5000/")]
|
||||||
|
[InlineData("http://central")]
|
||||||
|
public void IsValidManagementUrl_AcceptsAbsoluteHttpUrls(string url)
|
||||||
|
{
|
||||||
|
Assert.True(CommandHelpers.IsValidManagementUrl(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("localhost:9001")] // no scheme
|
||||||
|
[InlineData("")] // empty
|
||||||
|
[InlineData(" ")] // whitespace
|
||||||
|
[InlineData("not a url")]
|
||||||
|
[InlineData("ftp://central:21")] // non-http scheme
|
||||||
|
public void IsValidManagementUrl_RejectsMalformedOrNonHttpUrls(string url)
|
||||||
|
{
|
||||||
|
Assert.False(CommandHelpers.IsValidManagementUrl(url));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user