flowbase-cli 0.1.0__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.0
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
@@ -23,7 +23,9 @@ export FLOWBASE_API_KEY=fbk_xxx
23
23
 
24
24
  flowbase objects list
25
25
  flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
26
+ flowbase changes commit --message "Add customer field" --yes
27
+ flowbase changes release --message "Release customer field" --yes
26
28
  flowbase docs list
27
29
  ```
28
30
 
29
- `--host` selects the environment through its Host. The CLI never accepts a separate environment override and never stores API keys on disk. See `flowbase --help` and `flowbase docs list` for the complete command set.
31
+ `--host` selects the environment through its Host. Version commands are limited to the current enabled test environment; release can succeed only when its current version meets FlowBase's fast-forward checks. The CLI never accepts a separate environment override and never stores API keys on disk. See `flowbase --help` and `flowbase docs list` for the complete command set.
@@ -0,0 +1,22 @@
1
+ # FlowBase CLI
2
+
3
+ Install from this checkout:
4
+
5
+ ```bash
6
+ python -m pip install ./packages/flowbase-cli
7
+ ```
8
+
9
+ Configure an environment Base URL and an API key:
10
+
11
+ ```bash
12
+ export FLOWBASE_HOST=https://flowbase-test-a.deepflowagent.com
13
+ export FLOWBASE_API_KEY=fbk_xxx
14
+
15
+ flowbase objects list
16
+ flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
17
+ flowbase changes commit --message "Add customer field" --yes
18
+ flowbase changes release --message "Release customer field" --yes
19
+ flowbase docs list
20
+ ```
21
+
22
+ `--host` selects the environment through its Host. Version commands are limited to the current enabled test environment; release can succeed only when its current version meets FlowBase's fast-forward checks. The CLI never accepts a separate environment override and never stores API keys on disk. See `flowbase --help` and `flowbase docs list` for the complete command set.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flowbase-cli"
7
- version = "0.1.0"
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.0"
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,6 +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),
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
+ ),
30
58
  Endpoint("records.list", "flowbase_records_list", "GET", "/objects/{object_id}/records", "records:read", "List records.", "flowbase records list obj_xxx --page 1"),
31
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"),
32
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"),
@@ -35,3 +63,12 @@ ENDPOINTS = (
35
63
  )
36
64
 
37
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
+ )
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import argparse
5
+ from decimal import Decimal
5
6
  import json
6
7
  import os
7
8
  import sys
@@ -11,7 +12,12 @@ from urllib.error import HTTPError, URLError
11
12
  from urllib.parse import quote, urlencode
12
13
  from urllib.request import Request, urlopen
13
14
 
14
- 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
+ )
15
21
 
16
22
  DEFAULT_HOST = "http://flowbase.localhost:5001"
17
23
  API_PREFIX = "/api/openapi/v1"
@@ -49,7 +55,7 @@ def load_json_payload(json_text: str | None, json_file: str | None) -> dict[str,
49
55
  except OSError as exc:
50
56
  raise CliError(f"Cannot read JSON file: {exc}") from exc
51
57
  try:
52
- payload = json.loads(source)
58
+ payload = json.loads(source, parse_float=Decimal)
53
59
  except json.JSONDecodeError as exc:
54
60
  raise CliError(f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}") from exc
55
61
  if not isinstance(payload, dict):
@@ -99,7 +105,7 @@ class OpenApiClient:
99
105
  headers = {"Accept": "application/json", "Authorization": f"Bearer {self.api_key}"}
100
106
  data = None
101
107
  if body is not None:
102
- data = json.dumps(body, ensure_ascii=False).encode("utf-8")
108
+ data = exact_json_dumps(body).encode("utf-8")
103
109
  headers["Content-Type"] = "application/json"
104
110
  request = Request(url, data=data, headers=headers, method=endpoint.method)
105
111
  try:
@@ -127,6 +133,33 @@ def _error_detail(payload: str) -> str:
127
133
  return json.dumps(parsed, ensure_ascii=False)
128
134
 
129
135
 
136
+ def exact_json_dumps(value: Any) -> str:
137
+ if value is None:
138
+ return "null"
139
+ if value is True:
140
+ return "true"
141
+ if value is False:
142
+ return "false"
143
+ if isinstance(value, str):
144
+ return json.dumps(value, ensure_ascii=False)
145
+ if isinstance(value, Decimal):
146
+ if not value.is_finite():
147
+ raise CliError("JSON numbers must be finite.")
148
+ return format(value, "f")
149
+ if isinstance(value, int):
150
+ return str(value)
151
+ if isinstance(value, float):
152
+ raise CliError("Binary floating-point values are not accepted.")
153
+ if isinstance(value, list):
154
+ return "[" + ",".join(exact_json_dumps(item) for item in value) + "]"
155
+ if isinstance(value, dict):
156
+ return "{" + ",".join(
157
+ f"{json.dumps(str(key), ensure_ascii=False)}:{exact_json_dumps(item)}"
158
+ for key, item in value.items()
159
+ ) + "}"
160
+ raise CliError(f"Unsupported JSON value: {type(value).__name__}")
161
+
162
+
130
163
  def print_output(data: bytes, *, pretty: bool, raw: bool) -> None:
131
164
  text = data.decode("utf-8", errors="replace")
132
165
  if raw:
@@ -174,9 +207,42 @@ def run_request(
174
207
 
175
208
 
176
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
177
235
  if args.docs_action == "list":
178
236
  payload = [
179
- {"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
+ }
180
246
  for e in ENDPOINTS
181
247
  ]
182
248
  print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
@@ -190,6 +256,9 @@ def handle_docs(args: argparse.Namespace) -> int:
190
256
  "scope": endpoint.scope,
191
257
  "summary": endpoint.summary,
192
258
  "requiresConfirmation": endpoint.requires_confirmation,
259
+ "environments": list(endpoint.environments),
260
+ "preflightOperationId": endpoint.preflight_operation_id,
261
+ "auditEvent": endpoint.audit_event,
193
262
  "example": endpoint.example,
194
263
  }
195
264
  print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
@@ -240,6 +309,49 @@ def handle_fields(args: argparse.Namespace) -> int:
240
309
  return run_request(args, f"fields.{action}", path_params=path)
241
310
 
242
311
 
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
336
+ return run_request(args, f"changes.{args.changes_action}", body={"message": args.message})
337
+
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
+
243
355
  def handle_records(args: argparse.Namespace) -> int:
244
356
  action = args.records_action
245
357
  path = {"object_id": args.object_id}
@@ -285,6 +397,7 @@ def build_parser() -> argparse.ArgumentParser:
285
397
  docs = groups.add_parser("docs", help="Inspect the offline endpoint catalog.")
286
398
  docs_actions = docs.add_subparsers(dest="docs_action", required=True)
287
399
  docs_actions.add_parser("list")
400
+ docs_actions.add_parser("operations")
288
401
  docs_show = docs_actions.add_parser("show")
289
402
  docs_show.add_argument("endpoint", choices=sorted(ENDPOINT_BY_KEY))
290
403
  docs.set_defaults(handler=handle_docs)
@@ -322,6 +435,14 @@ def build_parser() -> argparse.ArgumentParser:
322
435
  add_yes(item)
323
436
  fields.set_defaults(handler=handle_fields)
324
437
 
438
+ changes = groups.add_parser("changes", help="Commit or release metadata versions.")
439
+ change_actions = changes.add_subparsers(dest="changes_action", required=True)
440
+ for action in ("commit", "release", "direct-release"):
441
+ item = change_actions.add_parser(action)
442
+ item.add_argument("--message", required=True)
443
+ add_yes(item)
444
+ changes.set_defaults(handler=handle_changes)
445
+
325
446
  records = groups.add_parser("records", help="Manage business records.")
326
447
  record_actions = records.add_subparsers(dest="records_action", required=True)
327
448
  record_list = record_actions.add_parser("list")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowbase-cli
3
- Version: 0.1.0
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
@@ -23,7 +23,9 @@ export FLOWBASE_API_KEY=fbk_xxx
23
23
 
24
24
  flowbase objects list
25
25
  flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
26
+ flowbase changes commit --message "Add customer field" --yes
27
+ flowbase changes release --message "Release customer field" --yes
26
28
  flowbase docs list
27
29
  ```
