vortex-cli 6.3.0__tar.gz → 6.4.1__tar.gz

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.
Files changed (60) hide show
  1. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/PKG-INFO +40 -1
  2. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/README.md +39 -0
  3. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/pyproject.toml +1 -1
  4. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/cli.py +58 -0
  5. vortex_cli-6.4.1/vortex/commands/keyword.py +309 -0
  6. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/gateway.py +37 -2
  7. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/main.py +12 -1
  8. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex_cli.egg-info/PKG-INFO +40 -1
  9. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex_cli.egg-info/SOURCES.txt +1 -0
  10. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/LICENSE +0 -0
  11. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/setup.cfg +0 -0
  12. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/__init__.py +0 -0
  13. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/__main__.py +0 -0
  14. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/colour.py +0 -0
  15. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/__init__.py +0 -0
  16. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/agenda.py +0 -0
  17. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/clean.py +0 -0
  18. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/clone.py +0 -0
  19. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/code.py +0 -0
  20. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/compile.py +0 -0
  21. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/config.py +0 -0
  22. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/copy.py +0 -0
  23. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/db.py +0 -0
  24. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/delete.py +0 -0
  25. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/docs.py +0 -0
  26. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/execute.py +0 -0
  27. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/export.py +0 -0
  28. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/find.py +0 -0
  29. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/grep.py +0 -0
  30. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/import_.py +0 -0
  31. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/libs.py +0 -0
  32. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/list.py +0 -0
  33. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/log.py +0 -0
  34. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/new.py +0 -0
  35. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/pull.py +0 -0
  36. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/push.py +0 -0
  37. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/render.py +0 -0
  38. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/schema.py +0 -0
  39. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/status.py +0 -0
  40. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/undo.py +0 -0
  41. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/use.py +0 -0
  42. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/commands/watch.py +0 -0
  43. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/constants.py +0 -0
  44. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/docs/Blackbook v2.md +0 -0
  45. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/docs/Blackbook.pdf +0 -0
  46. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/docs/index.html +0 -0
  47. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/docs/marked.min.js +0 -0
  48. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/lib/puakma-6.0.40.jar +0 -0
  49. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/libs.py +0 -0
  50. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/logging.py +0 -0
  51. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/models.py +0 -0
  52. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/soap.py +0 -0
  53. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/spinner.py +0 -0
  54. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/util.py +0 -0
  55. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/webdesign.py +0 -0
  56. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex/workspace.py +0 -0
  57. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex_cli.egg-info/dependency_links.txt +0 -0
  58. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex_cli.egg-info/entry_points.txt +0 -0
  59. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex_cli.egg-info/requires.txt +0 -0
  60. {vortex_cli-6.3.0 → vortex_cli-6.4.1}/vortex_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vortex_cli
3
- Version: 6.3.0
3
+ Version: 6.4.1
4
4
  Summary: Vortex CLI
5
5
  Author-email: Jordan Amos <jordan.amos@gmail.com>
6
6
  License: MIT License
@@ -182,6 +182,7 @@ For a full list of commands see `--help`.
182
182
  - `find`: Find Design Objects of cloned applications by name.
183
183
  - `grep`: Search the contents of cloned Design Objects using a Regular Expression.
184
184
  - `new`: Create new Design Objects, Applications, or Keywords. Use `--update <ID>` to update instead. Run without flags to launch an interactive wizard.
185
+ - `keyword` (or `kw`): Read or update an application's Keyword values. `vortex keyword 9` lists them, `-n <Name>` filters to one, and `-n <Name> --values ...` updates it, printing the prior value beside the new one before asking to continue. Secret-looking names are redacted unless `--reveal` is given - see [Keyword values and secrets](#keyword-values-and-secrets).
185
186
  - `copy`: Copy a Design Object from one application to another.
186
187
  - `delete`: Delete Design Objects by ID.
187
188
  - `db`: Interact with Database Connections. Accepts `--server`/`-s` like other commands for one-off queries against another server.
@@ -191,6 +192,42 @@ For a full list of commands see `--help`.
191
192
  - `docs`: Open the Tornado Server Blackbook.
192
193
  - `execute`: Execute a command on the server.
193
194
 
