flowbase-cli 0.1.1__tar.gz → 0.1.2__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.
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/PKG-INFO +1 -1
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/pyproject.toml +1 -1
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/src/flowbase_cli/__init__.py +1 -1
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/src/flowbase_cli/endpoints.py +37 -2
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/src/flowbase_cli/main.py +85 -4
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/src/flowbase_cli.egg-info/PKG-INFO +1 -1
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/tests/test_cli.py +55 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/README.md +0 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/setup.cfg +0 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/src/flowbase_cli.egg-info/SOURCES.txt +0 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/src/flowbase_cli.egg-info/dependency_links.txt +0 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/src/flowbase_cli.egg-info/entry_points.txt +0 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.2}/src/flowbase_cli.egg-info/top_level.txt +0 -0
|
@@ -14,6 +14,9 @@ class Endpoint:
|
|
|
14
14
|
summary: str
|
|
15
15
|
example: str
|
|
16
16
|
requires_confirmation: bool = False
|
|
17
|
+
environments: tuple[str, ...] = ("production", "test-a", "test-b")
|
|
18
|
+
preflight_operation_id: str | None = None
|
|
19
|
+
audit_event: str | None = None
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
ENDPOINTS = (
|
|
@@ -27,8 +30,31 @@ ENDPOINTS = (
|
|
|
27
30
|
Endpoint("fields.update", "flowbase_fields_update", "PUT", "/objects/{object_id}/fields/{field_id}", "metadata:write", "Update a field draft.", "flowbase fields update obj_xxx fld_xxx --label Name --description 'Customer name'"),
|
|
28
31
|
Endpoint("fields.delete", "flowbase_fields_delete", "DELETE", "/objects/{object_id}/fields/{field_id}", "metadata:write", "Logically delete a field.", "flowbase fields delete obj_xxx fld_xxx --yes", True),
|
|
29
32
|
Endpoint("fields.restore", "flowbase_fields_restore", "POST", "/objects/{object_id}/fields/{field_id}/restore", "metadata:write", "Restore a field.", "flowbase fields restore obj_xxx fld_xxx --yes", True),
|
|
30
|
-
Endpoint("changes.commit", "flowbase_changes_commit", "POST", "/changes/commit", "metadata:write", "Commit current test-environment drafts.", "flowbase changes commit --message 'Add customer field' --yes", True),
|
|
31
|
-
Endpoint("changes.release", "flowbase_changes_release", "POST", "/changes/release", "metadata:write", "Release the current test-environment version.", "flowbase changes release --message 'Release customer field' --yes", True),
|
|
33
|
+
Endpoint("changes.commit", "flowbase_changes_commit", "POST", "/changes/commit", "metadata:write", "Commit current test-environment drafts.", "flowbase changes commit --message 'Add customer field' --yes", True, environments=("test-a", "test-b"), audit_event="version.commit"),
|
|
34
|
+
Endpoint("changes.release", "flowbase_changes_release", "POST", "/changes/release", "metadata:write", "Release the current test-environment version.", "flowbase changes release --message 'Release customer field' --yes", True, environments=("test-a", "test-b"), audit_event="version.release"),
|
|
35
|
+
Endpoint(
|
|
36
|
+
"changes.direct-release-preflight",
|
|
37
|
+
"flowbase_changes_direct_release_preflight",
|
|
38
|
+
"POST",
|
|
39
|
+
"/changes/direct-release/preflight",
|
|
40
|
+
"metadata:write",
|
|
41
|
+
"Preflight a production direct release.",
|
|
42
|
+
"flowbase changes direct-release --message 'Fix production field' --yes",
|
|
43
|
+
environments=("production",),
|
|
44
|
+
),
|
|
45
|
+
Endpoint(
|
|
46
|
+
"changes.direct-release",
|
|
47
|
+
"flowbase_changes_direct_release",
|
|
48
|
+
"POST",
|
|
49
|
+
"/changes/direct-release",
|
|
50
|
+
"metadata:write",
|
|
51
|
+
"Directly release production drafts.",
|
|
52
|
+
"flowbase changes direct-release --message 'Fix production field' --yes",
|
|
53
|
+
requires_confirmation=True,
|
|
54
|
+
environments=("production",),
|
|
55
|
+
preflight_operation_id="flowbase_changes_direct_release_preflight",
|
|
56
|
+
audit_event="version.forward-fix",
|
|
57
|
+
),
|
|
32
58
|
Endpoint("records.list", "flowbase_records_list", "GET", "/objects/{object_id}/records", "records:read", "List records.", "flowbase records list obj_xxx --page 1"),
|
|
33
59
|
Endpoint("records.get", "flowbase_records_get", "GET", "/objects/{object_id}/records/{record_id}", "records:read", "Get a record.", "flowbase records get obj_xxx rec_xxx"),
|
|
34
60
|
Endpoint("records.create", "flowbase_records_create", "POST", "/objects/{object_id}/records", "records:write", "Create a record.", "flowbase records create obj_xxx --json-file record.json"),
|
|
@@ -37,3 +63,12 @@ ENDPOINTS = (
|
|
|
37
63
|
)
|
|
38
64
|
|
|
39
65
|
ENDPOINT_BY_KEY = {endpoint.key: endpoint for endpoint in ENDPOINTS}
|
|
66
|
+
|
|
67
|
+
PENDING_MANAGEMENT_OPERATIONS = (
|
|
68
|
+
{"key": "environments.enable", "status": "pending", "reason": "Requires an independent capability and confirmation contract."},
|
|
69
|
+
{"key": "environments.restart", "status": "pending", "reason": "Requires destructive data-reset confirmation."},
|
|
70
|
+
{"key": "changes.clear-drafts", "status": "pending", "reason": "Requires an independent capability and confirmation contract."},
|
|
71
|
+
{"key": "api-keys.manage", "status": "pending", "reason": "API keys cannot delegate or expand their own authority."},
|
|
72
|
+
{"key": "git-credentials.manage", "status": "pending", "reason": "Requires a dedicated credential-management capability."},
|
|
73
|
+
{"key": "metadata.purge", "status": "pending", "reason": "Physical purge is not delivered."},
|
|
74
|
+
)
|
|
@@ -12,7 +12,12 @@ from urllib.error import HTTPError, URLError
|
|
|
12
12
|
from urllib.parse import quote, urlencode
|
|
13
13
|
from urllib.request import Request, urlopen
|
|
14
14
|
|
|
15
|
-
from flowbase_cli.endpoints import
|
|
15
|
+
from flowbase_cli.endpoints import (
|
|
16
|
+
ENDPOINTS,
|
|
17
|
+
ENDPOINT_BY_KEY,
|
|
18
|
+
PENDING_MANAGEMENT_OPERATIONS,
|
|
19
|
+
Endpoint,
|
|
20
|
+
)
|
|
16
21
|
|
|
17
22
|
DEFAULT_HOST = "http://flowbase.localhost:5001"
|
|
18
23
|
API_PREFIX = "/api/openapi/v1"
|
|
@@ -202,9 +207,42 @@ def run_request(
|
|
|
202
207
|
|
|
203
208
|
|
|
204
209
|
def handle_docs(args: argparse.Namespace) -> int:
|
|
210
|
+
if args.docs_action == "operations":
|
|
211
|
+
delivered = [
|
|
212
|
+
{
|
|
213
|
+
"key": endpoint.key,
|
|
214
|
+
"status": "delivered",
|
|
215
|
+
"operationId": endpoint.operation_id,
|
|
216
|
+
"method": endpoint.method,
|
|
217
|
+
"path": endpoint.path,
|
|
218
|
+
"scope": endpoint.scope,
|
|
219
|
+
"environments": list(endpoint.environments),
|
|
220
|
+
"requiresPreflight": endpoint.preflight_operation_id is not None,
|
|
221
|
+
"requiresConfirmation": endpoint.requires_confirmation,
|
|
222
|
+
"auditEvent": endpoint.audit_event,
|
|
223
|
+
}
|
|
224
|
+
for endpoint in ENDPOINTS
|
|
225
|
+
if endpoint.scope.startswith("metadata:")
|
|
226
|
+
]
|
|
227
|
+
print(
|
|
228
|
+
json.dumps(
|
|
229
|
+
delivered + list(PENDING_MANAGEMENT_OPERATIONS),
|
|
230
|
+
ensure_ascii=False,
|
|
231
|
+
indent=2 if args.pretty else None,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
return 0
|
|
205
235
|
if args.docs_action == "list":
|
|
206
236
|
payload = [
|
|
207
|
-
{
|
|
237
|
+
{
|
|
238
|
+
"key": e.key,
|
|
239
|
+
"operationId": e.operation_id,
|
|
240
|
+
"method": e.method,
|
|
241
|
+
"path": e.path,
|
|
242
|
+
"scope": e.scope,
|
|
243
|
+
"environments": list(e.environments),
|
|
244
|
+
"summary": e.summary,
|
|
245
|
+
}
|
|
208
246
|
for e in ENDPOINTS
|
|
209
247
|
]
|
|
210
248
|
print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
|
|
@@ -218,6 +256,9 @@ def handle_docs(args: argparse.Namespace) -> int:
|
|
|
218
256
|
"scope": endpoint.scope,
|
|
219
257
|
"summary": endpoint.summary,
|
|
220
258
|
"requiresConfirmation": endpoint.requires_confirmation,
|
|
259
|
+
"environments": list(endpoint.environments),
|
|
260
|
+
"preflightOperationId": endpoint.preflight_operation_id,
|
|
261
|
+
"auditEvent": endpoint.audit_event,
|
|
221
262
|
"example": endpoint.example,
|
|
222
263
|
}
|
|
223
264
|
print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
|
|
@@ -269,9 +310,48 @@ def handle_fields(args: argparse.Namespace) -> int:
|
|
|
269
310
|
|
|
270
311
|
|
|
271
312
|
def handle_changes(args: argparse.Namespace) -> int:
|
|
313
|
+
if args.changes_action == "direct-release":
|
|
314
|
+
client = client_from_args(args)
|
|
315
|
+
body = {"message": args.message}
|
|
316
|
+
preflight_data = client.request(
|
|
317
|
+
"changes.direct-release-preflight",
|
|
318
|
+
body=body,
|
|
319
|
+
)
|
|
320
|
+
try:
|
|
321
|
+
preflight = json.loads(preflight_data)
|
|
322
|
+
except json.JSONDecodeError as exc:
|
|
323
|
+
raise CliError("Direct-release preflight returned invalid JSON.") from exc
|
|
324
|
+
token = preflight.get("confirmationToken")
|
|
325
|
+
if not isinstance(token, str):
|
|
326
|
+
raise CliError("Direct-release preflight did not return a confirmation token.")
|
|
327
|
+
_print_direct_release_preflight(preflight)
|
|
328
|
+
endpoint = get_endpoint("changes.direct-release")
|
|
329
|
+
confirm_change(endpoint, args.yes)
|
|
330
|
+
data = client.request(
|
|
331
|
+
"changes.direct-release",
|
|
332
|
+
body={"message": args.message, "confirmationToken": token},
|
|
333
|
+
)
|
|
334
|
+
print_output(data, pretty=args.pretty, raw=args.raw)
|
|
335
|
+
return 0
|
|
272
336
|
return run_request(args, f"changes.{args.changes_action}", body={"message": args.message})
|
|
273
337
|
|
|
274
338
|
|
|
339
|
+
def _print_direct_release_preflight(preflight: dict[str, Any]) -> None:
|
|
340
|
+
print("Production direct-release preflight:", file=sys.stderr)
|
|
341
|
+
print(f" Current main: {preflight.get('mainCommit', 'unknown')}", file=sys.stderr)
|
|
342
|
+
print(f" Draft revision: {preflight.get('draftRevision', 'unknown')}", file=sys.stderr)
|
|
343
|
+
print(
|
|
344
|
+
f" Changes: {preflight.get('semanticCount', 0)} semantic, "
|
|
345
|
+
f"{preflight.get('fileCount', 0)} files",
|
|
346
|
+
file=sys.stderr,
|
|
347
|
+
)
|
|
348
|
+
for check in preflight.get("checks", []):
|
|
349
|
+
if isinstance(check, dict):
|
|
350
|
+
mark = "OK" if check.get("ok") else "FAIL"
|
|
351
|
+
print(f" [{mark}] {check.get('label', '')}", file=sys.stderr)
|
|
352
|
+
print(f" RISK: {preflight.get('risk', 'This bypasses test environments.')}", file=sys.stderr)
|
|
353
|
+
|
|
354
|
+
|
|
275
355
|
def handle_records(args: argparse.Namespace) -> int:
|
|
276
356
|
action = args.records_action
|
|
277
357
|
path = {"object_id": args.object_id}
|
|
@@ -317,6 +397,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
317
397
|
docs = groups.add_parser("docs", help="Inspect the offline endpoint catalog.")
|
|
318
398
|
docs_actions = docs.add_subparsers(dest="docs_action", required=True)
|
|
319
399
|
docs_actions.add_parser("list")
|
|
400
|
+
docs_actions.add_parser("operations")
|
|
320
401
|
docs_show = docs_actions.add_parser("show")
|
|
321
402
|
docs_show.add_argument("endpoint", choices=sorted(ENDPOINT_BY_KEY))
|
|
322
403
|
docs.set_defaults(handler=handle_docs)
|
|
@@ -354,9 +435,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
354
435
|
add_yes(item)
|
|
355
436
|
fields.set_defaults(handler=handle_fields)
|
|
356
437
|
|
|
357
|
-
changes = groups.add_parser("changes", help="Commit or release
|
|
438
|
+
changes = groups.add_parser("changes", help="Commit or release metadata versions.")
|
|
358
439
|
change_actions = changes.add_subparsers(dest="changes_action", required=True)
|
|
359
|
-
for action in ("commit", "release"):
|
|
440
|
+
for action in ("commit", "release", "direct-release"):
|
|
360
441
|
item = change_actions.add_parser(action)
|
|
361
442
|
item.add_argument("--message", required=True)
|
|
362
443
|
add_yes(item)
|
|
@@ -75,6 +75,15 @@ class FlowBaseCliTest(unittest.TestCase):
|
|
|
75
75
|
self.assertTrue(any(item["key"] == "records.create" for item in payload))
|
|
76
76
|
self.assertTrue(any(item["key"] == "changes.release" for item in payload))
|
|
77
77
|
|
|
78
|
+
def test_management_operation_catalog_identifies_pending_gaps(self):
|
|
79
|
+
stdout = io.StringIO()
|
|
80
|
+
with patch("sys.stdout", stdout):
|
|
81
|
+
code = main(["docs", "operations"])
|
|
82
|
+
self.assertEqual(code, 0)
|
|
83
|
+
payload = json.loads(stdout.getvalue())
|
|
84
|
+
self.assertTrue(any(item["key"] == "changes.direct-release" and item["status"] == "delivered" for item in payload))
|
|
85
|
+
self.assertTrue(any(item["key"] == "changes.clear-drafts" and item["status"] == "pending" for item in payload))
|
|
86
|
+
|
|
78
87
|
def test_object_create_requires_business_description(self):
|
|
79
88
|
stderr = io.StringIO()
|
|
80
89
|
with patch("sys.stderr", stderr):
|
|
@@ -96,6 +105,52 @@ class FlowBaseCliTest(unittest.TestCase):
|
|
|
96
105
|
self.assertEqual(code, 1)
|
|
97
106
|
self.assertIn("without --yes", stderr.getvalue())
|
|
98
107
|
|
|
108
|
+
def test_direct_release_always_preflights_before_execution(self):
|
|
109
|
+
preflight = {
|
|
110
|
+
"confirmationToken": "fbc_secret",
|
|
111
|
+
"mainCommit": "abc1234",
|
|
112
|
+
"draftRevision": 4,
|
|
113
|
+
"semanticCount": 2,
|
|
114
|
+
"fileCount": 1,
|
|
115
|
+
"checks": [{"label": "Configuration valid", "ok": True}],
|
|
116
|
+
"risk": "Bypasses test environments.",
|
|
117
|
+
}
|
|
118
|
+
stdout = io.StringIO()
|
|
119
|
+
stderr = io.StringIO()
|
|
120
|
+
with (
|
|
121
|
+
patch(
|
|
122
|
+
"flowbase_cli.main.OpenApiClient.request",
|
|
123
|
+
side_effect=[
|
|
124
|
+
json.dumps(preflight).encode(),
|
|
125
|
+
b'{"mainCommit":"def5678"}',
|
|
126
|
+
],
|
|
127
|
+
) as request,
|
|
128
|
+
patch("sys.stdout", stdout),
|
|
129
|
+
patch("sys.stderr", stderr),
|
|
130
|
+
):
|
|
131
|
+
code = main([
|
|
132
|
+
"--api-key",
|
|
133
|
+
"fbk_test",
|
|
134
|
+
"changes",
|
|
135
|
+
"direct-release",
|
|
136
|
+
"--message",
|
|
137
|
+
"Production fix",
|
|
138
|
+
"--yes",
|
|
139
|
+
])
|
|
140
|
+
self.assertEqual(code, 0)
|
|
141
|
+
self.assertEqual(request.call_count, 2)
|
|
142
|
+
self.assertEqual(
|
|
143
|
+
request.call_args_list[0].args[0],
|
|
144
|
+
"changes.direct-release-preflight",
|
|
145
|
+
)
|
|
146
|
+
self.assertEqual(
|
|
147
|
+
request.call_args_list[1].kwargs["body"]["confirmationToken"],
|
|
148
|
+
"fbc_secret",
|
|
149
|
+
)
|
|
150
|
+
self.assertNotIn("fbc_secret", stdout.getvalue())
|
|
151
|
+
self.assertNotIn("fbc_secret", stderr.getvalue())
|
|
152
|
+
self.assertIn("Bypasses test environments", stderr.getvalue())
|
|
153
|
+
|
|
99
154
|
|
|
100
155
|
if __name__ == "__main__":
|
|
101
156
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|