flowbase-cli 0.1.3__tar.gz → 0.1.5__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.3
3
+ Version: 0.1.5
4
4
  Summary: Command line client for the FlowBase OpenAPI
5
5
  Author: DeepFlowAI
6
6
  License: Apache-2.0
@@ -22,6 +22,8 @@ export FLOWBASE_HOST=https://flowbase-test-a.deepflowagent.com
22
22
  export FLOWBASE_API_KEY=fbk_xxx
23
23
 
24
24
  flowbase objects list
25
+ flowbase views list obj_xxx
26
+ flowbase views create obj_xxx --json-file view.json
25
27
  flowbase pages list --object-id obj_xxx
26
28
  flowbase pages create --json-file page.json
27
29
  flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
@@ -13,6 +13,8 @@ export FLOWBASE_HOST=https://flowbase-test-a.deepflowagent.com
13
13
  export FLOWBASE_API_KEY=fbk_xxx
14
14
 
15
15
  flowbase objects list
16
+ flowbase views list obj_xxx
17
+ flowbase views create obj_xxx --json-file view.json
16
18
  flowbase pages list --object-id obj_xxx
17
19
  flowbase pages create --json-file page.json
18
20
  flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flowbase-cli"
7
- version = "0.1.3"
7
+ version = "0.1.5"
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.3"
3
+ __version__ = "0.1.5"
@@ -20,16 +20,65 @@ class Endpoint:
20
20
 
21
21
 
22
22
  ENDPOINTS = (
23
+ Endpoint("apps.list", "flowbase_apps_list", "GET", "/apps", "metadata:read", "List apps.", "flowbase apps list"),
24
+ Endpoint("apps.get", "flowbase_apps_get", "GET", "/apps/{app_id}", "metadata:read", "Get app metadata with groups and objects.", "flowbase apps get app_xxx"),
25
+ Endpoint("apps.create", "flowbase_apps_create", "POST", "/apps", "metadata:write", "Create an app draft.", "flowbase apps create --json-file app.json"),
26
+ Endpoint("apps.update", "flowbase_apps_update", "PUT", "/apps/{app_id}", "metadata:write", "Update an app draft.", "flowbase apps update app_xxx --label CRM --color '#2563EB'"),
27
+ Endpoint("apps.set-enabled", "flowbase_apps_set_enabled", "POST", "/apps/{app_id}/enabled", "metadata:write", "Enable or disable an app.", "flowbase apps set-enabled app_xxx --enabled --yes", True),
28
+ Endpoint("apps.delete", "flowbase_apps_delete", "DELETE", "/apps/{app_id}", "metadata:write", "Delete an empty app.", "flowbase apps delete app_xxx --yes", True),
29
+ Endpoint(
30
+ "apps.groups.create",
31
+ "flowbase_apps_groups_create",
32
+ "POST",
33
+ "/apps/{app_id}/groups",
34
+ "metadata:write",
35
+ "Create an app group draft.",
36
+ "flowbase groups create app_xxx --name Sales --icon IconFolder",
37
+ ),
38
+ Endpoint(
39
+ "apps.groups.update",
40
+ "flowbase_apps_groups_update",
41
+ "PUT",
42
+ "/apps/{app_id}/groups/{group_id}",
43
+ "metadata:write",
44
+ "Update an app group draft.",
45
+ "flowbase groups update app_xxx grp_xxx --name Sales",
46
+ ),
47
+ Endpoint(
48
+ "apps.groups.delete",
49
+ "flowbase_apps_groups_delete",
50
+ "DELETE",
51
+ "/apps/{app_id}/groups/{group_id}",
52
+ "metadata:write",
53
+ "Delete an app group (objects become ungrouped).",
54
+ "flowbase groups delete app_xxx grp_xxx --yes",
55
+ True,
56
+ ),
23
57
  Endpoint("objects.list", "flowbase_objects_list", "GET", "/objects", "metadata:read", "List object metadata.", "flowbase objects list"),
24
58
  Endpoint("objects.get", "flowbase_objects_get", "GET", "/objects/{object_id}", "metadata:read", "Get object and field metadata.", "flowbase objects get obj_xxx"),
25
59
  Endpoint("objects.create", "flowbase_objects_create", "POST", "/objects", "metadata:write", "Create an object draft.", "flowbase objects create --json-file object.json"),
26
60
  Endpoint("objects.update", "flowbase_objects_update", "PUT", "/objects/{object_id}", "metadata:write", "Update an object draft.", "flowbase objects update obj_xxx --label Customer --description 'Customer records'"),
61
+ Endpoint(
62
+ "objects.set-group",
63
+ "flowbase_objects_set_group",
64
+ "POST",
65
+ "/objects/{object_id}/group",
66
+ "metadata:write",
67
+ "Move an object into an app group or ungroup it.",
68
+ "flowbase objects set-group obj_xxx --group-id grp_xxx",
69
+ ),
27
70
  Endpoint("objects.delete", "flowbase_objects_delete", "DELETE", "/objects/{object_id}", "metadata:write", "Logically delete an object.", "flowbase objects delete obj_xxx --yes", True),
28
71
  Endpoint("objects.restore", "flowbase_objects_restore", "POST", "/objects/{object_id}/restore", "metadata:write", "Restore an object.", "flowbase objects restore obj_xxx --yes", True),
29
72
  Endpoint("fields.create", "flowbase_fields_create", "POST", "/objects/{object_id}/fields", "metadata:write", "Create a field draft.", "flowbase fields create obj_xxx --json-file field.json"),
30
73
  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
74
  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
75
  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),