195
+ ### Keyword values and secrets
196
+
197
+ Keywords are an application's live configuration, and in practice they hold credentials in
198
+ cleartext - API keys, passwords, vendor secrets, signing keys. `vortex keyword` therefore
199
+ **redacts by default**, following the same precedent as `vortex config --show-server`:
200
+
201
+ ```
202
+ vortex keyword 9 # list every Keyword (secrets redacted)
203
+ vortex keyword 9 -n AppVersion # just this one
204
+ vortex keyword 9 -n AppVersion --values 3.7.0 # update it
205
+ vortex keyword 9 --reveal # print secret values in full
206
+ ```
207
+
208
+ - A Keyword is treated as secret when its **name** contains a marker such as `password`,
209
+ `passwd`, `passphrase`, `secret`, `credential`, `signature`, `apikey`, `privatekey`,
210
+ `keystore`, `webhook`, `connectionstring` or `mnemonic`, or when any *word* of the name
211
+ is one of `key`, `keys`, `pwd`, `token`, `salt`, `hash`, `private`, `auth`, `bearer`,
212
+ `cert`, `pem`, `jwt`, `dsn`, `otp`, `pin`, `seed` or `sig`. Names are split on
213
+ camelCase/snake_case/kebab-case, so `AccessKeyId` and `API_KEY` are redacted while
214
+ `Monkey` and `Concert` are not. The match is on the name only - a value-based heuristic
215
+ would leak the thing it is trying to hide on every near miss. Classification is
216
+ deliberately biased towards over-redaction: a false positive costs one `--reveal`, a
217
+ false negative prints a live credential.
218
+ - Redacted values print as `<redacted>`, one per value, so the value *count* stays visible.
219
+ - `--reveal` is the explicit opt-in. Without it, neither a listing nor an update's
220
+ before/after diff can put a live credential into your terminal scrollback, your shell
221
+ history or a CI log.
222
+
223
+ An update always prints the prior value beside the new one and waits for `[Y/y]` on stdin
224
+ before writing; on a `protected = true` server the server name must also be typed back
225
+ (`--yes` does not bypass that). The write uses the gateway's keyword upsert, so it never
226
+ inserts a duplicate KEYWORD row.
227
+
228
+ Reading does **not** require the application to be cloned locally - diagnosing a bad
229
+ configuration value should not depend on having a working copy.
230
+
194
231
  ### Cloning a whole group
195
232
 
196
233
  `vortex clone` takes an ID, a TemplateName, an application group, or `group/name`:
@@ -365,6 +402,8 @@ needs no change to the gateway.
365
402
  | `copy`, `new object` | webdesign `POST`/`PUT design`, `PUT design/params` | SOAP |
366
403
  | `new app` | webdesign `POST vortex` | SOAP `saveApplication` |
367
404
  | `new keyword` | gateway `keyword` (upsert) | SOAP `saveKeyword` |
