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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowbase-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Command line client for the FlowBase OpenAPI
5
5
  Author: DeepFlowAI
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flowbase-cli"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Command line client for the FlowBase OpenAPI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """FlowBase OpenAPI CLI."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.2"
@@ -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 ENDPOINTS, ENDPOINT_BY_KEY, Endpoint
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
- {"key": e.key, "operationId": e.operation_id, "method": e.method, "path": e.path, "scope": e.scope, "summary": e.summary}
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 eligible test-environment metadata versions.")
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowbase-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Command line client for the FlowBase OpenAPI
5
5
  Author: DeepFlowAI
6
6
  License: Apache-2.0
@@ -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