76
+ Endpoint("views.list", "flowbase_views_list", "GET", "/objects/{object_id}/views", "metadata:read", "List object view metadata.", "flowbase views list obj_xxx"),
77
+ 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"),
78
+ 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"),
79
+ 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\"]}'"),
80
+ 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),
81
+ 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),
33
82
  Endpoint("pages.list", "flowbase_pages_list", "GET", "/pages", "metadata:read", "List custom Page metadata.", "flowbase pages list --object-id obj_xxx"),
34
83
  Endpoint("pages.options", "flowbase_pages_options", "GET", "/pages/options", "metadata:read", "Get custom Page design options.", "flowbase pages options"),
35
84
  Endpoint("pages.get", "flowbase_pages_get", "GET", "/pages/{page_id}", "metadata:read", "Get a custom Page.", "flowbase pages get pag_xxx"),
@@ -91,6 +140,8 @@ ENDPOINTS = (
91
140
  ),
92
141
  Endpoint("records.list", "flowbase_records_list", "GET", "/objects/{object_id}/records", "records:read", "List records.", "flowbase records list obj_xxx --page 1"),
93
142
  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"),
143
+ Endpoint("records.upload-attachment", "flowbase_records_upload_attachment", "POST", "/objects/{object_id}/fields/{field_id}/attachments", "records:write", "Upload an attachment before writing its ID to a record.", "flowbase records upload-attachment obj_xxx fld_xxx ./document.pdf"),
144
+ Endpoint("records.download-attachment", "flowbase_records_download_attachment", "GET", "/objects/{object_id}/records/{record_id}/attachments/{attachment_id}", "records:read", "Download an attachment bound to a record.", "flowbase records download-attachment obj_xxx rec_xxx att_xxx --output ./document.pdf"),
94
145
  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"),
95
146
  Endpoint("records.update", "flowbase_records_update", "PUT", "/objects/{object_id}/records/{record_id}", "records:write", "Replace active field values on a record.", "flowbase records update obj_xxx rec_xxx --json-file record.json"),
96
147
  Endpoint("records.delete", "flowbase_records_delete", "DELETE", "/objects/{object_id}/records/{record_id}", "records:write", "Delete a record.", "flowbase records delete obj_xxx rec_xxx --yes", True),
@@ -4,7 +4,9 @@ from __future__ import annotations
4
4
  import argparse
5
5
  from decimal import Decimal
6
6
  import json