28
30
 
29
- `--host` selects the environment through its Host. The CLI never accepts a separate environment override and never stores API keys on disk. See `flowbase --help` and `flowbase docs list` for the complete command set.
31
+ `--host` selects the environment through its Host. Version commands are limited to the current enabled test environment; release can succeed only when its current version meets FlowBase's fast-forward checks. The CLI never accepts a separate environment override and never stores API keys on disk. See `flowbase --help` and `flowbase docs list` for the complete command set.
@@ -3,12 +3,20 @@ from __future__ import annotations
3
3
  import io
4
4
  import json
5
5
  import unittest
6
+ from decimal import Decimal
6
7
  from pathlib import Path
7
8
  from tempfile import TemporaryDirectory
8
9
  from unittest.mock import patch
9
10
 
10
11
  from flowbase_cli.endpoints import ENDPOINTS
11
- from flowbase_cli.main import CliError, OpenApiClient, api_root, load_json_payload, main
12
+ from flowbase_cli.main import (
13
+ CliError,
14
+ OpenApiClient,
15
+ api_root,
16
+ exact_json_dumps,
17
+ load_json_payload,
18
+ main,
19
+ )
12
20
 
13
21
 
14
22
  class FlowBaseCliTest(unittest.TestCase):
@@ -33,6 +41,18 @@ class FlowBaseCliTest(unittest.TestCase):
33
41
  path.write_text('{"label":"客户"}', encoding="utf-8")
