vortex-cli 6.2.1__tar.gz → 6.4.0__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 (61) hide show
  1. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/PKG-INFO +53 -2
  2. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/README.md +52 -1
  3. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/pyproject.toml +1 -1
  4. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/cli.py +84 -1
  5. vortex_cli-6.4.0/vortex/commands/keyword.py +309 -0
  6. vortex_cli-6.4.0/vortex/commands/push.py +303 -0
  7. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/gateway.py +20 -0
  8. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/main.py +20 -2
  9. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex_cli.egg-info/PKG-INFO +53 -2
  10. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex_cli.egg-info/SOURCES.txt +1 -0
  11. vortex_cli-6.2.1/vortex/commands/push.py +0 -132
  12. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/LICENSE +0 -0
  13. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/setup.cfg +0 -0
  14. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/__init__.py +0 -0
  15. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/__main__.py +0 -0
  16. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/colour.py +0 -0
  17. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/__init__.py +0 -0
  18. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/agenda.py +0 -0
  19. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/clean.py +0 -0
  20. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/clone.py +0 -0
  21. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/code.py +0 -0
  22. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/compile.py +0 -0
  23. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/config.py +0 -0
  24. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/copy.py +0 -0
  25. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/db.py +0 -0
  26. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/delete.py +0 -0
  27. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/docs.py +0 -0
  28. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/execute.py +0 -0
  29. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/export.py +0 -0
  30. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/find.py +0 -0
  31. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/grep.py +0 -0
  32. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/import_.py +0 -0
  33. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/libs.py +0 -0
  34. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/list.py +0 -0
  35. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/log.py +0 -0
  36. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/new.py +0 -0
  37. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/pull.py +0 -0
  38. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/render.py +0 -0
  39. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/schema.py +0 -0
  40. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/status.py +0 -0
  41. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/undo.py +0 -0
  42. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/use.py +0 -0
  43. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/commands/watch.py +0 -0
  44. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/constants.py +0 -0
  45. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/docs/Blackbook v2.md +0 -0
  46. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/docs/Blackbook.pdf +0 -0
  47. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/docs/index.html +0 -0
  48. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/docs/marked.min.js +0 -0
  49. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/lib/puakma-6.0.40.jar +0 -0
  50. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/libs.py +0 -0
  51. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/logging.py +0 -0
  52. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/models.py +0 -0
  53. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/soap.py +0 -0
  54. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/spinner.py +0 -0
  55. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/util.py +0 -0
  56. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/webdesign.py +0 -0
  57. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex/workspace.py +0 -0
  58. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex_cli.egg-info/dependency_links.txt +0 -0
  59. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex_cli.egg-info/entry_points.txt +0 -0
  60. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/vortex_cli.egg-info/requires.txt +0 -0
  61. {vortex_cli-6.2.1 → vortex_cli-6.4.0}/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.2.1
3
+ Version: 6.4.0
4
4
  Summary: Vortex CLI
5
5
  Author-email: Jordan Amos <jordan.amos@gmail.com>
6
6
  License: MIT License
@@ -108,6 +108,18 @@ While it is possible to use without it, this software has been purposefully desi
108
108
  java_environment_name = JavaSE-17 ; Java Execution Environment name https://docs.osgi.org/reference/eenames.html