7
+ import mimetypes
7
8
  import os
9
+ import secrets
8
10
  import sys
9
11
  from pathlib import Path
10
12
  from typing import Any
@@ -97,20 +99,27 @@ class OpenApiClient:
97
99
  path_params: dict[str, Any] | None = None,
98
100
  query: dict[str, Any] | None = None,
99
101
  body: dict[str, Any] | None = None,
102
+ raw_body: bytes | None = None,
103
+ content_type: str | None = None,
104
+ accept: str = "application/json",
100
105
  ) -> bytes:
101
106
  endpoint = get_endpoint(endpoint_key)
102
107
  if not self.api_key:
103
108
  raise CliError(f"Missing API key. Set {ENV_API_KEY} or pass --api-key.")
104
109
  url = self.build_url(endpoint, path_params, query)
105
110
  headers = {
106
- "Accept": "application/json",
111
+ "Accept": accept,
107
112
  "Authorization": f"Bearer {self.api_key}",
108
113
  "X-FlowBase-Channel": "cli",
109
114
  }
110
- data = None
115
+ if body is not None and raw_body is not None:
116
+ raise CliError("JSON and raw request bodies cannot be used together.")
117
+ data = raw_body
111
118
  if body is not None:
112
119
  data = exact_json_dumps(body).encode("utf-8")
113
120
  headers["Content-Type"] = "application/json"
121
+ elif raw_body is not None and content_type:
122
+ headers["Content-Type"] = content_type
114
123
  request = Request(url, data=data, headers=headers, method=endpoint.method)
115
124
  try:
116
125
  with urlopen(request) as response: # noqa: S310 - the user selects the FlowBase host.
@@ -164,6 +173,30 @@ def exact_json_dumps(value: Any) -> str:
164
173
  raise CliError(f"Unsupported JSON value: {type(value).__name__}")
165
174
 
166
175
 
176
+ def multipart_upload(path_value: str) -> tuple[bytes, str]:
177
+ path = Path(path_value)
178
+ try:
179
+ content = path.read_bytes()
180
+ except OSError as exc:
181
+ raise CliError(f"Cannot read attachment: {exc}") from exc
182
+ filename = path.name.replace('"', "_").replace("\r", "_").replace("\n", "_") or "file"
183
+ boundary = f"flowbase-{secrets.token_hex(16)}"
184
+ content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
185
+ ascii_name = filename.encode("ascii", errors="replace").decode("ascii")
186
+ disposition = (
187
+ f'Content-Disposition: form-data; name="upload"; filename="{ascii_name}"; '
188
+ f"filename*=UTF-8''{quote(filename, safe='')}\r\n"
189
+ ).encode("ascii")
190
+ payload = b"".join([
191
+ f"--{boundary}\r\n".encode("ascii"),
192
+ disposition,
193
+ f"Content-Type: {content_type}\r\n\r\n".encode("ascii"),
194
+ content,
195
+ f"\r\n--{boundary}--\r\n".encode("ascii"),
196
+ ])
197
+ return payload, f"multipart/form-data; boundary={boundary}"
198
+
199
+
167
200
  def print_output(data: bytes, *, pretty: bool, raw: bool) -> None:
168
201
  text = data.decode("utf-8", errors="replace")
169
202
  if raw:
@@ -276,6 +309,50 @@ def _payload_from_args(args: argparse.Namespace, direct: dict[str, Any], require
276
309
  return payload
277
310
 
278
311
 
312
+ def handle_apps(args: argparse.Namespace) -> int:
313
+ action = args.apps_action
314
+ if action == "list":
315
+ return run_request(args, "apps.list")
316
+ if action == "get":
317
+ return run_request(args, "apps.get", path_params={"app_id": args.app_id})
318
+ if action == "create":
319
+ body = _payload_from_args(
320
+ args,
321
+ {"label": args.label, "key": args.key, "icon": args.icon, "color": args.color},
322
+ ("label", "key"),
323
+ )
324
+ return run_request(args, "apps.create", body=body)
325
+ if action == "update":
326
+ body = _payload_from_args(
327
+ args,
328
+ {"label": args.label, "icon": args.icon, "color": args.color},
329
+ ("label", "icon", "color"),
330
+ )
331
+ return run_request(args, "apps.update", path_params={"app_id": args.app_id}, body=body)
332
+ if action == "set-enabled":
333
+ return run_request(
334
+ args,
335
+ "apps.set-enabled",
336
+ path_params={"app_id": args.app_id},
337
+ body={"enabled": args.enabled},
338
+ )
339
+ return run_request(args, "apps.delete", path_params={"app_id": args.app_id})
340
+
341
+
342
+ def handle_groups(args: argparse.Namespace) -> int:
343
+ action = args.groups_action
344
+ path = {"app_id": args.app_id}
345
+ if action == "create":
346
+ body = _payload_from_args(args, {"name": args.name, "icon": args.icon}, ("name",))
347
+ return run_request(args, "apps.groups.create", path_params=path, body=body)
348
+ if action == "update":
349
+ path["group_id"] = args.group_id
350
+ body = _payload_from_args(args, {"name": args.name, "icon": args.icon}, ("name", "icon"))
351
+ return run_request(args, "apps.groups.update", path_params=path, body=body)
352
+ path["group_id"] = args.group_id
353
+ return run_request(args, "apps.groups.delete", path_params=path)
354
+
355
+
279
356
  def handle_objects(args: argparse.Namespace) -> int:
280
357
  action = args.objects_action
281
358
  if action == "list":
@@ -285,13 +362,39 @@ def handle_objects(args: argparse.Namespace) -> int:
285
362
  if action == "create":
286
363
  body = _payload_from_args(
287
364
  args,
288
- {"label": args.label, "key": args.key, "description": args.description},
365
+ {
366
+ "label": args.label,
367
+ "key": args.key,
368
+ "description": args.description,
369
+ "appId": args.app_id,
370
+ },
289
371
  ("label", "key", "description"),
290
372
  )
291
373
  return run_request(args, "objects.create", body=body)
292
374
  if action == "update":
293
- body = _payload_from_args(args, {"label": args.label, "description": args.description}, ("label", "description"))
375
+ body = _payload_from_args(
376
+ args,
377
+ {
378
+ "label": args.label,
379
+ "description": args.description,
380
+ "appId": args.app_id,
381
+ },
382
+ ("label", "description"),
383
+ )
294
384
  return run_request(args, "objects.update", path_params={"object_id": args.object_id}, body=body)
385
+ if action == "set-group":
386
+ if args.ungrouped:
387
+ body = {"groupId": None}
388
+ elif args.group_id is not None:
389
+ body = {"groupId": args.group_id}
390
+ else:
391
+ raise CliError("Provide --group-id or --ungrouped.")
392
+ return run_request(
393
+ args,
394
+ "objects.set-group",
395
+ path_params={"object_id": args.object_id},
396
+ body=body,
397
+ )
295
398
  return run_request(args, f"objects.{action}", path_params={"object_id": args.object_id})
296
399
 
297
400
 
@@ -313,6 +416,39 @@ def handle_fields(args: argparse.Namespace) -> int:
313
416
  return run_request(args, f"fields.{action}", path_params=path)
314
417
 
315
418
 
419
+ def handle_views(args: argparse.Namespace) -> int:
420
+ action = args.views_action
421
+ path = {"object_id": args.object_id}
422
+ if hasattr(args, "view_id"):
423
+ path["view_id"] = args.view_id
424
+ if action == "list":
425
+ return run_request(args, "views.list", path_params=path)
426
+ if action in ("create", "update"):
427
+ body = load_json_payload(args.json, args.json_file)
428
+ require_fields(body, "baseRevision", "name", "defaultFieldIds")
429
+ if not isinstance(body["defaultFieldIds"], list):
430
+ raise CliError("Request field 'defaultFieldIds' must be an array.")
431
+ return run_request(args, f"views.{action}", path_params=path, body=body)
432
+ if action == "reorder":
433
+ body = load_json_payload(args.json, args.json_file)
434
+ require_fields(body, "baseRevision", "viewIds")
435
+ if not isinstance(body["viewIds"], list):
436
+ raise CliError("Request field 'viewIds' must be an array.")
437
+ return run_request(args, "views.reorder", path_params=path, body=body)
438
+ if action == "set-enabled":
439
+ body = {
440
+ "baseRevision": args.base_revision,
441
+ "enabled": args.enabled,
442
+ }
443
+ return run_request(args, "views.set-enabled", path_params=path, body=body)
444
+ return run_request(
445
+ args,
446
+ "views.delete",
447
+ path_params=path,
448
+ body={"baseRevision": args.base_revision},
449
+ )
450
+
451
+
316
452
  def handle_pages(args: argparse.Namespace) -> int:
317
453
  action = args.pages_action
318
454
  if action == "list":
@@ -480,6 +616,31 @@ def handle_environments(args: argparse.Namespace) -> int:
480
616
  def handle_records(args: argparse.Namespace) -> int:
481
617
  action = args.records_action
482
618
  path = {"object_id": args.object_id}
619
+ if action == "upload-attachment":
620
+ body, content_type = multipart_upload(args.file)
621
+ data = client_from_args(args).request(
622
+ "records.upload-attachment",
623
+ path_params={**path, "field_id": args.field_id},
624
+ raw_body=body,
625
+ content_type=content_type,
626
+ )
627
+ print_output(data, pretty=args.pretty, raw=args.raw)
628
+ return 0
629
+ if action == "download-attachment":
630
+ data = client_from_args(args).request(
631
+ "records.download-attachment",
632
+ path_params={
633
+ **path,
634
+ "record_id": args.record_id,
635
+ "attachment_id": args.attachment_id,
636
+ },
637
+ accept="application/octet-stream",
638
+ )
639
+ try:
640
+ Path(args.output).write_bytes(data)
641
+ except OSError as exc:
642
+ raise CliError(f"Cannot write attachment: {exc}") from exc
643
+ return 0
483
644
  if hasattr(args, "record_id"):
484
645
  path["record_id"] = args.record_id
485
646
  if action == "list":
@@ -504,6 +665,7 @@ def add_object_fields(parser: argparse.ArgumentParser, *, include_key: bool) ->
504
665
  if include_key:
505
666
  parser.add_argument("--key")
506
667
  parser.add_argument("--description")
668
+ parser.add_argument("--app-id", help="Owning app ID (appId).")
507
669
  add_json_args(parser)
508
670
 
509
671
 
@@ -527,6 +689,55 @@ def build_parser() -> argparse.ArgumentParser:
527
689
  docs_show.add_argument("endpoint", choices=sorted(ENDPOINT_BY_KEY))
528
690
  docs.set_defaults(handler=handle_docs)
529
691
 
692
+ apps = groups.add_parser("apps", help="Manage app metadata.")
693
+ app_actions = apps.add_subparsers(dest="apps_action", required=True)
694
+ app_actions.add_parser("list")
695
+ app_get = app_actions.add_parser("get")
696
+ app_get.add_argument("app_id")
697
+ app_create = app_actions.add_parser("create")
698
+ app_create.add_argument("--label")
699
+ app_create.add_argument("--key")
700
+ app_create.add_argument("--icon")
701
+ app_create.add_argument("--color")
702
+ add_json_args(app_create)
703
+ app_update = app_actions.add_parser("update")
704
+ app_update.add_argument("app_id")
705
+ app_update.add_argument("--label")
706
+ app_update.add_argument("--icon")
707
+ app_update.add_argument("--color")
708
+ add_json_args(app_update)
709
+ app_enabled = app_actions.add_parser("set-enabled")
710
+ app_enabled.add_argument("app_id")
711
+ app_enabled.add_argument(
712
+ "--enabled",
713
+ action=argparse.BooleanOptionalAction,
714
+ required=True,
715
+ )
716
+ add_yes(app_enabled)
717
+ app_delete = app_actions.add_parser("delete")
718
+ app_delete.add_argument("app_id")
719
+ add_yes(app_delete)
720
+ apps.set_defaults(handler=handle_apps)
721
+
722
+ app_groups = groups.add_parser("groups", help="Manage app object groups.")
723
+ group_actions = app_groups.add_subparsers(dest="groups_action", required=True)
724
+ group_create = group_actions.add_parser("create")
725
+ group_create.add_argument("app_id")
726
+ group_create.add_argument("--name")
727
+ group_create.add_argument("--icon")
728
+ add_json_args(group_create)
729
+ group_update = group_actions.add_parser("update")
730
+ group_update.add_argument("app_id")
731
+ group_update.add_argument("group_id")
732
+ group_update.add_argument("--name")
733
+ group_update.add_argument("--icon")
734
+ add_json_args(group_update)
735
+ group_delete = group_actions.add_parser("delete")
736
+ group_delete.add_argument("app_id")
737
+ group_delete.add_argument("group_id")
738
+ add_yes(group_delete)
739
+ app_groups.set_defaults(handler=handle_groups)
740
+
530
741
  objects = groups.add_parser("objects", help="Manage object metadata.")
531
742
  object_actions = objects.add_subparsers(dest="objects_action", required=True)
532
743
  object_list = object_actions.add_parser("list")
@@ -538,6 +749,14 @@ def build_parser() -> argparse.ArgumentParser:
538
749
  object_update = object_actions.add_parser("update")
539
750
  object_update.add_argument("object_id")
540
751
  add_object_fields(object_update, include_key=False)
752
+ object_set_group = object_actions.add_parser("set-group")
753
+ object_set_group.add_argument("object_id")
754
+ object_set_group.add_argument("--group-id", help="Target group ID. Omit with --ungrouped.")
755
+ object_set_group.add_argument(
756
+ "--ungrouped",
757
+ action="store_true",
758
+ help="Remove the object from its current group.",
759
+ )
541
760
  for action in ("delete", "restore"):
542
761
  item = object_actions.add_parser(action)
543
762
  item.add_argument("object_id")
@@ -560,6 +779,35 @@ def build_parser() -> argparse.ArgumentParser:
560
779
  add_yes(item)
561
780
  fields.set_defaults(handler=handle_fields)
562
781
 
782
+ views = groups.add_parser("views", help="Manage object view metadata.")
783
+ view_actions = views.add_subparsers(dest="views_action", required=True)
784
+ view_list = view_actions.add_parser("list")
785
+ view_list.add_argument("object_id")
786
+ for action in ("create", "reorder"):
787
+ item = view_actions.add_parser(action)
788
+ item.add_argument("object_id")
789
+ add_json_args(item)
790
+ view_update = view_actions.add_parser("update")
791
+ view_update.add_argument("object_id")
792
+ view_update.add_argument("view_id")
793
+ add_json_args(view_update)
794
+ view_enabled = view_actions.add_parser("set-enabled")
795
+ view_enabled.add_argument("object_id")
796
+ view_enabled.add_argument("view_id")
797
+ view_enabled.add_argument("--base-revision", required=True, type=int)
798
+ view_enabled.add_argument(
799
+ "--enabled",
800
+ action=argparse.BooleanOptionalAction,
801
+ required=True,
802
+ )
803
+ add_yes(view_enabled)
804
+ view_delete = view_actions.add_parser("delete")
805
+ view_delete.add_argument("object_id")
806
+ view_delete.add_argument("view_id")
807
+ view_delete.add_argument("--base-revision", required=True, type=int)
808
+ add_yes(view_delete)
809
+ views.set_defaults(handler=handle_views)
810
+
563
811
  pages = groups.add_parser("pages", help="Manage custom Page metadata.")
564
812
  page_actions = pages.add_subparsers(dest="pages_action", required=True)
565
813
  page_list = page_actions.add_parser("list")
@@ -631,6 +879,15 @@ def build_parser() -> argparse.ArgumentParser:
631
879
  record_get = record_actions.add_parser("get")
632
880
  record_get.add_argument("object_id")
633
881
  record_get.add_argument("record_id")
882
+ record_upload = record_actions.add_parser("upload-attachment")
883
+ record_upload.add_argument("object_id")
884
+ record_upload.add_argument("field_id")
885
+ record_upload.add_argument("file")
886
+ record_download = record_actions.add_parser("download-attachment")
887
+ record_download.add_argument("object_id")
888
+ record_download.add_argument("record_id")
889
+ record_download.add_argument("attachment_id")
890
+ record_download.add_argument("--output", required=True)
634
891
  record_create = record_actions.add_parser("create")
635
892
  record_create.add_argument("object_id")
636
893
  add_json_args(record_create)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowbase-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Command line client for the FlowBase OpenAPI
5
5
  Author: DeepFlowAI
6
6
  License: Apache-2.0
@@ -22,6 +22,8 @@ export FLOWBASE_HOST=https://flowbase-test-a.deepflowagent.com
22
22
  export FLOWBASE_API_KEY=fbk_xxx
23
23
 
24
24
  flowbase objects list
25
+ flowbase views list obj_xxx
26
+ flowbase views create obj_xxx --json-file view.json
25
27
  flowbase pages list --object-id obj_xxx
26
28
  flowbase pages create --json-file page.json
27
29
  flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
@@ -88,6 +88,78 @@ class FlowBaseCliTest(unittest.TestCase):
88
88
  self.assertTrue(any(item["key"] == "changes.release" for item in payload))
89
89
  self.assertTrue(any(item["key"] == "versions.get" for item in payload))
90
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
+ )
91
163
 