34
42
  self.assertEqual(load_json_payload(None, str(path)), {"label": "客户"})
35
43
 
44
+ def test_decimal_payload_preserves_json_number_text(self):
45
+ payload = load_json_payload(
46
+ '{"values":{"amount":12345678901234.5678}}', None
47
+ )
48
+ self.assertEqual(
49
+ payload["values"]["amount"], Decimal("12345678901234.5678")
50
+ )
51
+ self.assertEqual(
52
+ exact_json_dumps(payload),
53
+ '{"values":{"amount":12345678901234.5678}}',
54
+ )
55
+
36
56
  def test_missing_key_is_rejected_before_network(self):
37
57
  client = OpenApiClient("https://example.com", None)
38
58
  with patch("flowbase_cli.main.urlopen") as mocked:
@@ -53,6 +73,16 @@ class FlowBaseCliTest(unittest.TestCase):
53
73
  self.assertEqual(code, 0)
54
74
  payload = json.loads(stdout.getvalue())
55
75
  self.assertTrue(any(item["key"] == "records.create" for item in payload))
76
+ self.assertTrue(any(item["key"] == "changes.release" for item in payload))
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))
56
86
 
57
87
  def test_object_create_requires_business_description(self):
58
88
  stderr = io.StringIO()
@@ -68,6 +98,59 @@ class FlowBaseCliTest(unittest.TestCase):
68
98
  self.assertEqual(code, 1)
69
99
  self.assertIn("without --yes", stderr.getvalue())
70
100
 
101
+ def test_noninteractive_commit_requires_yes(self):
102
+ stderr = io.StringIO()
103
+ with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
104
+ code = main(["--api-key", "fbk_test", "changes", "commit", "--message", "Add customer field"])
105
+ self.assertEqual(code, 1)
106
+ self.assertIn("without --yes", stderr.getvalue())
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
+
71
154
 
72
155
  if __name__ == "__main__":
73
156
  unittest.main()
@@ -1,20 +0,0 @@
1
- # FlowBase CLI
2
-
3
- Install from this checkout:
4
-
5
- ```bash
6
- python -m pip install ./packages/flowbase-cli
7
- ```
8
-
9
- Configure an environment Base URL and an API key:
10
-
11
- ```bash
12
- export FLOWBASE_HOST=https://flowbase-test-a.deepflowagent.com
13
- export FLOWBASE_API_KEY=fbk_xxx
14
-
15
- flowbase objects list
16
- flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
17
- flowbase docs list
18
- ```
19
-
20
- `--host` selects the environment through its Host. The CLI never accepts a separate environment override and never stores API keys on disk. See `flowbase --help` and `flowbase docs list` for the complete command set.
File without changes