109
109
  ```
110
110
 
111
+ ## Upgrading to 6.3
112
+
113
+ - **`vortex push` only uploads what changed.** On a `backend = gateway` server the push first
114
+ reads the server's element hashes and skips every file that already matches - a full-app push
115
+ that used to re-send (and journal) hundreds of identical files now sends the few that differ.
116
+ `--all` restores the old push-everything behaviour; `backend = soap` still pushes everything.
117
+ - **Uploads run concurrently** - up to `--jobs N` elements at once (default 8; `-j 1` is the
118
+ old one-at-a-time push). A Java element's source and class still go in that order, and a
119
+ failed source stops that element's class from being uploaded over it.
120
+ - **`push --name` prefers an exact match.** Previously `--name dashboard` also pushed a
121
+ `Dashboard` element; now the case-insensitive match only applies when nothing matches exactly.
122
+
111
123
  ## Upgrading to 6.0
112
124
 
113
125
  6.0 is additive - **the default behaviour is unchanged**. Every server keeps using the SOAP
@@ -163,13 +175,14 @@ For a full list of commands see `--help`.
163
175
  - `log`: View the server log.
164
176
  - `status`: Show the server status - via the agent gateway when available, otherwise the raw console `status` output.
165
177
  - `agenda`: List every scheduled action with its decoded schedule, last and next run, and whether it is overdue (read-only, gateway only).
166
- - `push`: Upload local design files (source, compiled classes, pages, resources) to the server without a watch session - journaled and deploy-confirmed when routed via the gateway.
178
+ - `push`: Upload local design files (source, compiled classes, pages, resources) to the server without a watch session - journaled and deploy-confirmed when routed via the gateway. On a gateway server only files whose content differs from the server's are uploaded (compared by the server's element hashes; `--all` pushes everything), and elements upload concurrently (`--jobs N`, default 8) while each Java element's source always lands before its class. `--name` matches exactly first, case-insensitively only when nothing matches exactly.
167
179
  - `pull`: Refresh cloned applications in place via the gateway's incremental sync - only elements changed since the last sync are downloaded. Refuses to overwrite locally modified files without `--force`.
168
180
  - `render`: Render a PAGE server-side as your identity and print it (or `--out FILE`) - see a change without a browser login (gateway only).
169
181
  - `undo`: The server-side undo journal. No arguments lists journal entries; with an entry id it dry-runs a restore, and `--confirm` writes the journaled version back (gateway only).
170
182
  - `find`: Find Design Objects of cloned applications by name.
171
183
  - `grep`: Search the contents of cloned Design Objects using a Regular Expression.
172
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).
173
186
  - `copy`: Copy a Design Object from one application to another.
174
187
  - `delete`: Delete Design Objects by ID.
175
188
  - `db`: Interact with Database Connections. Accepts `--server`/`-s` like other commands for one-off queries against another server.
@@ -179,6 +192,42 @@ For a full list of commands see `--help`.
179
192
  - `docs`: Open the Tornado Server Blackbook.
180
193
  - `execute`: Execute a command on the server.
181
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
+
182
231
  ### Cloning a whole group
183
232
 
184
233
  `vortex clone` takes an ID, a TemplateName, an application group, or `group/name`:
@@ -353,6 +402,8 @@ needs no change to the gateway.
353
402
  | `copy`, `new object` | webdesign `POST`/`PUT design`, `PUT design/params` | SOAP |
354
403
  | `new app` | webdesign `POST vortex` | SOAP `saveApplication` |
355
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** |
356
407
  | `schema`, `db --list`, `db --schema` | gateway `dictionary` (webdesign's `database`/`table`/`column` routes run server-side; `GatewayDBRead`/`GatewayDBWrite`) | SOAP SQL + `savePuakma*` |
357
408
  | `export` | webdesign `ExportPMX` action | SOAP `downloadPmx` |
358
409
  | `import` | **still SOAP** - webdesign has no import route yet | SOAP `uploadPmx` |
@@ -64,6 +64,18 @@ While it is possible to use without it, this software has been purposefully desi
64
64
  java_environment_name = JavaSE-17 ; Java Execution Environment name https://docs.osgi.org/reference/eenames.html
65
65
  ```
66
66
 
67
+ ## Upgrading to 6.3
68
+
69
+ - **`vortex push` only uploads what changed.** On a `backend = gateway` server the push first
70
+ reads the server's element hashes and skips every file that already matches - a full-app push
71
+ that used to re-send (and journal) hundreds of identical files now sends the few that differ.
72
+ `--all` restores the old push-everything behaviour; `backend = soap` still pushes everything.
73
+ - **Uploads run concurrently** - up to `--jobs N` elements at once (default 8; `-j 1` is the
74
+ old one-at-a-time push). A Java element's source and class still go in that order, and a
75
+ failed source stops that element's class from being uploaded over it.
76
+ - **`push --name` prefers an exact match.** Previously `--name dashboard` also pushed a
77
+ `Dashboard` element; now the case-insensitive match only applies when nothing matches exactly.
78
+
67
79
  ## Upgrading to 6.0
68
80
 
69
81
  6.0 is additive - **the default behaviour is unchanged**. Every server keeps using the SOAP
@@ -119,13 +131,14 @@ For a full list of commands see `--help`.
119
131
  - `log`: View the server log.
120
132
  - `status`: Show the server status - via the agent gateway when available, otherwise the raw console `status` output.
121
133
  - `agenda`: List every scheduled action with its decoded schedule, last and next run, and whether it is overdue (read-only, gateway only).
122
- - `push`: Upload local design files (source, compiled classes, pages, resources) to the server without a watch session - journaled and deploy-confirmed when routed via the gateway.
134
+ - `push`: Upload local design files (source, compiled classes, pages, resources) to the server without a watch session - journaled and deploy-confirmed when routed via the gateway. On a gateway server only files whose content differs from the server's are uploaded (compared by the server's element hashes; `--all` pushes everything), and elements upload concurrently (`--jobs N`, default 8) while each Java element's source always lands before its class. `--name` matches exactly first, case-insensitively only when nothing matches exactly.
123
135
  - `pull`: Refresh cloned applications in place via the gateway's incremental sync - only elements changed since the last sync are downloaded. Refuses to overwrite locally modified files without `--force`.