92
164
  def test_page_set_default_sends_revision_and_requires_yes(self):
93
165
  stderr = io.StringIO()
@@ -155,6 +227,103 @@ class FlowBaseCliTest(unittest.TestCase):
155
227
  self.assertEqual(code, 1)
156
228
  self.assertIn("description", stderr.getvalue())
157
229
 
230
+ def test_object_create_sends_app_id(self):
231
+ with patch(
232
+ "flowbase_cli.main.OpenApiClient.request",
233
+ return_value=b'{"objectId":"obj_1","draftRevision":2}',
234
+ ) as request:
235
+ code = main([
236
+ "--api-key",
237
+ "fbk_test",
238
+ "objects",
239
+ "create",
240
+ "--label",
241
+ "Customer",
242
+ "--key",
243
+ "customer",
244
+ "--description",
245
+ "Customer records",
246
+ "--app-id",
247
+ "app_1",
248
+ ])
249
+ self.assertEqual(code, 0)
250
+ self.assertEqual(request.call_args.args[0], "objects.create")
251
+ self.assertEqual(
252
+ request.call_args.kwargs["body"],
253
+ {
254
+ "label": "Customer",
255
+ "key": "customer",
256
+ "description": "Customer records",
257
+ "appId": "app_1",
258
+ },
259
+ )
260
+
261
+ def test_group_create_and_object_set_group(self):
262
+ with patch(
263
+ "flowbase_cli.main.OpenApiClient.request",
264
+ return_value=b'{"groupId":"grp_1","draftRevision":3}',
265
+ ) as request:
266
+ code = main([
267
+ "--api-key",
268
+ "fbk_test",
269
+ "groups",
270
+ "create",
271
+ "app_1",
272
+ "--name",
273
+ "Sales",
274
+ "--icon",
275
+ "IconFolder",
276
+ ])
277
+ self.assertEqual(code, 0)
278
+ self.assertEqual(request.call_args.args[0], "apps.groups.create")
279
+ self.assertEqual(request.call_args.kwargs["path_params"], {"app_id": "app_1"})
280
+ self.assertEqual(
281
+ request.call_args.kwargs["body"],
282
+ {"name": "Sales", "icon": "IconFolder"},
283
+ )
284
+
285
+ with patch(
286
+ "flowbase_cli.main.OpenApiClient.request",
287
+ return_value=b'{"ok":true,"draftRevision":4}',
288
+ ) as request:
289
+ code = main([
290
+ "--api-key",
291
+ "fbk_test",
292
+ "objects",
293
+ "set-group",
294
+ "obj_1",
295
+ "--group-id",
296
+ "grp_1",
297
+ ])
298
+ self.assertEqual(code, 0)
299
+ self.assertEqual(request.call_args.args[0], "objects.set-group")
300
+ self.assertEqual(
301
+ request.call_args.kwargs["body"],
302
+ {"groupId": "grp_1"},
303
+ )
304
+
305
+ with patch(
306
+ "flowbase_cli.main.OpenApiClient.request",
307
+ return_value=b'{"ok":true,"draftRevision":5}',
308
+ ) as request:
309
+ code = main([
310
+ "--api-key",
311
+ "fbk_test",
312
+ "objects",
313
+ "set-group",
314
+ "obj_1",
315
+ "--ungrouped",
316
+ ])
317
+ self.assertEqual(code, 0)
318
+ self.assertEqual(request.call_args.kwargs["body"], {"groupId": None})
319
+
320
+ def test_group_delete_requires_yes(self):
321
+ stderr = io.StringIO()
322
+ with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
323
+ code = main(["--api-key", "fbk_test", "groups", "delete", "app_1", "grp_1"])
324
+ self.assertEqual(code, 1)
325
+ self.assertIn("without --yes", stderr.getvalue())
326
+
158
327
  def test_noninteractive_delete_requires_yes(self):
159
328
  stderr = io.StringIO()
160
329
  with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
@@ -162,6 +331,44 @@ class FlowBaseCliTest(unittest.TestCase):
162
331
  self.assertEqual(code, 1)
163
332
  self.assertIn("without --yes", stderr.getvalue())
164
333
 
334
+ def test_record_attachment_upload_sends_multipart_body(self):
335
+ with TemporaryDirectory() as directory:
336
+ path = Path(directory) / "note.txt"
337
+ path.write_text("hello", encoding="utf-8")
338
+ with patch(
339
+ "flowbase_cli.main.OpenApiClient.request",
340
+ return_value=b'{"id":"att_1"}',
341
+ ) as request:
342
+ code = main([
343
+ "--api-key", "fbk_test", "records", "upload-attachment",
344
+ "obj_1", "fld_1", str(path),
345
+ ])
346
+ self.assertEqual(code, 0)
347
+ self.assertEqual(request.call_args.args[0], "records.upload-attachment")
348
+ self.assertEqual(
349
+ request.call_args.kwargs["path_params"],
350
+ {"object_id": "obj_1", "field_id": "fld_1"},
351
+ )
352
+ self.assertTrue(request.call_args.kwargs["content_type"].startswith("multipart/form-data; boundary="))
353
+ self.assertIn(b'name="upload"', request.call_args.kwargs["raw_body"])
354
+ self.assertIn(b"hello", request.call_args.kwargs["raw_body"])
355
+
356
+ def test_record_attachment_download_writes_exact_bytes(self):
357
+ with TemporaryDirectory() as directory:
358
+ output = Path(directory) / "download.pdf"
359
+ with patch(
360
+ "flowbase_cli.main.OpenApiClient.request",
361
+ return_value=b"%PDF-1.7",
362
+ ) as request:
363
+ code = main([
364
+ "--api-key", "fbk_test", "records", "download-attachment",
365
+ "obj_1", "rec_1", "att_1", "--output", str(output),
366
+ ])
367
+ self.assertEqual(output.read_bytes(), b"%PDF-1.7")
368
+ self.assertEqual(code, 0)
369
+ self.assertEqual(request.call_args.args[0], "records.download-attachment")
370
+ self.assertEqual(request.call_args.kwargs["accept"], "application/octet-stream")
371
+
165
372
  def test_noninteractive_commit_requires_yes(self):
166
373
  stderr = io.StringIO()
167
374
  with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
File without changes