flowbase-cli 0.1.2__tar.gz → 0.1.4__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.
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: flowbase-cli
3
+ Version: 0.1.4
4
+ Summary: Command line client for the FlowBase OpenAPI
5
+ Author: DeepFlowAI
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+
10
+ # FlowBase CLI
11
+
12
+ Install from this checkout:
13
+
14
+ ```bash
15
+ python -m pip install ./packages/flowbase-cli
16
+ ```
17
+
18
+ Configure an environment Base URL and an API key:
19
+
20
+ ```bash
21
+ export FLOWBASE_HOST=https://flowbase-test-a.deepflowagent.com
22
+ export FLOWBASE_API_KEY=fbk_xxx
23
+
24
+ flowbase objects list
25
+ flowbase views list obj_xxx
26
+ flowbase views create obj_xxx --json-file view.json
27
+ flowbase pages list --object-id obj_xxx
28
+ flowbase pages create --json-file page.json
29
+ flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
30
+ flowbase versions list --resource customer --change-type business-change
31
+ flowbase versions show abc1234
32
+ flowbase versions releases
33
+ flowbase changes commit --message "Add customer field" --yes
34
+ flowbase changes release --message "Release customer field" --yes
35
+ flowbase environments restart --confirm "A 测试环境" --yes
36
+ flowbase docs list
37
+ ```
38
+
39
+ `--host` selects the environment through its Host. Version history commands are read-only and limited to the history visible from that Host; production release records are available only on the production Host. A test release can succeed only when its current version meets FlowBase's fast-forward checks. `environments restart` first shows a destructive preflight and then clears the current test configuration and records before starting from the latest production version. 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,30 @@
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 views list obj_xxx
17
+ flowbase views create obj_xxx --json-file view.json
18
+ flowbase pages list --object-id obj_xxx
19
+ flowbase pages create --json-file page.json
20
+ flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
21
+ flowbase versions list --resource customer --change-type business-change
22
+ flowbase versions show abc1234
23
+ flowbase versions releases
24
+ flowbase changes commit --message "Add customer field" --yes
25
+ flowbase changes release --message "Release customer field" --yes
26
+ flowbase environments restart --confirm "A 测试环境" --yes
27
+ flowbase docs list
28
+ ```
29
+
30
+ `--host` selects the environment through its Host. Version history commands are read-only and limited to the history visible from that Host; production release records are available only on the production Host. A test release can succeed only when its current version meets FlowBase's fast-forward checks. `environments restart` first shows a destructive preflight and then clears the current test configuration and records before starting from the latest production version. 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.2"
7
+ version = "0.1.4"
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.2"
3
+ __version__ = "0.1.4"
@@ -30,6 +30,23 @@ ENDPOINTS = (
30
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'"),
31
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),
32
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("views.list", "flowbase_views_list", "GET", "/objects/{object_id}/views", "metadata:read", "List object view metadata.", "flowbase views list obj_xxx"),
34
+ Endpoint("views.create", "flowbase_views_create", "POST", "/objects/{object_id}/views", "metadata:write", "Create an object view draft.", "flowbase views create obj_xxx --json-file view.json"),
35
+ Endpoint("views.update", "flowbase_views_update", "PUT", "/objects/{object_id}/views/{view_id}", "metadata:write", "Update an object view draft.", "flowbase views update obj_xxx viw_xxx --json-file view.json"),
36
+ Endpoint("views.reorder", "flowbase_views_reorder", "PUT", "/objects/{object_id}/views/order", "metadata:write", "Reorder object views.", "flowbase views reorder obj_xxx --json '{\"baseRevision\":4,\"viewIds\":[\"viw_xxx\"]}'"),
37
+ Endpoint("views.set-enabled", "flowbase_views_set_enabled", "POST", "/objects/{object_id}/views/{view_id}/enabled", "metadata:write", "Enable or disable an object view.", "flowbase views set-enabled obj_xxx viw_xxx --base-revision 4 --enabled --yes", True),
38
+ Endpoint("views.delete", "flowbase_views_delete", "DELETE", "/objects/{object_id}/views/{view_id}", "metadata:write", "Logically delete an object view.", "flowbase views delete obj_xxx viw_xxx --base-revision 4 --yes", True),
39
+ Endpoint("pages.list", "flowbase_pages_list", "GET", "/pages", "metadata:read", "List custom Page metadata.", "flowbase pages list --object-id obj_xxx"),
40
+ Endpoint("pages.options", "flowbase_pages_options", "GET", "/pages/options", "metadata:read", "Get custom Page design options.", "flowbase pages options"),
41
+ Endpoint("pages.get", "flowbase_pages_get", "GET", "/pages/{page_id}", "metadata:read", "Get a custom Page.", "flowbase pages get pag_xxx"),
42
+ Endpoint("pages.create", "flowbase_pages_create", "POST", "/pages", "metadata:write", "Create a custom Page draft.", "flowbase pages create --json-file page.json"),
43
+ Endpoint("pages.update", "flowbase_pages_update", "PUT", "/pages/{page_id}", "metadata:write", "Update a custom Page draft.", "flowbase pages update pag_xxx --json-file page.json"),
44
+ Endpoint("pages.set-default", "flowbase_pages_set_default", "POST", "/pages/{page_id}/set-default", "metadata:write", "Set a default custom Page.", "flowbase pages set-default pag_xxx --base-revision 4 --yes", True),
45
+ Endpoint("pages.delete", "flowbase_pages_delete", "POST", "/pages/{page_id}/delete", "metadata:write", "Logically delete a custom Page.", "flowbase pages delete pag_xxx --base-revision 4 --yes", True),
46
+ Endpoint("pages.restore", "flowbase_pages_restore", "POST", "/pages/{page_id}/restore", "metadata:write", "Restore a custom Page.", "flowbase pages restore pag_xxx --base-revision 4 --yes", True),
47
+ Endpoint("versions.list", "flowbase_versions_list", "GET", "/versions", "metadata:read", "List visible configuration versions.", "flowbase versions list"),
48
+ Endpoint("versions.get", "flowbase_versions_get", "GET", "/versions/{version_id}", "metadata:read", "Get a version with semantic and file diffs.", "flowbase versions show abc1234"),
49
+ Endpoint("releases.list", "flowbase_releases_list", "GET", "/releases", "metadata:read", "List production release records.", "flowbase versions releases", environments=("production",)),
33
50
  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
51
  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
52
  Endpoint(
@@ -55,6 +72,29 @@ ENDPOINTS = (
55
72
  preflight_operation_id="flowbase_changes_direct_release_preflight",
56
73
  audit_event="version.forward-fix",
57
74
  ),
75
+ Endpoint(
76
+ "environments.restart-preflight",
77
+ "flowbase_environments_restart_preflight",
78
+ "GET",
79
+ "/environments/restart/preflight",
80
+ "metadata:write",
81
+ "Preflight restarting the current test environment from production.",
82
+ "flowbase environments restart --confirm 'A 测试环境' --yes",
83
+ environments=("test-a", "test-b"),
84
+ ),
85
+ Endpoint(
86
+ "environments.restart",
87
+ "flowbase_environments_restart",
88
+ "POST",
89
+ "/environments/restart",
90
+ "metadata:write",
91
+ "Clear and restart the current test environment from production.",
92
+ "flowbase environments restart --confirm 'A 测试环境' --yes",
93
+ requires_confirmation=True,
94
+ environments=("test-a", "test-b"),
95
+ preflight_operation_id="flowbase_environments_restart_preflight",
96
+ audit_event="version.base-update",
97
+ ),
58
98
  Endpoint("records.list", "flowbase_records_list", "GET", "/objects/{object_id}/records", "records:read", "List records.", "flowbase records list obj_xxx --page 1"),
59
99
  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"),
60
100
  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"),
@@ -66,7 +106,6 @@ ENDPOINT_BY_KEY = {endpoint.key: endpoint for endpoint in ENDPOINTS}
66
106
 
67
107
  PENDING_MANAGEMENT_OPERATIONS = (
68
108
  {"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
109
  {"key": "changes.clear-drafts", "status": "pending", "reason": "Requires an independent capability and confirmation contract."},
71
110
  {"key": "api-keys.manage", "status": "pending", "reason": "API keys cannot delegate or expand their own authority."},
72
111
  {"key": "git-credentials.manage", "status": "pending", "reason": "Requires a dedicated credential-management capability."},
@@ -102,7 +102,11 @@ class OpenApiClient:
102
102
  if not self.api_key:
103
103
  raise CliError(f"Missing API key. Set {ENV_API_KEY} or pass --api-key.")
104
104
  url = self.build_url(endpoint, path_params, query)
105
- headers = {"Accept": "application/json", "Authorization": f"Bearer {self.api_key}"}
105
+ headers = {
106
+ "Accept": "application/json",
107
+ "Authorization": f"Bearer {self.api_key}",
108
+ "X-FlowBase-Channel": "cli",
109
+ }
106
110
  data = None
107
111
  if body is not None:
108
112
  data = exact_json_dumps(body).encode("utf-8")
@@ -309,6 +313,76 @@ def handle_fields(args: argparse.Namespace) -> int:
309
313
  return run_request(args, f"fields.{action}", path_params=path)
310
314
 
311
315
 
316
+ def handle_views(args: argparse.Namespace) -> int:
317
+ action = args.views_action
318
+ path = {"object_id": args.object_id}
319
+ if hasattr(args, "view_id"):
320
+ path["view_id"] = args.view_id
321
+ if action == "list":
322
+ return run_request(args, "views.list", path_params=path)
323
+ if action in ("create", "update"):
324
+ body = load_json_payload(args.json, args.json_file)
325
+ require_fields(body, "baseRevision", "name", "defaultFieldIds")
326
+ if not isinstance(body["defaultFieldIds"], list):
327
+ raise CliError("Request field 'defaultFieldIds' must be an array.")
328
+ return run_request(args, f"views.{action}", path_params=path, body=body)
329
+ if action == "reorder":
330
+ body = load_json_payload(args.json, args.json_file)
331
+ require_fields(body, "baseRevision", "viewIds")
332
+ if not isinstance(body["viewIds"], list):
333
+ raise CliError("Request field 'viewIds' must be an array.")
334
+ return run_request(args, "views.reorder", path_params=path, body=body)
335
+ if action == "set-enabled":
336
+ body = {
337
+ "baseRevision": args.base_revision,
338
+ "enabled": args.enabled,
339
+ }
340
+ return run_request(args, "views.set-enabled", path_params=path, body=body)
341
+ return run_request(
342
+ args,
343
+ "views.delete",
344
+ path_params=path,
345
+ body={"baseRevision": args.base_revision},
346
+ )
347
+
348
+
349
+ def handle_pages(args: argparse.Namespace) -> int:
350
+ action = args.pages_action
351
+ if action == "list":
352
+ return run_request(
353
+ args,
354
+ "pages.list",
355
+ query={
356
+ "status": args.status,
357
+ "objectId": args.object_id,
358
+ "type": args.type,
359
+ "keyword": args.keyword,
360
+ "page": args.page,
361
+ },
362
+ )
363
+ if action == "options":
364
+ return run_request(args, "pages.options")
365
+ if action == "get":
366
+ return run_request(args, "pages.get", path_params={"page_id": args.page_id})
367
+ if action in ("create", "update"):
368
+ body = load_json_payload(args.json, args.json_file)
369
+ require_fields(body, "baseRevision", "page")
370
+ if not isinstance(body["page"], dict):
371
+ raise CliError("Request field 'page' must be an object.")
372
+ return run_request(
373
+ args,
374
+ f"pages.{action}",
375
+ path_params={"page_id": args.page_id} if action == "update" else None,
376
+ body=body,
377
+ )
378
+ return run_request(
379
+ args,
380
+ f"pages.{action}",
381
+ path_params={"page_id": args.page_id},
382
+ body={"baseRevision": args.base_revision},
383
+ )
384
+
385
+
312
386
  def handle_changes(args: argparse.Namespace) -> int:
313
387
  if args.changes_action == "direct-release":
314
388
  client = client_from_args(args)
@@ -336,6 +410,28 @@ def handle_changes(args: argparse.Namespace) -> int:
336
410
  return run_request(args, f"changes.{args.changes_action}", body={"message": args.message})
337
411
 
338
412
 
413
+ def handle_versions(args: argparse.Namespace) -> int:
414
+ if args.versions_action == "show":
415
+ return run_request(
416
+ args,
417
+ "versions.get",
418
+ path_params={"version_id": args.version_id},
419
+ )
420
+ if args.versions_action == "releases":
421
+ return run_request(args, "releases.list")
422
+ return run_request(
423
+ args,
424
+ "versions.list",
425
+ query={
426
+ "q": args.query,
427
+ "resource": args.resource,
428
+ "changeType": args.change_type,
429
+ "releaseStatus": args.release_status,
430
+ "channel": args.channel,
431
+ },
432
+ )
433
+
434
+
339
435
  def _print_direct_release_preflight(preflight: dict[str, Any]) -> None:
340
436
  print("Production direct-release preflight:", file=sys.stderr)
341
437
  print(f" Current main: {preflight.get('mainCommit', 'unknown')}", file=sys.stderr)
@@ -352,6 +448,68 @@ def _print_direct_release_preflight(preflight: dict[str, Any]) -> None:
352
448
  print(f" RISK: {preflight.get('risk', 'This bypasses test environments.')}", file=sys.stderr)
353
449
 
354
450
 
451
+ def _environment_confirmation(args: argparse.Namespace, label: str) -> str:
452
+ value = (args.confirm or "").strip()
453
+ if not value:
454
+ if not sys.stdin.isatty():
455
+ raise CliError(
456
+ "Refusing to restart without --confirm '<environment name>' in non-interactive mode."
457
+ )
458
+ value = input(f"Type {label} to confirm: ").strip()
459
+ if value != label:
460
+ raise CliError("Environment confirmation does not match the preflight result.")
461
+ return value
462
+
463
+
464
+ def _print_environment_restart_preflight(preflight: dict[str, Any]) -> None:
465
+ current = str(preflight.get("currentCommit", "unknown"))
466
+ main = str(preflight.get("mainCommit", "unknown"))
467
+ print("Test-environment restart preflight:", file=sys.stderr)
468
+ print(f" Environment: {preflight.get('environmentLabel', 'unknown')}", file=sys.stderr)
469
+ print(f" Current test version: {current[:7]}", file=sys.stderr)
470
+ print(f" Latest production version: {main[:7]}", file=sys.stderr)
471
+ print(f" Drafts: {preflight.get('draftCount', 0)}", file=sys.stderr)
472
+ print(f" Test records: {preflight.get('recordCount', 0)}", file=sys.stderr)
473
+ for check in preflight.get("checks", []):
474
+ if isinstance(check, dict):
475
+ mark = "OK" if check.get("ok") else "FAIL"
476
+ print(f" [{mark}] {check.get('label', '')}", file=sys.stderr)
477
+ print(
478
+ f" RISK: {preflight.get('risk', 'All test configuration and records will be cleared.')}",
479
+ file=sys.stderr,
480
+ )
481
+
482
+
483
+ def handle_environments(args: argparse.Namespace) -> int:
484
+ client = client_from_args(args)
485
+ preflight_data = client.request("environments.restart-preflight")
486
+ try:
487
+ preflight = json.loads(preflight_data)
488
+ except json.JSONDecodeError as exc:
489
+ raise CliError("Environment restart preflight returned invalid JSON.") from exc
490
+ _print_environment_restart_preflight(preflight)
491
+ if not preflight.get("canRestart"):
492
+ raise CliError("Environment restart preflight failed. Resolve failed checks first.")
493
+ label = preflight.get("environmentLabel")
494
+ if not isinstance(label, str) or not label:
495
+ raise CliError("Environment restart preflight did not return an environment label.")
496
+ confirm = _environment_confirmation(args, label)
497
+ endpoint = get_endpoint("environments.restart")
498
+ confirm_change(endpoint, args.yes)
499
+ body = {
500
+ "confirm": confirm,
501
+ "expectedCurrentCommit": preflight.get("currentCommit"),
502
+ "expectedMainCommit": preflight.get("mainCommit"),
503
+ "expectedDraftRevision": preflight.get("draftRevision"),
504
+ "expectedRecordCount": preflight.get("recordCount"),
505
+ }
506
+ if any(value is None for value in body.values()):
507
+ raise CliError("Environment restart preflight returned incomplete binding state.")
508
+ data = client.request("environments.restart", body=body)
509
+ print_output(data, pretty=args.pretty, raw=args.raw)
510
+ return 0
511
+
512
+
355
513
  def handle_records(args: argparse.Namespace) -> int:
356
514
  action = args.records_action
357
515
  path = {"object_id": args.object_id}
@@ -435,6 +593,58 @@ def build_parser() -> argparse.ArgumentParser:
435
593
  add_yes(item)
436
594
  fields.set_defaults(handler=handle_fields)
437
595
 
596
+ views = groups.add_parser("views", help="Manage object view metadata.")
597
+ view_actions = views.add_subparsers(dest="views_action", required=True)
598
+ view_list = view_actions.add_parser("list")
599
+ view_list.add_argument("object_id")
600
+ for action in ("create", "reorder"):
601
+ item = view_actions.add_parser(action)
602
+ item.add_argument("object_id")
603
+ add_json_args(item)
604
+ view_update = view_actions.add_parser("update")
605
+ view_update.add_argument("object_id")
606
+ view_update.add_argument("view_id")
607
+ add_json_args(view_update)
608
+ view_enabled = view_actions.add_parser("set-enabled")
609
+ view_enabled.add_argument("object_id")
610
+ view_enabled.add_argument("view_id")
611
+ view_enabled.add_argument("--base-revision", required=True, type=int)
612
+ view_enabled.add_argument(
613
+ "--enabled",
614
+ action=argparse.BooleanOptionalAction,
615
+ required=True,
616
+ )
617
+ add_yes(view_enabled)
618
+ view_delete = view_actions.add_parser("delete")
619
+ view_delete.add_argument("object_id")
620
+ view_delete.add_argument("view_id")
621
+ view_delete.add_argument("--base-revision", required=True, type=int)
622
+ add_yes(view_delete)
623
+ views.set_defaults(handler=handle_views)
624
+
625
+ pages = groups.add_parser("pages", help="Manage custom Page metadata.")
626
+ page_actions = pages.add_subparsers(dest="pages_action", required=True)
627
+ page_list = page_actions.add_parser("list")
628
+ page_list.add_argument("--status", choices=("active", "deleted"), default="active")
629
+ page_list.add_argument("--object-id")
630
+ page_list.add_argument("--type", choices=("create", "detail"))
631
+ page_list.add_argument("--keyword")
632
+ page_list.add_argument("--page", type=int, default=1)
633
+ page_actions.add_parser("options")
634
+ page_get = page_actions.add_parser("get")
635
+ page_get.add_argument("page_id")
636
+ page_create = page_actions.add_parser("create")
637
+ add_json_args(page_create)
638
+ page_update = page_actions.add_parser("update")
639
+ page_update.add_argument("page_id")
640
+ add_json_args(page_update)
641
+ for action in ("set-default", "delete", "restore"):
642
+ item = page_actions.add_parser(action)
643
+ item.add_argument("page_id")
644
+ item.add_argument("--base-revision", required=True, type=int)
645
+ add_yes(item)
646
+ pages.set_defaults(handler=handle_pages)
647
+
438
648
  changes = groups.add_parser("changes", help="Commit or release metadata versions.")
439
649
  change_actions = changes.add_subparsers(dest="changes_action", required=True)
440
650
  for action in ("commit", "release", "direct-release"):
@@ -443,6 +653,38 @@ def build_parser() -> argparse.ArgumentParser:
443
653
  add_yes(item)
444
654
  changes.set_defaults(handler=handle_changes)
445
655
 
656
+ versions = groups.add_parser("versions", help="Inspect read-only configuration history.")
657
+ version_actions = versions.add_subparsers(dest="versions_action", required=True)
658
+ version_list = version_actions.add_parser("list")
659
+ version_list.add_argument("--query")
660
+ version_list.add_argument("--resource")
661
+ version_list.add_argument(
662
+ "--change-type",
663
+ choices=("business-change", "format-migration"),
664
+ )
665
+ version_list.add_argument(
666
+ "--release-status",
667
+ choices=("released", "unreleased"),
668
+ )
669
+ version_list.add_argument(
670
+ "--channel",
671
+ choices=("web", "openapi", "cli", "ai", "system-migration"),
672
+ )
673
+ version_show = version_actions.add_parser("show")
674
+ version_show.add_argument("version_id")
675
+ version_actions.add_parser("releases")
676
+ versions.set_defaults(handler=handle_versions)
677
+
678
+ environments = groups.add_parser("environments", help="Manage the current test environment.")
679
+ environment_actions = environments.add_subparsers(dest="environments_action", required=True)
680
+ environment_restart = environment_actions.add_parser("restart")
681
+ environment_restart.add_argument(
682
+ "--confirm",
683
+ help="Exact environment name returned by preflight. Required in non-interactive mode.",
684
+ )
685
+ add_yes(environment_restart)
686
+ environments.set_defaults(handler=handle_environments)
687
+
446
688
  records = groups.add_parser("records", help="Manage business records.")
447
689
  record_actions = records.add_subparsers(dest="records_action", required=True)
448
690
  record_list = record_actions.add_parser("list")
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: flowbase-cli
3
+ Version: 0.1.4
4
+ Summary: Command line client for the FlowBase OpenAPI
5
+ Author: DeepFlowAI
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+
10
+ # FlowBase CLI
11
+
12
+ Install from this checkout:
13
+
14
+ ```bash
15
+ python -m pip install ./packages/flowbase-cli
16
+ ```
17
+
18
+ Configure an environment Base URL and an API key:
19
+
20
+ ```bash
21
+ export FLOWBASE_HOST=https://flowbase-test-a.deepflowagent.com
22
+ export FLOWBASE_API_KEY=fbk_xxx
23
+
24
+ flowbase objects list
25
+ flowbase views list obj_xxx
26
+ flowbase views create obj_xxx --json-file view.json
27
+ flowbase pages list --object-id obj_xxx
28
+ flowbase pages create --json-file page.json
29
+ flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
30
+ flowbase versions list --resource customer --change-type business-change
31
+ flowbase versions show abc1234
32
+ flowbase versions releases
33
+ flowbase changes commit --message "Add customer field" --yes
34
+ flowbase changes release --message "Release customer field" --yes
35
+ flowbase environments restart --confirm "A 测试环境" --yes
36
+ flowbase docs list
37
+ ```
38
+
39
+ `--host` selects the environment through its Host. Version history commands are read-only and limited to the history visible from that Host; production release records are available only on the production Host. A test release can succeed only when its current version meets FlowBase's fast-forward checks. `environments restart` first shows a destructive preflight and then clears the current test configuration and records before starting from the latest production version. 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,381 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import unittest
6
+ from decimal import Decimal
7
+ from pathlib import Path
8
+ from tempfile import TemporaryDirectory
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ from flowbase_cli.endpoints import ENDPOINTS
12
+ from flowbase_cli.main import (
13
+ CliError,
14
+ OpenApiClient,
15
+ api_root,
16
+ exact_json_dumps,
17
+ load_json_payload,
18
+ main,
19
+ )
20
+
21
+
22
+ class FlowBaseCliTest(unittest.TestCase):
23
+ def test_endpoint_keys_and_operation_ids_are_unique(self):
24
+ self.assertEqual(len({item.key for item in ENDPOINTS}), len(ENDPOINTS))
25
+ self.assertEqual(len({item.operation_id for item in ENDPOINTS}), len(ENDPOINTS))
26
+
27
+ def test_api_root_appends_prefix_once(self):
28
+ self.assertEqual(api_root("https://example.com/"), "https://example.com/api/openapi/v1")
29
+ self.assertEqual(api_root("https://example.com/api/openapi/v1"), "https://example.com/api/openapi/v1")
30
+
31
+ def test_json_payload_requires_one_object_source(self):
32
+ self.assertEqual(load_json_payload('{"values":{}}', None), {"values": {}})
33
+ with self.assertRaisesRegex(CliError, "cannot be used together"):
34
+ load_json_payload("{}", "body.json")
35
+ with self.assertRaisesRegex(CliError, "must be an object"):
36
+ load_json_payload("[]", None)
37
+
38
+ def test_json_file_is_utf8(self):
39
+ with TemporaryDirectory() as directory:
40
+ path = Path(directory) / "body.json"
41
+ path.write_text('{"label":"客户"}', encoding="utf-8")
42
+ self.assertEqual(load_json_payload(None, str(path)), {"label": "客户"})
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
+
56
+ def test_missing_key_is_rejected_before_network(self):
57
+ client = OpenApiClient("https://example.com", None)
58
+ with patch("flowbase_cli.main.urlopen") as mocked:
59
+ with self.assertRaisesRegex(CliError, "Missing API key"):
60
+ client.request("objects.list")
61
+ mocked.assert_not_called()
62
+
63
+ def test_url_encodes_path_and_query(self):
64
+ client = OpenApiClient("https://example.com", "fbk_test")
65
+ endpoint = next(item for item in ENDPOINTS if item.key == "records.get")
66
+ url = client.build_url(endpoint, {"object_id": "obj/a", "record_id": "rec b"}, {"page": 2})
67
+ self.assertEqual(url, "https://example.com/api/openapi/v1/objects/obj%2Fa/records/rec%20b?page=2")
68
+
69
+ def test_requests_identify_cli_channel_without_exposing_key_in_url(self):
70
+ client = OpenApiClient("https://example.com", "fbk_secret")
71
+ response = MagicMock()
72
+ response.read.return_value = b"[]"
73
+ context = MagicMock()
74
+ context.__enter__.return_value = response
75
+ with patch("flowbase_cli.main.urlopen", return_value=context) as mocked:
76
+ client.request("versions.list")
77
+ request = mocked.call_args.args[0]
78
+ self.assertEqual(request.get_header("X-flowbase-channel"), "cli")
79
+ self.assertNotIn("fbk_secret", request.full_url)
80
+
81
+ def test_docs_list_needs_no_api_key(self):
82
+ stdout = io.StringIO()
83
+ with patch("sys.stdout", stdout):
84
+ code = main(["docs", "list"])
85
+ self.assertEqual(code, 0)
86
+ payload = json.loads(stdout.getvalue())
87
+ self.assertTrue(any(item["key"] == "records.create" for item in payload))
88
+ self.assertTrue(any(item["key"] == "changes.release" for item in payload))
89
+ self.assertTrue(any(item["key"] == "versions.get" for item in payload))
90
+ self.assertTrue(any(item["key"] == "pages.create" for item in payload))
91
+ self.assertTrue(any(item["key"] == "views.create" for item in payload))
92
+
93
+ def test_view_create_sends_complete_snapshot(self):
94
+ body = {
95
+ "baseRevision": 4,
96
+ "name": "Active customers",
97
+ "defaultFieldIds": ["fld_1"],
98
+ }
99
+ stdout = io.StringIO()
100
+ with (
101
+ patch(
102
+ "flowbase_cli.main.OpenApiClient.request",
103
+ return_value=b'{"viewId":"viw_1","draftRevision":5}',
104
+ ) as request,
105
+ patch("sys.stdout", stdout),
106
+ ):
107
+ code = main([
108
+ "--api-key",
109
+ "fbk_test",
110
+ "views",
111
+ "create",
112
+ "obj_1",
113
+ "--json",
114
+ json.dumps(body),
115
+ ])
116
+ self.assertEqual(code, 0)
117
+ self.assertEqual(request.call_args.args[0], "views.create")
118
+ self.assertEqual(
119
+ request.call_args.kwargs["path_params"],
120
+ {"object_id": "obj_1"},
121
+ )
122
+ self.assertEqual(request.call_args.kwargs["body"], body)
123
+
124
+ def test_view_set_enabled_sends_revision_and_requires_yes(self):
125
+ stderr = io.StringIO()
126
+ with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
127
+ code = main([
128
+ "--api-key",
129
+ "fbk_test",
130
+ "views",
131
+ "set-enabled",
132
+ "obj_1",
133
+ "viw_1",
134
+ "--base-revision",
135
+ "4",
136
+ "--no-enabled",
137
+ ])
138
+ self.assertEqual(code, 1)
139
+ self.assertIn("without --yes", stderr.getvalue())
140
+
141
+ with patch(
142
+ "flowbase_cli.main.OpenApiClient.request",
143
+ return_value=b'{"ok":true,"draftRevision":5}',
144
+ ) as request:
145
+ code = main([
146
+ "--api-key",
147
+ "fbk_test",
148
+ "views",
149
+ "set-enabled",
150
+ "obj_1",
151
+ "viw_1",
152
+ "--base-revision",
153
+ "4",
154
+ "--no-enabled",
155
+ "--yes",
156
+ ])
157
+ self.assertEqual(code, 0)
158
+ self.assertEqual(request.call_args.args[0], "views.set-enabled")
159
+ self.assertEqual(
160
+ request.call_args.kwargs["body"],
161
+ {"baseRevision": 4, "enabled": False},
162
+ )
163
+
164
+ def test_page_set_default_sends_revision_and_requires_yes(self):
165
+ stderr = io.StringIO()
166
+ with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
167
+ code = main(["--api-key", "fbk_test", "pages", "set-default", "pag_1", "--base-revision", "4"])
168
+ self.assertEqual(code, 1)
169
+ self.assertIn("without --yes", stderr.getvalue())
170
+
171
+ stdout = io.StringIO()
172
+ with (
173
+ patch("flowbase_cli.main.OpenApiClient.request", return_value=b'{"draftRevision":5}') as request,
174
+ patch("sys.stdout", stdout),
175
+ ):
176
+ code = main([
177
+ "--api-key", "fbk_test", "pages", "set-default", "pag_1",
178
+ "--base-revision", "4", "--yes",
179
+ ])
180
+ self.assertEqual(code, 0)
181
+ self.assertEqual(request.call_args.args[0], "pages.set-default")
182
+ self.assertEqual(request.call_args.kwargs["body"], {"baseRevision": 4})
183
+
184
+ def test_page_create_requires_full_request_body(self):
185
+ stderr = io.StringIO()
186
+ with patch("sys.stderr", stderr):
187
+ code = main(["pages", "create", "--json", '{"baseRevision":0}'])
188
+ self.assertEqual(code, 1)
189
+ self.assertIn("page", stderr.getvalue())
190
+
191
+ def test_version_show_uses_read_only_version_endpoint(self):
192
+ stdout = io.StringIO()
193
+ with (
194
+ patch(
195
+ "flowbase_cli.main.OpenApiClient.request",
196
+ return_value=b'{"commit":"abcdef0"}',
197
+ ) as request,
198
+ patch("sys.stdout", stdout),
199
+ ):
200
+ code = main([
201
+ "--api-key",
202
+ "fbk_test",
203
+ "versions",
204
+ "show",
205
+ "abcdef0",
206
+ ])
207
+ self.assertEqual(code, 0)
208
+ self.assertEqual(request.call_args.args[0], "versions.get")
209
+ self.assertEqual(
210
+ request.call_args.kwargs["path_params"],
211
+ {"version_id": "abcdef0"},
212
+ )
213
+
214
+ def test_management_operation_catalog_identifies_pending_gaps(self):
215
+ stdout = io.StringIO()
216
+ with patch("sys.stdout", stdout):
217
+ code = main(["docs", "operations"])
218
+ self.assertEqual(code, 0)
219
+ payload = json.loads(stdout.getvalue())
220
+ self.assertTrue(any(item["key"] == "changes.direct-release" and item["status"] == "delivered" for item in payload))
221
+ self.assertTrue(any(item["key"] == "changes.clear-drafts" and item["status"] == "pending" for item in payload))
222
+
223
+ def test_object_create_requires_business_description(self):
224
+ stderr = io.StringIO()
225
+ with patch("sys.stderr", stderr):
226
+ code = main(["objects", "create", "--label", "Customer", "--key", "customer"])
227
+ self.assertEqual(code, 1)
228
+ self.assertIn("description", stderr.getvalue())
229
+
230
+ def test_noninteractive_delete_requires_yes(self):
231
+ stderr = io.StringIO()
232
+ with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
233
+ code = main(["--api-key", "fbk_test", "records", "delete", "obj_1", "rec_1"])
234
+ self.assertEqual(code, 1)
235
+ self.assertIn("without --yes", stderr.getvalue())
236
+
237
+ def test_noninteractive_commit_requires_yes(self):
238
+ stderr = io.StringIO()
239
+ with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
240
+ code = main(["--api-key", "fbk_test", "changes", "commit", "--message", "Add customer field"])
241
+ self.assertEqual(code, 1)
242
+ self.assertIn("without --yes", stderr.getvalue())
243
+
244
+ def test_direct_release_always_preflights_before_execution(self):
245
+ preflight = {
246
+ "confirmationToken": "fbc_secret",
247
+ "mainCommit": "abc1234",
248
+ "draftRevision": 4,
249
+ "semanticCount": 2,
250
+ "fileCount": 1,
251
+ "checks": [{"label": "Configuration valid", "ok": True}],
252
+ "risk": "Bypasses test environments.",
253
+ }
254
+ stdout = io.StringIO()
255
+ stderr = io.StringIO()
256
+ with (
257
+ patch(
258
+ "flowbase_cli.main.OpenApiClient.request",
259
+ side_effect=[
260
+ json.dumps(preflight).encode(),
261
+ b'{"mainCommit":"def5678"}',
262
+ ],
263
+ ) as request,
264
+ patch("sys.stdout", stdout),
265
+ patch("sys.stderr", stderr),
266
+ ):
267
+ code = main([
268
+ "--api-key",
269
+ "fbk_test",
270
+ "changes",
271
+ "direct-release",
272
+ "--message",
273
+ "Production fix",
274
+ "--yes",
275
+ ])
276
+ self.assertEqual(code, 0)
277
+ self.assertEqual(request.call_count, 2)
278
+ self.assertEqual(
279
+ request.call_args_list[0].args[0],
280
+ "changes.direct-release-preflight",
281
+ )
282
+ self.assertEqual(
283
+ request.call_args_list[1].kwargs["body"]["confirmationToken"],
284
+ "fbc_secret",
285
+ )
286
+ self.assertNotIn("fbc_secret", stdout.getvalue())
287
+ self.assertNotIn("fbc_secret", stderr.getvalue())
288
+ self.assertIn("Bypasses test environments", stderr.getvalue())
289
+
290
+ def test_environment_restart_preflights_and_binds_execution(self):
291
+ preflight = {
292
+ "environmentId": "test-a",
293
+ "environmentLabel": "A 测试环境",
294
+ "currentCommit": "a" * 40,
295
+ "mainCommit": "b" * 40,
296
+ "draftRevision": 7,
297
+ "draftCount": 0,
298
+ "recordCount": 3,
299
+ "checks": [
300
+ {"label": "当前环境为已启用测试环境", "ok": True},
301
+ {"label": "当前环境无待提交草稿", "ok": True},
302
+ ],
303
+ "canRestart": True,
304
+ "risk": "All test records will be deleted.",
305
+ }
306
+ stdout = io.StringIO()
307
+ stderr = io.StringIO()
308
+ with (
309
+ patch(
310
+ "flowbase_cli.main.OpenApiClient.request",
311
+ side_effect=[
312
+ json.dumps(preflight).encode(),
313
+ b'{"currentCommit":"bbbbbbb","clearedRecordCount":3}',
314
+ ],
315
+ ) as request,
316
+ patch("sys.stdout", stdout),
317
+ patch("sys.stderr", stderr),
318
+ ):
319
+ code = main([
320
+ "--api-key",
321
+ "fbk_test",
322
+ "environments",
323
+ "restart",
324
+ "--confirm",
325
+ "A 测试环境",
326
+ "--yes",
327
+ ])
328
+
329
+ self.assertEqual(code, 0)
330
+ self.assertEqual(request.call_count, 2)
331
+ self.assertEqual(request.call_args_list[0].args[0], "environments.restart-preflight")
332
+ self.assertEqual(request.call_args_list[1].args[0], "environments.restart")
333
+ self.assertEqual(
334
+ request.call_args_list[1].kwargs["body"],
335
+ {
336
+ "confirm": "A 测试环境",
337
+ "expectedCurrentCommit": "a" * 40,
338
+ "expectedMainCommit": "b" * 40,
339
+ "expectedDraftRevision": 7,
340
+ "expectedRecordCount": 3,
341
+ },
342
+ )
343
+ self.assertIn("All test records will be deleted", stderr.getvalue())
344
+
345
+ def test_environment_restart_noninteractive_requires_name_confirmation(self):
346
+ preflight = {
347
+ "environmentId": "test-a",
348
+ "environmentLabel": "A 测试环境",
349
+ "currentCommit": "a" * 40,
350
+ "mainCommit": "b" * 40,
351
+ "draftRevision": 0,
352
+ "draftCount": 0,
353
+ "recordCount": 0,
354
+ "checks": [],
355
+ "canRestart": True,
356
+ "risk": "Destructive.",
357
+ }
358
+ stderr = io.StringIO()
359
+ with (
360
+ patch(
361
+ "flowbase_cli.main.OpenApiClient.request",
362
+ return_value=json.dumps(preflight).encode(),
363
+ ) as request,
364
+ patch("sys.stderr", stderr),
365
+ patch("sys.stdin.isatty", return_value=False),
366
+ ):
367
+ code = main([
368
+ "--api-key",
369
+ "fbk_test",
370
+ "environments",
371
+ "restart",
372
+ "--yes",
373
+ ])
374
+
375
+ self.assertEqual(code, 1)
376
+ self.assertEqual(request.call_count, 1)
377
+ self.assertIn("--confirm", stderr.getvalue())
378
+
379
+
380
+ if __name__ == "__main__":
381
+ unittest.main()
@@ -1,31 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: flowbase-cli
3
- Version: 0.1.2
4
- Summary: Command line client for the FlowBase OpenAPI
5
- Author: DeepFlowAI
6
- License: Apache-2.0
7
- Requires-Python: >=3.11
8
- Description-Content-Type: text/markdown
9
-
10
- # FlowBase CLI
11
-
12
- Install from this checkout:
13
-
14
- ```bash
15
- python -m pip install ./packages/flowbase-cli
16
- ```
17
-
18
- Configure an environment Base URL and an API key:
19
-
20
- ```bash
21
- export FLOWBASE_HOST=https://flowbase-test-a.deepflowagent.com
22
- export FLOWBASE_API_KEY=fbk_xxx
23
-
24
- flowbase objects list
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
28
- flowbase docs list
29
- ```
30
-
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.
@@ -1,22 +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 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.
@@ -1,31 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: flowbase-cli
3
- Version: 0.1.2
4
- Summary: Command line client for the FlowBase OpenAPI
5
- Author: DeepFlowAI
6
- License: Apache-2.0
7
- Requires-Python: >=3.11
8
- Description-Content-Type: text/markdown
9
-
10
- # FlowBase CLI
11
-
12
- Install from this checkout:
13
-
14
- ```bash
15
- python -m pip install ./packages/flowbase-cli
16
- ```
17
-
18
- Configure an environment Base URL and an API key:
19
-
20
- ```bash
21
- export FLOWBASE_HOST=https://flowbase-test-a.deepflowagent.com
22
- export FLOWBASE_API_KEY=fbk_xxx
23
-
24
- flowbase objects list
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
28
- flowbase docs list
29
- ```
30
-
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.
@@ -1,156 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import io
4
- import json
5
- import unittest
6
- from decimal import Decimal
7
- from pathlib import Path
8
- from tempfile import TemporaryDirectory
9
- from unittest.mock import patch
10
-
11
- from flowbase_cli.endpoints import ENDPOINTS
12
- from flowbase_cli.main import (
13
- CliError,
14
- OpenApiClient,
15
- api_root,
16
- exact_json_dumps,
17
- load_json_payload,
18
- main,
19
- )
20
-
21
-
22
- class FlowBaseCliTest(unittest.TestCase):
23
- def test_endpoint_keys_and_operation_ids_are_unique(self):
24
- self.assertEqual(len({item.key for item in ENDPOINTS}), len(ENDPOINTS))
25
- self.assertEqual(len({item.operation_id for item in ENDPOINTS}), len(ENDPOINTS))
26
-
27
- def test_api_root_appends_prefix_once(self):
28
- self.assertEqual(api_root("https://example.com/"), "https://example.com/api/openapi/v1")
29
- self.assertEqual(api_root("https://example.com/api/openapi/v1"), "https://example.com/api/openapi/v1")
30
-
31
- def test_json_payload_requires_one_object_source(self):
32
- self.assertEqual(load_json_payload('{"values":{}}', None), {"values": {}})
33
- with self.assertRaisesRegex(CliError, "cannot be used together"):
34
- load_json_payload("{}", "body.json")
35
- with self.assertRaisesRegex(CliError, "must be an object"):
36
- load_json_payload("[]", None)
37
-
38
- def test_json_file_is_utf8(self):
39
- with TemporaryDirectory() as directory:
40
- path = Path(directory) / "body.json"
41
- path.write_text('{"label":"客户"}', encoding="utf-8")
42
- self.assertEqual(load_json_payload(None, str(path)), {"label": "客户"})
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
-
56
- def test_missing_key_is_rejected_before_network(self):
57
- client = OpenApiClient("https://example.com", None)
58
- with patch("flowbase_cli.main.urlopen") as mocked:
59
- with self.assertRaisesRegex(CliError, "Missing API key"):
60
- client.request("objects.list")
61
- mocked.assert_not_called()
62
-
63
- def test_url_encodes_path_and_query(self):
64
- client = OpenApiClient("https://example.com", "fbk_test")
65
- endpoint = next(item for item in ENDPOINTS if item.key == "records.get")
66
- url = client.build_url(endpoint, {"object_id": "obj/a", "record_id": "rec b"}, {"page": 2})
67
- self.assertEqual(url, "https://example.com/api/openapi/v1/objects/obj%2Fa/records/rec%20b?page=2")
68
-
69
- def test_docs_list_needs_no_api_key(self):
70
- stdout = io.StringIO()
71
- with patch("sys.stdout", stdout):
72
- code = main(["docs", "list"])
73
- self.assertEqual(code, 0)
74
- payload = json.loads(stdout.getvalue())
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))
86
-
87
- def test_object_create_requires_business_description(self):
88
- stderr = io.StringIO()
89
- with patch("sys.stderr", stderr):
90
- code = main(["objects", "create", "--label", "Customer", "--key", "customer"])
91
- self.assertEqual(code, 1)
92
- self.assertIn("description", stderr.getvalue())
93
-
94
- def test_noninteractive_delete_requires_yes(self):
95
- stderr = io.StringIO()
96
- with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
97
- code = main(["--api-key", "fbk_test", "records", "delete", "obj_1", "rec_1"])
98
- self.assertEqual(code, 1)
99
- self.assertIn("without --yes", stderr.getvalue())
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
-
154
-
155
- if __name__ == "__main__":
156
- unittest.main()
File without changes