124
136
  - `render`: Render a PAGE server-side as your identity and print it (or `--out FILE`) - see a change without a browser login (gateway only).
125
137
  - `undo`: The server-side undo journal. No arguments lists journal entries; with an entry id it dry-runs a restore, and `--confirm` writes the journaled version back (gateway only).
126
138
  - `find`: Find Design Objects of cloned applications by name.
127
139
  - `grep`: Search the contents of cloned Design Objects using a Regular Expression.
128
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).
129
142
  - `copy`: Copy a Design Object from one application to another.
130
143
  - `delete`: Delete Design Objects by ID.
131
144
  - `db`: Interact with Database Connections. Accepts `--server`/`-s` like other commands for one-off queries against another server.
@@ -135,6 +148,42 @@ For a full list of commands see `--help`.
135
148
  - `docs`: Open the Tornado Server Blackbook.
136
149
  - `execute`: Execute a command on the server.
137
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
+
138
187
  ### Cloning a whole group
139
188
 
140
189
  `vortex clone` takes an ID, a TemplateName, an application group, or `group/name`:
@@ -309,6 +358,8 @@ needs no change to the gateway.
309
358
  | `copy`, `new object` | webdesign `POST`/`PUT design`, `PUT design/params` | SOAP |
310
359
  | `new app` | webdesign `POST vortex` | SOAP `saveApplication` |
311
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** |
312
363
  | `schema`, `db --list`, `db --schema` | gateway `dictionary` (webdesign's `database`/`table`/`column` routes run server-side; `GatewayDBRead`/`GatewayDBWrite`) | SOAP SQL + `savePuakma*` |
313
364
  | `export` | webdesign `ExportPMX` action | SOAP `downloadPmx` |
314
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.2.1"
8
+ version = "6.4.0"
9
9
  description = "Vortex CLI"
10
10
  requires-python = ">=3.10"
11
11
  readme = { file = "README.md", content-type = "text/markdown" }
@@ -14,6 +14,7 @@ from typing import NamedTuple
14
14
  from vortex.commands.db import DEFAULT_LIMIT_N_RESULTS
15
15
  from vortex.commands.db import MAX_LIMIT_N_RESULTS
16
16
  from vortex.commands.db import MIN_LIMIT_N_RESULTS
17
+ from vortex.commands.push import DEFAULT_JOBS as DEFAULT_PUSH_JOBS
17
18
  from vortex.models import DesignType
18
19
  from vortex.models import PuakmaApplication
19
20
 
@@ -197,6 +198,7 @@ def validate_args(
197
198
  new_parser: ArgumentParser,
198
199
  clone_parser: ArgumentParser,
199
200
  db_parser: ArgumentParser,
201
+ keyword_parser: ArgumentParser,
200
202
  ) -> None:
201
203
  if args.command == "new":
202
204
  msg = None
@@ -264,6 +266,11 @@ def validate_args(
264
266
  clone_parser.error(
265
267
  f"--group expects group names, not {', '.join(not_groups)}"
266
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")
267
274
  elif args.command == "db" and args.sql is not None:
268
275
  try:
269
276
  args.database = int(args.database)
@@ -718,7 +725,31 @@ def add_push_parser(command_parser: _SubParsersAction[ArgumentParser]) -> None:
718
725
  "-n",
719
726
  dest="name_filter",
720
727
  default=None,
721
- help="Only push the design element with this name",
728
+ help=(
729
+ "Only push the design element with this name (an exact match "
730
+ "wins; case-insensitive only when nothing matches exactly)"
731
+ ),
732
+ )
733
+ push_parser.add_argument(
734
+ "--all",
735
+ "-a",
736
+ action="store_true",
737
+ help=(
738
+ "Push every local file, including ones whose content already "
739
+ "matches the server. By default (gateway backend) unchanged "
740
+ "files are skipped using the server's element hashes"
741
+ ),
742
+ )
743
+ push_parser.add_argument(
744
+ "--jobs",
745
+ "-j",
746
+ type=int,
747
+ default=DEFAULT_PUSH_JOBS,
748
+ metavar="N",
749
+ help=(
750
+ "Maximum elements uploaded concurrently "
751
+ f"(default {DEFAULT_PUSH_JOBS}; 1 uploads one at a time)"
752
+ ),
722
753
  )
723
754
  _add_server_option(push_parser)
724
755
 
@@ -981,6 +1012,58 @@ def add_new_parser(
981
1012
  return new_parser
982
1013
 
983
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
+
984
1067
  def add_copy_parser(command_parser: _SubParsersAction[ArgumentParser]) -> None:
985
1068
  copy_parser = command_parser.add_parser(
986
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