405
+ | `keyword` (read) | gateway `download` (metadata window; `GatewayDesignRead`) | **unsupported** - SOAP has no keyword read call |
406
+ | `keyword --values` (write) | gateway `keyword` (upsert) | **unsupported** |
368
407
  | `schema`, `db --list`, `db --schema` | gateway `dictionary` (webdesign's `database`/`table`/`column` routes run server-side; `GatewayDBRead`/`GatewayDBWrite`) | SOAP SQL + `savePuakma*` |
369
408
  | `export` | webdesign `ExportPMX` action | SOAP `downloadPmx` |
370
409
  | `import` | **still SOAP** - webdesign has no import route yet | SOAP `uploadPmx` |
@@ -138,6 +138,7 @@ For a full list of commands see `--help`.
138
138
  - `find`: Find Design Objects of cloned applications by name.
139
139
  - `grep`: Search the contents of cloned Design Objects using a Regular Expression.
140
140
  - `new`: Create new Design Objects, Applications, or Keywords. Use `--update <ID>` to update instead. Run without flags to launch an interactive wizard.
141
+ - `keyword` (or `kw`): Read or update an application's Keyword values. `vortex keyword 9` lists them, `-n <Name>` filters to one, and `-n <Name> --values ...` updates it, printing the prior value beside the new one before asking to continue. Secret-looking names are redacted unless `--reveal` is given - see [Keyword values and secrets](#keyword-values-and-secrets).
141
142
  - `copy`: Copy a Design Object from one application to another.
142
143
  - `delete`: Delete Design Objects by ID.
143
144
  - `db`: Interact with Database Connections. Accepts `--server`/`-s` like other commands for one-off queries against another server.
@@ -147,6 +148,42 @@ For a full list of commands see `--help`.
147
148
  - `docs`: Open the Tornado Server Blackbook.
148
149
  - `execute`: Execute a command on the server.
149
150
 
151
+ ### Keyword values and secrets
152
+
153
+ Keywords are an application's live configuration, and in practice they hold credentials in
154
+ cleartext - API keys, passwords, vendor secrets, signing keys. `vortex keyword` therefore
155
+ **redacts by default**, following the same precedent as `vortex config --show-server`:
156
+
157
+ ```
158
+ vortex keyword 9 # list every Keyword (secrets redacted)
159
+ vortex keyword 9 -n AppVersion # just this one
160
+ vortex keyword 9 -n AppVersion --values 3.7.0 # update it
161
+ vortex keyword 9 --reveal # print secret values in full
162
+ ```
163
+
164
+ - A Keyword is treated as secret when its **name** contains a marker such as `password`,
165
+ `passwd`, `passphrase`, `secret`, `credential`, `signature`, `apikey`, `privatekey`,
166
+ `keystore`, `webhook`, `connectionstring` or `mnemonic`, or when any *word* of the name
167
+ is one of `key`, `keys`, `pwd`, `token`, `salt`, `hash`, `private`, `auth`, `bearer`,
168
+ `cert`, `pem`, `jwt`, `dsn`, `otp`, `pin`, `seed` or `sig`. Names are split on
169
+ camelCase/snake_case/kebab-case, so `AccessKeyId` and `API_KEY` are redacted while
170
+ `Monkey` and `Concert` are not. The match is on the name only - a value-based heuristic
171
+ would leak the thing it is trying to hide on every near miss. Classification is
172
+ deliberately biased towards over-redaction: a false positive costs one `--reveal`, a
173
+ false negative prints a live credential.
174
+ - Redacted values print as `<redacted>`, one per value, so the value *count* stays visible.
175
+ - `--reveal` is the explicit opt-in. Without it, neither a listing nor an update's
176
+ before/after diff can put a live credential into your terminal scrollback, your shell
177
+ history or a CI log.
178
+
179
+ An update always prints the prior value beside the new one and waits for `[Y/y]` on stdin
180
+ before writing; on a `protected = true` server the server name must also be typed back
181
+ (`--yes` does not bypass that). The write uses the gateway's keyword upsert, so it never
182
+ inserts a duplicate KEYWORD row.
183
+
184
+ Reading does **not** require the application to be cloned locally - diagnosing a bad
185
+ configuration value should not depend on having a working copy.
186
+
150
187
  ### Cloning a whole group
151
188
 
152
189
  `vortex clone` takes an ID, a TemplateName, an application group, or `group/name`:
@@ -321,6 +358,8 @@ needs no change to the gateway.
321
358
  | `copy`, `new object` | webdesign `POST`/`PUT design`, `PUT design/params` | SOAP |
322
359
  | `new app` | webdesign `POST vortex` | SOAP `saveApplication` |
323
360
  | `new keyword` | gateway `keyword` (upsert) | SOAP `saveKeyword` |
361
+ | `keyword` (read) | gateway `download` (metadata window; `GatewayDesignRead`) | **unsupported** - SOAP has no keyword read call |
362
+ | `keyword --values` (write) | gateway `keyword` (upsert) | **unsupported** |
324
363
  | `schema`, `db --list`, `db --schema` | gateway `dictionary` (webdesign's `database`/`table`/`column` routes run server-side; `GatewayDBRead`/`GatewayDBWrite`) | SOAP SQL + `savePuakma*` |
325
364
  | `export` | webdesign `ExportPMX` action | SOAP `downloadPmx` |
326
365
  | `import` | **still SOAP** - webdesign has no import route yet | SOAP `uploadPmx` |
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "vortex_cli"
8
- version = "6.3.0"
8
+ version = "6.4.1"
9
9
  description = "Vortex CLI"
10
10
  requires-python = ">=3.10"
11
11
  readme = { file = "README.md", content-type = "text/markdown" }
@@ -198,6 +198,7 @@ def validate_args(
198
198
  new_parser: ArgumentParser,
199
199
  clone_parser: ArgumentParser,
200
200
  db_parser: ArgumentParser,
201
+ keyword_parser: ArgumentParser,
201
202
  ) -> None:
202
203
  if args.command == "new":
203
204
  msg = None
@@ -265,6 +266,11 @@ def validate_args(
265
266
  clone_parser.error(
266
267
  f"--group expects group names, not {', '.join(not_groups)}"
267
268
  )
269
+ elif args.command in ("keyword", "kw"):
270
+ # A write needs a target: '--values' with no '--name' would
271
+ # otherwise be a silent no-op, or worse, ambiguous
272
+ if args.values is not None and not args.name:
273
+ keyword_parser.error("--values requires --name")
268
274
  elif args.command == "db" and args.sql is not None:
269
275
  try:
270
276
  args.database = int(args.database)
@@ -1006,6 +1012,58 @@ def add_new_parser(
1006
1012
  return new_parser
1007
1013
 
1008
1014
 
1015
+ def add_keyword_parser(
1016
+ command_parser: _SubParsersAction[ArgumentParser],
1017
+ ) -> ArgumentParser:
1018
+ keyword_parser = command_parser.add_parser(
1019
+ "keyword",
1020
+ aliases=("kw",),
1021
+ help=(
1022
+ "Read or update an application's Keyword values. With no "
1023
+ "--values it lists them; with --values it updates one, "
1024
+ "showing the prior value first (gateway only)"
1025
+ ),
1026
+ )
1027
+ keyword_parser.add_argument(
1028
+ "app_id",
1029
+ metavar="APP_ID",
1030
+ type=int,
1031
+ help="The application id whose Keywords to read or update",
1032
+ )
1033
+ keyword_parser.add_argument(
1034
+ "--name",
1035
+ "-n",
1036
+ help=(
1037
+ "Only this Keyword (exact, case-insensitive). Required when "
1038
+ "--values is given"
1039
+ ),
1040
+ )
1041
+ keyword_parser.add_argument(
1042
+ "--values",
1043
+ nargs="*",
1044
+ default=None,
1045
+ metavar="VALUE",
1046
+ help=(
1047
+ "Set --name to these value(s), replacing what is there. Pass "
1048
+ "with no values to set it empty. Requires --name"
1049
+ ),
1050
+ )
1051
+ keyword_parser.add_argument(
1052
+ "--reveal",
1053
+ action="store_true",
1054
+ help=(
1055
+ "Print secret-looking Keyword values in full. Values of "
1056
+ "Keywords whose NAME looks like a credential (password, "
1057
+ "secret, key, token, auth, cert, hash, salt, pin, otp, "
1058
+ "webhook, connection string and similar) are redacted by "
1059
+ "default so a plain listing cannot leak a live credential "
1060
+ "into the terminal, shell history or a CI log"
1061
+ ),
1062
+ )
1063
+ _add_server_option(keyword_parser)
1064
+ return keyword_parser
1065
+
1066
+
1009
1067
  def add_copy_parser(command_parser: _SubParsersAction[ArgumentParser]) -> None:
1010
1068
  copy_parser = command_parser.add_parser(
1011
1069
  "copy", help="Copy a Design Object from one application to another"
@@ -0,0 +1,309 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ from typing import Any
6
+
7
+ import tabulate
8
+
9
+ from vortex.colour import Colour
10
+ from vortex.gateway import GatewayClient
11
+ from vortex.gateway import GatewayRefused
12
+ from vortex.gateway import GatewayUnavailable
13
+ from vortex.gateway import refusal_detail
14
+ from vortex.models import PuakmaServer
15
+ from vortex.util import confirm_protected
16
+
17
+ logger = logging.getLogger("vortex")
18
+
19
+ _REFUSAL_HELP = {
20
+ "FORBIDDEN": (
21
+ "this identity does not hold the required gateway role on this "
22
+ "server (listing needs GatewayDesignRead; setting a value needs "
23
+ "GatewayDesignWrite - or Admin)"
24
+ ),
25
+ "NOT_FOUND": "no application with that id on this server",
26
+ }
27
+
28
+ # Keyword values are an application's live configuration and routinely
29
+ # hold credentials in cleartext (API keys, passwords, vendor secrets,
30
+ # signing keys). Printing them by default would put them in the terminal
31
+ # scrollback, the shell history and any CI log that captured the run, so
32
+ # a Keyword whose NAME matches this is redacted unless --reveal is given.
33
+ # This follows the gateway's own 'server_config' precedent, which
34
+ # redacts the server's secrets before they ever reach the wire.
35
+ # Long, unambiguous markers - matched anywhere in the name.
36
+ SECRET_SUBSTRINGS = (
37
+ "password",
38
+ "passwd",
39
+ "passphrase",
40
+ "secret",
41
+ "credential",
42
+ "signature",
43
+ "mnemonic",
44
+ "keystore",
45
+ "truststore",
46
+ "keyphrase",
47
+ "connectionstring",
48
+ "connstring",
49
+ "webhook",
50
+ "apikey",
51
+ "privatekey",
52
+ )
53
+
54
+ # Short/ambiguous markers - matched only as a whole word of the name, so
55
+ # 'Monkey' and 'Spinner' are not mistaken for 'key' and 'pin'.
56
+ SECRET_WORDS = frozenset(
57
+ {
58
+ "pwd",
59
+ "token",
60
+ "tokens",
61
+ "salt",
62
+ "hash",
63
+ "key",
64
+ "keys",
65
+ "private",
66
+ "bearer",
67
+ "auth",
68
+ "authorization",
69
+ "authentication",
70
+ "cert",
71
+ "certificate",
72
+ "pem",
73
+ "pfx",
74
+ "jwt",
75
+ "dsn",
76
+ "otp",
77
+ "pin",
78
+ "seed",
79
+ "sig",
80
+ }
81
+ )
82
+
83
+ # camelCase / PascalCase / snake_case / kebab-case / ALLCAPS -> words
84
+ _WORD_PATTERN = re.compile(r"[A-Z]+(?![a-z])|[A-Z][a-z]*|[a-z]+|\d+")
85
+
86
+ REDACTED = "<redacted>"
87
+
88
+
89
+ def name_words(name: str) -> list[str]:
90
+ """The name split into lowercased words, however it is cased."""
91
+ return [w.casefold() for w in _WORD_PATTERN.findall(name)]
92
+
93
+
94
+ def is_secret_name(name: str) -> bool:
95
+ """
96
+ True when a Keyword name looks like it holds a credential. Matched
97
+ on the NAME only - a value-based heuristic would leak the very thing
98
+ it is trying to hide on every near miss.
99
+
100
+ Deliberately biased towards over-redaction: a false positive costs
101
+ one '--reveal', a false negative prints a live credential into the
102
+ terminal scrollback, the shell history and any CI log.
103
+ """
104
+ folded = name.casefold()
105
+ if any(marker in folded for marker in SECRET_SUBSTRINGS):
106
+ return True
107
+ return any(word in SECRET_WORDS for word in name_words(name))
108
+
109
+
110
+ def redact_values(name: str, values: list[str], reveal: bool) -> list[str]:
111
+ """
112
+ What to display for a Keyword: the real values, or one <redacted>
113
+ marker per value (the COUNT is safe and useful; the content is not).
114
+ """
115
+ if reveal or not is_secret_name(name):
116
+ return list(values)
117
+ return [REDACTED for _ in values]
118
+
119
+
120
+ def _format_values(values: list[str]) -> str:
121
+ return " | ".join(values)
122
+
123
+
124
+ def _app_label(app: dict[str, Any], app_id: int) -> str:
125
+ group = str(app.get("group") or "")
126
+ name = str(app.get("name") or "")
127
+ label = f"{group}/{name}".strip("/")
128
+ return f"{label} [{app_id}]" if label else f"[{app_id}]"
129
+
130
+
131
+ def _fetch(
132
+ client: GatewayClient, app_id: int, name: str | None
133
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
134
+ app, rows = client.get_keywords(app_id)
135
+ if name is not None:
136
+ needle = name.casefold()
137
+ rows = [r for r in rows if str(r.get("name", "")).casefold() == needle]
138
+ return app, rows
139
+
140
+
141
+ def _client_or_error(server: PuakmaServer) -> GatewayClient | None:
142
+ """
143
+ The gateway client, or None with the reason already logged. Keywords
144
+ are gateway-only here: the legacy SOAP designer has no keyword READ
145
+ call at all, which is exactly why reading one was impossible before
146
+ this command existed.
147
+ """
148
+ try:
149
+ client = server.negotiate_gateway()
150
+ except GatewayUnavailable as e:
151
+ logger.error(e)
152
+ return None
153
+ if client is None:
154
+ logger.error(
155
+ "'vortex keyword' needs the gateway backend "
156
+ f"(set 'backend = gateway' for '{server.name}') - the legacy "
157
+ "SOAP designer has no keyword read call"
158
+ )
159
+ return None
160
+ return client
161
+
162
+
163
+ def keyword(
164
+ server: PuakmaServer,
165
+ app_id: int,
166
+ *,
167
+ name: str | None = None,
168
+ values: list[str] | None = None,
169
+ reveal: bool = False,
170
+ yes: bool = False,
171
+ ) -> int:
172
+ """
173
+ Read or update an application's Keyword values.
174
+
175
+ With no --values this LISTS Keywords (all of them, or just --name).
176
+ With --values it UPDATES --name, showing the prior value beside the
177
+ new one before writing, so the change is visible rather than blind;
178
+ the write goes through the gateway's keyword upsert, which never
179
+ inserts a duplicate row.
180
+
181
+ The application does NOT need to be cloned locally - diagnosing a
182
+ bad configuration value must not require a working copy.
183
+ """
184
+ if values is not None:
185
+ if not name:
186
+ # argparse rejects this first; belt-and-braces so a direct
187
+ # call cannot upsert a nameless Keyword
188
+ logger.error("--values requires --name")
189
+ return 1
190
+ return _set_keyword(server, app_id, name, values, reveal=reveal, yes=yes)
191
+ return _list_keywords(server, app_id, name, reveal=reveal)
192
+
193
+
194
+ def _list_keywords(
195
+ server: PuakmaServer,
196
+ app_id: int,
197
+ name: str | None,
198
+ *,
199
+ reveal: bool,
200
+ ) -> int:
201
+ with server as s:
202
+ client = _client_or_error(s)
203
+ if client is None:
204
+ return 1
205
+ try:
206
+ app, rows = _fetch(client, app_id, name)
207
+ except GatewayRefused as e:
208
+ logger.error(refusal_detail(e, _REFUSAL_HELP))
209
+ return 1
210
+ except GatewayUnavailable as e:
211
+ logger.error(e)
212
+ return 1
213
+
214
+ label = _app_label(app, app_id)
215
+ if not rows:
216
+ if name:
217
+ logger.error(f"No Keyword named '{name}' in {label}")
218
+ return 1
219
+ print(f"[{server.name}] {label} has no Keywords.")
220
+ return 0
221
+
222
+ redacted = 0
223
+ table = []
224
+ for row in sorted(rows, key=lambda r: str(r.get("name", "")).casefold()):
225
+ kw_name = str(row.get("name") or "")
226
+ raw = [str(v) for v in (row.get("values") or [])]
227
+ shown = redact_values(kw_name, raw, reveal)
228
+ if shown != raw:
229
+ redacted += 1
230
+ flag = "DUPLICATE" if row.get("duplicate") else ""
231
+ table.append([row.get("id"), kw_name, _format_values(shown), flag])
232
+
233
+ header = f"[{server.name}] {label} - {len(table)} Keyword(s)"
234
+ if redacted:
235
+ header += f", {redacted} redacted (--reveal to show)"
236
+ print(header)
237
+ print(tabulate.tabulate(table, headers=["ID", "Name", "Values", ""]))
238
+ return 0
239
+
240
+
241
+ def _set_keyword(
242
+ server: PuakmaServer,
243
+ app_id: int,
244
+ name: str,
245
+ values: list[str],
246
+ *,
247
+ reveal: bool,
248
+ yes: bool,
249
+ ) -> int:
250
+ with server as s:
251
+ client = _client_or_error(s)
252
+ if client is None:
253
+ return 1
254
+
255
+ try:
256
+ app, existing = _fetch(client, app_id, name)
257
+ except GatewayRefused as e:
258
+ logger.error(refusal_detail(e, _REFUSAL_HELP))
259
+ return 1
260
+ except GatewayUnavailable as e:
261
+ logger.error(e)
262
+ return 1
263
+
264
+ prior = (
265
+ [str(v) for v in (existing[0].get("values") or [])] if existing else None
266
+ )
267
+
268
+ # Showing the prior value IS the feature: a blind write is how a
269
+ # bad Keyword value becomes an outage nobody can diagnose. A
270
+ # secret-looking name stays redacted even in the diff.
271
+ label = _app_label(app, app_id)
272
+ verb = (
273
+ Colour.colour("UPDATED", Colour.YELLOW)
274
+ if existing
275
+ else Colour.colour("CREATED", Colour.GREEN)
276
+ )
277
+ print(f"Keyword '{name}' in {label} will be {verb}:\n")
278
+ if prior is None:
279
+ print(" from: (does not exist)")
280
+ else:
281
+ print(f" from: {_format_values(redact_values(name, prior, reveal))}")
282
+ print(f" to: {_format_values(redact_values(name, values, reveal))}")
283
+ if prior is not None and prior == values:
284
+ print("\nThe Keyword already has this value; nothing to do.")
285
+ return 0
286
+
287
+ if not yes and input("\n[Y/y] to continue:") not in ["Y", "y"]:
288
+ logger.error("Operation Cancelled")
289
+ return 1
290
+ # Deliberately NOT bypassed by --yes: on a protected server the
291
+ # name must still be typed back
292
+ if not confirm_protected(s, f"update the Keyword '{name}'"):
293
+ return 1
294
+
295
+ try:
296
+ reply = client.keyword_upsert(app_id, name, values)
297
+ except GatewayRefused as e:
298
+ logger.error(refusal_detail(e, _REFUSAL_HELP))
299
+ return 1
300
+ except GatewayUnavailable as e:
301
+ logger.error(e)
302
+ return 1
303
+
304
+ dupes = int(reply.get("duplicatesConsolidated") or 0)
305
+ extra = f" ({dupes} duplicate row(s) consolidated)" if dupes else ""
306
+ logger.info(
307
+ f"Upserted Keyword '{name}' [{reply.get('keywordId')}] via gateway{extra}"
308
+ )
309
+ return 0
@@ -214,6 +214,17 @@ class GatewayCapabilities(NamedTuple):
214
214
  return [name for name, grant in self.roles.items() if grant.effective]
215
215
 
216
216
 
217
+ def _form_body(payload: dict[str, Any]) -> dict[str, str]:
218
+ """
219
+ The gateway reads a request body from the 'Data' document item, and
220
+ the server fills that item completely only for a form-encoded POST.
221
+ A raw application/json body over ~512 KB arrives as an EMPTY item
222
+ (MISSING_PARAMETER for an upload of any real size), so every POST is
223
+ sent as one form field, the same way the webdesign client does it.
224
+ """
225
+ return {"Data": json.dumps(payload)}
226
+
227
+
217
228
  class GatewayClient:
218
229
  def __init__(self, server: PuakmaServer) -> None:
219
230
  self.server = server
@@ -568,7 +579,9 @@ class GatewayClient:
568
579
  url = self._url(endpoint)
569
580
  logger.debug(f"POST {url}")
570
581
  try:
571
- resp = self.server._client.post(url, json=payload, timeout=timeout)
582
+ resp = self.server._client.post(
583
+ url, data=_form_body(payload), timeout=timeout
584
+ )
572
585
  except httpx.HTTPError as e:
573
586
  raise GatewayUnavailable(f"Gateway request failed: {e} ({url})") from e
574
587
  return self._parse_reply(resp.status_code, resp.text, url)
@@ -582,7 +595,9 @@ class GatewayClient:
582
595
  url = self._url(endpoint)
583
596
  logger.debug(f"POST {url}")
584
597
  try:
585
- resp = await self.server._aclient.post(url, json=payload, timeout=timeout)
598
+ resp = await self.server._aclient.post(
599
+ url, data=_form_body(payload), timeout=timeout
600
+ )
586
601
  except httpx.HTTPError as e:
587
602
  raise GatewayUnavailable(f"Gateway request failed: {e} ({url})") from e
588
603
  return self._parse_reply(resp.status_code, resp.text, url)
@@ -631,6 +646,26 @@ class GatewayClient:
631
646
  params["confirm"] = 1
632
647
  return self._get("delete", params=params)
633
648
 
649
+ def get_keywords(self, app_id: int) -> tuple[dict[str, Any], list[dict[str, Any]]]:
650
+ """
651
+ One app's row and its KEYWORD rows - name, values, id and
652
+ whether the name has duplicate rows (GatewayDesignRead).
653
+
654
+ The gateway ships no dedicated keyword read endpoint and needs
655
+ none: 'download' already returns the keywords, so this asks for
656
+ its metadata-only window ('ids=' selects no element content)
657
+ rather than requiring a server-side change. Values come back
658
+ RAW - redaction is the caller's job (commands.keyword).
659
+ """
660
+ reply = self._get("download", params={"appId": app_id, "ids": ""})
661
+ try:
662
+ return dict(reply["app"]), [dict(row) for row in reply["keywords"]]
663
+ except (KeyError, TypeError) as e:
664
+ raise GatewayUnavailable(
665
+ f"Malformed download reply from {self._url('download')} "
666
+ "(no 'app'/'keywords')"
667
+ ) from e
668
+
634
669
  def keyword_upsert(
635
670
  self, app_id: int, name: str, values: list[str]
636
671
  ) -> dict[str, Any]:
@@ -28,6 +28,7 @@ from vortex.commands.export import export
28
28
  from vortex.commands.find import find
29
29
  from vortex.commands.grep import grep
30
30
  from vortex.commands.import_ import import_
31
+ from vortex.commands.keyword import keyword
31
32
  from vortex.commands.libs import libs_
32
33
  from vortex.commands.list import list_
33
34
  from vortex.commands.log import log
@@ -259,13 +260,14 @@ def main(argv: Sequence[str] | None = None) -> int:
259
260
  code_parser = cli.add_code_parser(command_parser)
260
261
  db_parser = cli.add_db_parser(command_parser)
261
262
  new_parser = cli.add_new_parser(command_parser)
263
+ keyword_parser = cli.add_keyword_parser(command_parser)
262
264
 
263
265
  args, remaining_args = parser.parse_known_args(argv)
264
266
  # Call this for initial Validation
265
267
  if args.command != "code":
266
268
  parser.parse_args(argv)
267
269
 
268
- cli.validate_args(args, new_parser, clone_parser, db_parser)
270
+ cli.validate_args(args, new_parser, clone_parser, db_parser, keyword_parser)
269
271
 
270
272
  if args.no_colour:
271
273
  Colour.disable()
@@ -484,6 +486,15 @@ def main(argv: Sequence[str] | None = None) -> int:
484
486
  "'new' command requires a sub command 'object', 'obj', 'app' or 'keyword' or 'kw'"
485
487
  )
486
488
  return new(workspace, server, args)
489
+ elif args.command in ("keyword", "kw"):
490
+ return keyword(
491
+ server,
492
+ args.app_id,
493
+ name=args.name,
494
+ values=args.values,
495
+ reveal=args.reveal,
496
+ yes=args.yes,
497
+ )
487
498
  elif args.command == "delete":
488
499
  return delete(workspace, server, obj_ids, yes=args.yes)
489
500
  elif args.command == "copy":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vortex_cli
3
- Version: 6.3.0
3
+ Version: 6.4.1
4
4
  Summary: Vortex CLI
5
5
  Author-email: Jordan Amos <jordan.amos@gmail.com>
6
6
  License: MIT License
@@ -182,6 +182,7 @@ For a full list of commands see `--help`.
182
182
  - `find`: Find Design Objects of cloned applications by name.
183
183
  - `grep`: Search the contents of cloned Design Objects using a Regular Expression.
184
184
  - `new`: Create new Design Objects, Applications, or Keywords. Use `--update <ID>` to update instead. Run without flags to launch an interactive wizard.
185
+ - `keyword` (or `kw`): Read or update an application's Keyword values. `vortex keyword 9` lists them, `-n <Name>` filters to one, and `-n <Name> --values ...` updates it, printing the prior value beside the new one before asking to continue. Secret-looking names are redacted unless `--reveal` is given - see [Keyword values and secrets](#keyword-values-and-secrets).
185
186
  - `copy`: Copy a Design Object from one application to another.
186
187
  - `delete`: Delete Design Objects by ID.
187
188
  - `db`: Interact with Database Connections. Accepts `--server`/`-s` like other commands for one-off queries against another server.
@@ -191,6 +192,42 @@ For a full list of commands see `--help`.
191
192
  - `docs`: Open the Tornado Server Blackbook.
192
193
  - `execute`: Execute a command on the server.
193
194
 
195
+ ### Keyword values and secrets
196
+
197
+ Keywords are an application's live configuration, and in practice they hold credentials in
198
+ cleartext - API keys, passwords, vendor secrets, signing keys. `vortex keyword` therefore
199
+ **redacts by default**, following the same precedent as `vortex config --show-server`:
200
+
201
+ ```
202
+ vortex keyword 9 # list every Keyword (secrets redacted)
203
+ vortex keyword 9 -n AppVersion # just this one
204
+ vortex keyword 9 -n AppVersion --values 3.7.0 # update it
205
+ vortex keyword 9 --reveal # print secret values in full
206
+ ```
207
+
208
+ - A Keyword is treated as secret when its **name** contains a marker such as `password`,
209
+ `passwd`, `passphrase`, `secret`, `credential`, `signature`, `apikey`, `privatekey`,
210
+ `keystore`, `webhook`, `connectionstring` or `mnemonic`, or when any *word* of the name
211
+ is one of `key`, `keys`, `pwd`, `token`, `salt`, `hash`, `private`, `auth`, `bearer`,
212
+ `cert`, `pem`, `jwt`, `dsn`, `otp`, `pin`, `seed` or `sig`. Names are split on
213
+ camelCase/snake_case/kebab-case, so `AccessKeyId` and `API_KEY` are redacted while
214
+ `Monkey` and `Concert` are not. The match is on the name only - a value-based heuristic
215
+ would leak the thing it is trying to hide on every near miss. Classification is
216
+ deliberately biased towards over-redaction: a false positive costs one `--reveal`, a
217
+ false negative prints a live credential.
218
+ - Redacted values print as `<redacted>`, one per value, so the value *count* stays visible.
219
+ - `--reveal` is the explicit opt-in. Without it, neither a listing nor an update's
220
+ before/after diff can put a live credential into your terminal scrollback, your shell
221
+ history or a CI log.
222
+
223
+ An update always prints the prior value beside the new one and waits for `[Y/y]` on stdin
224
+ before writing; on a `protected = true` server the server name must also be typed back
225
+ (`--yes` does not bypass that). The write uses the gateway's keyword upsert, so it never
226
+ inserts a duplicate KEYWORD row.
227
+
228
+ Reading does **not** require the application to be cloned locally - diagnosing a bad
229
+ configuration value should not depend on having a working copy.
230
+
194
231
  ### Cloning a whole group
195
232
 
196
233
  `vortex clone` takes an ID, a TemplateName, an application group, or `group/name`:
@@ -365,6 +402,8 @@ needs no change to the gateway.
365
402
  | `copy`, `new object` | webdesign `POST`/`PUT design`, `PUT design/params` | SOAP |
366
403
  | `new app` | webdesign `POST vortex` | SOAP `saveApplication` |
367
404
  | `new keyword` | gateway `keyword` (upsert) | SOAP `saveKeyword` |
405
+ | `keyword` (read) | gateway `download` (metadata window; `GatewayDesignRead`) | **unsupported** - SOAP has no keyword read call |
406
+ | `keyword --values` (write) | gateway `keyword` (upsert) | **unsupported** |
368
407
  | `schema`, `db --list`, `db --schema` | gateway `dictionary` (webdesign's `database`/`table`/`column` routes run server-side; `GatewayDBRead`/`GatewayDBWrite`) | SOAP SQL + `savePuakma*` |
369
408
  | `export` | webdesign `ExportPMX` action | SOAP `downloadPmx` |
370
409
  | `import` | **still SOAP** - webdesign has no import route yet | SOAP `uploadPmx` |
@@ -32,6 +32,7 @@ vortex/commands/export.py
32
32
  vortex/commands/find.py
33
33
  vortex/commands/grep.py
34
34
  vortex/commands/import_.py
35
+ vortex/commands/keyword.py
35
36
  vortex/commands/libs.py
36
37
  vortex/commands/list.py
37
38
  vortex/commands/log.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes