flowbase-cli 0.1.1__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowbase_cli-0.1.3/PKG-INFO +37 -0
- flowbase_cli-0.1.3/README.md +28 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.3}/pyproject.toml +1 -1
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.3}/src/flowbase_cli/__init__.py +1 -1
- flowbase_cli-0.1.3/src/flowbase_cli/endpoints.py +107 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.3}/src/flowbase_cli/main.py +266 -5
- flowbase_cli-0.1.3/src/flowbase_cli.egg-info/PKG-INFO +37 -0
- flowbase_cli-0.1.3/tests/test_cli.py +309 -0
- flowbase_cli-0.1.1/PKG-INFO +0 -31
- flowbase_cli-0.1.1/README.md +0 -22
- flowbase_cli-0.1.1/src/flowbase_cli/endpoints.py +0 -39
- flowbase_cli-0.1.1/src/flowbase_cli.egg-info/PKG-INFO +0 -31
- flowbase_cli-0.1.1/tests/test_cli.py +0 -101
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.3}/setup.cfg +0 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.3}/src/flowbase_cli.egg-info/SOURCES.txt +0 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.3}/src/flowbase_cli.egg-info/dependency_links.txt +0 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.3}/src/flowbase_cli.egg-info/entry_points.txt +0 -0
- {flowbase_cli-0.1.1 → flowbase_cli-0.1.3}/src/flowbase_cli.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flowbase-cli
|
|
3
|
+
Version: 0.1.3
|
|
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 pages list --object-id obj_xxx
|
|
26
|
+
flowbase pages create --json-file page.json
|
|
27
|
+
flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
|
|
28
|
+
flowbase versions list --resource customer --change-type business-change
|
|
29
|
+
flowbase versions show abc1234
|
|
30
|
+
flowbase versions releases
|
|
31
|
+
flowbase changes commit --message "Add customer field" --yes
|
|
32
|
+
flowbase changes release --message "Release customer field" --yes
|
|
33
|
+
flowbase environments restart --confirm "A 测试环境" --yes
|
|
34
|
+
flowbase docs list
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--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,28 @@
|
|
|
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 pages list --object-id obj_xxx
|
|
17
|
+
flowbase pages create --json-file page.json
|
|
18
|
+
flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
|
|
19
|
+
flowbase versions list --resource customer --change-type business-change
|
|
20
|
+
flowbase versions show abc1234
|
|
21
|
+
flowbase versions releases
|
|
22
|
+
flowbase changes commit --message "Add customer field" --yes
|
|
23
|
+
flowbase changes release --message "Release customer field" --yes
|
|
24
|
+
flowbase environments restart --confirm "A 测试环境" --yes
|
|
25
|
+
flowbase docs list
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`--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,107 @@
|
|
|
1
|
+
"""CLI endpoint catalog, contract-tested against the server OpenAPI schema."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Endpoint:
|
|
9
|
+
key: str
|
|
10
|
+
operation_id: str
|
|
11
|
+
method: str
|
|
12
|
+
path: str
|
|
13
|
+
scope: str
|
|
14
|
+
summary: str
|
|
15
|
+
example: str
|
|
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
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ENDPOINTS = (
|
|
23
|
+
Endpoint("objects.list", "flowbase_objects_list", "GET", "/objects", "metadata:read", "List object metadata.", "flowbase objects list"),
|
|
24
|
+
Endpoint("objects.get", "flowbase_objects_get", "GET", "/objects/{object_id}", "metadata:read", "Get object and field metadata.", "flowbase objects get obj_xxx"),
|
|
25
|
+
Endpoint("objects.create", "flowbase_objects_create", "POST", "/objects", "metadata:write", "Create an object draft.", "flowbase objects create --json-file object.json"),
|
|
26
|
+
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'"),
|
|
27
|
+
Endpoint("objects.delete", "flowbase_objects_delete", "DELETE", "/objects/{object_id}", "metadata:write", "Logically delete an object.", "flowbase objects delete obj_xxx --yes", True),
|
|
28
|
+
Endpoint("objects.restore", "flowbase_objects_restore", "POST", "/objects/{object_id}/restore", "metadata:write", "Restore an object.", "flowbase objects restore obj_xxx --yes", True),
|
|
29
|
+
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
|
+
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
|
+
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
|
+
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("pages.list", "flowbase_pages_list", "GET", "/pages", "metadata:read", "List custom Page metadata.", "flowbase pages list --object-id obj_xxx"),
|
|
34
|
+
Endpoint("pages.options", "flowbase_pages_options", "GET", "/pages/options", "metadata:read", "Get custom Page design options.", "flowbase pages options"),
|
|
35
|
+
Endpoint("pages.get", "flowbase_pages_get", "GET", "/pages/{page_id}", "metadata:read", "Get a custom Page.", "flowbase pages get pag_xxx"),
|
|
36
|
+
Endpoint("pages.create", "flowbase_pages_create", "POST", "/pages", "metadata:write", "Create a custom Page draft.", "flowbase pages create --json-file page.json"),
|
|
37
|
+
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"),
|
|
38
|
+
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),
|
|
39
|
+
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),
|
|
40
|
+
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),
|
|
41
|
+
Endpoint("versions.list", "flowbase_versions_list", "GET", "/versions", "metadata:read", "List visible configuration versions.", "flowbase versions list"),
|
|
42
|
+
Endpoint("versions.get", "flowbase_versions_get", "GET", "/versions/{version_id}", "metadata:read", "Get a version with semantic and file diffs.", "flowbase versions show abc1234"),
|
|
43
|
+
Endpoint("releases.list", "flowbase_releases_list", "GET", "/releases", "metadata:read", "List production release records.", "flowbase versions releases", environments=("production",)),
|
|
44
|
+
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"),
|
|
45
|
+
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"),
|
|
46
|
+
Endpoint(
|
|
47
|
+
"changes.direct-release-preflight",
|
|
48
|
+
"flowbase_changes_direct_release_preflight",
|
|
49
|
+
"POST",
|
|
50
|
+
"/changes/direct-release/preflight",
|
|
51
|
+
"metadata:write",
|
|
52
|
+
"Preflight a production direct release.",
|
|
53
|
+
"flowbase changes direct-release --message 'Fix production field' --yes",
|
|
54
|
+
environments=("production",),
|
|
55
|
+
),
|
|
56
|
+
Endpoint(
|
|
57
|
+
"changes.direct-release",
|
|
58
|
+
"flowbase_changes_direct_release",
|
|
59
|
+
"POST",
|
|
60
|
+
"/changes/direct-release",
|
|
61
|
+
"metadata:write",
|
|
62
|
+
"Directly release production drafts.",
|
|
63
|
+
"flowbase changes direct-release --message 'Fix production field' --yes",
|
|
64
|
+
requires_confirmation=True,
|
|
65
|
+
environments=("production",),
|
|
66
|
+
preflight_operation_id="flowbase_changes_direct_release_preflight",
|
|
67
|
+
audit_event="version.forward-fix",
|
|
68
|
+
),
|
|
69
|
+
Endpoint(
|
|
70
|
+
"environments.restart-preflight",
|
|
71
|
+
"flowbase_environments_restart_preflight",
|
|
72
|
+
"GET",
|
|
73
|
+
"/environments/restart/preflight",
|
|
74
|
+
"metadata:write",
|
|
75
|
+
"Preflight restarting the current test environment from production.",
|
|
76
|
+
"flowbase environments restart --confirm 'A 测试环境' --yes",
|
|
77
|
+
environments=("test-a", "test-b"),
|
|
78
|
+
),
|
|
79
|
+
Endpoint(
|
|
80
|
+
"environments.restart",
|
|
81
|
+
"flowbase_environments_restart",
|
|
82
|
+
"POST",
|
|
83
|
+
"/environments/restart",
|
|
84
|
+
"metadata:write",
|
|
85
|
+
"Clear and restart the current test environment from production.",
|
|
86
|
+
"flowbase environments restart --confirm 'A 测试环境' --yes",
|
|
87
|
+
requires_confirmation=True,
|
|
88
|
+
environments=("test-a", "test-b"),
|
|
89
|
+
preflight_operation_id="flowbase_environments_restart_preflight",
|
|
90
|
+
audit_event="version.base-update",
|
|
91
|
+
),
|
|
92
|
+
Endpoint("records.list", "flowbase_records_list", "GET", "/objects/{object_id}/records", "records:read", "List records.", "flowbase records list obj_xxx --page 1"),
|
|
93
|
+
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"),
|
|
94
|
+
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
|
+
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
|
+
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),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
ENDPOINT_BY_KEY = {endpoint.key: endpoint for endpoint in ENDPOINTS}
|
|
100
|
+
|
|
101
|
+
PENDING_MANAGEMENT_OPERATIONS = (
|
|
102
|
+
{"key": "environments.enable", "status": "pending", "reason": "Requires an independent capability and confirmation contract."},
|
|
103
|
+
{"key": "changes.clear-drafts", "status": "pending", "reason": "Requires an independent capability and confirmation contract."},
|
|
104
|
+
{"key": "api-keys.manage", "status": "pending", "reason": "API keys cannot delegate or expand their own authority."},
|
|
105
|
+
{"key": "git-credentials.manage", "status": "pending", "reason": "Requires a dedicated credential-management capability."},
|
|
106
|
+
{"key": "metadata.purge", "status": "pending", "reason": "Physical purge is not delivered."},
|
|
107
|
+
)
|
|
@@ -12,7 +12,12 @@ from urllib.error import HTTPError, URLError
|
|
|
12
12
|
from urllib.parse import quote, urlencode
|
|
13
13
|
from urllib.request import Request, urlopen
|
|
14
14
|
|
|
15
|
-
from flowbase_cli.endpoints import
|
|
15
|
+
from flowbase_cli.endpoints import (
|
|
16
|
+
ENDPOINTS,
|
|
17
|
+
ENDPOINT_BY_KEY,
|
|
18
|
+
PENDING_MANAGEMENT_OPERATIONS,
|
|
19
|
+
Endpoint,
|
|
20
|
+
)
|
|
16
21
|
|
|
17
22
|
DEFAULT_HOST = "http://flowbase.localhost:5001"
|
|
18
23
|
API_PREFIX = "/api/openapi/v1"
|
|
@@ -97,7 +102,11 @@ class OpenApiClient:
|
|
|
97
102
|
if not self.api_key:
|
|
98
103
|
raise CliError(f"Missing API key. Set {ENV_API_KEY} or pass --api-key.")
|
|
99
104
|
url = self.build_url(endpoint, path_params, query)
|
|
100
|
-
headers = {
|
|
105
|
+
headers = {
|
|
106
|
+
"Accept": "application/json",
|
|
107
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
108
|
+
"X-FlowBase-Channel": "cli",
|
|
109
|
+
}
|
|
101
110
|
data = None
|
|
102
111
|
if body is not None:
|
|
103
112
|
data = exact_json_dumps(body).encode("utf-8")
|
|
@@ -202,9 +211,42 @@ def run_request(
|
|
|
202
211
|
|
|
203
212
|
|
|
204
213
|
def handle_docs(args: argparse.Namespace) -> int:
|
|
214
|
+
if args.docs_action == "operations":
|
|
215
|
+
delivered = [
|
|
216
|
+
{
|
|
217
|
+
"key": endpoint.key,
|
|
218
|
+
"status": "delivered",
|
|
219
|
+
"operationId": endpoint.operation_id,
|
|
220
|
+
"method": endpoint.method,
|
|
221
|
+
"path": endpoint.path,
|
|
222
|
+
"scope": endpoint.scope,
|
|
223
|
+
"environments": list(endpoint.environments),
|
|
224
|
+
"requiresPreflight": endpoint.preflight_operation_id is not None,
|
|
225
|
+
"requiresConfirmation": endpoint.requires_confirmation,
|
|
226
|
+
"auditEvent": endpoint.audit_event,
|
|
227
|
+
}
|
|
228
|
+
for endpoint in ENDPOINTS
|
|
229
|
+
if endpoint.scope.startswith("metadata:")
|
|
230
|
+
]
|
|
231
|
+
print(
|
|
232
|
+
json.dumps(
|
|
233
|
+
delivered + list(PENDING_MANAGEMENT_OPERATIONS),
|
|
234
|
+
ensure_ascii=False,
|
|
235
|
+
indent=2 if args.pretty else None,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return 0
|
|
205
239
|
if args.docs_action == "list":
|
|
206
240
|
payload = [
|
|
207
|
-
{
|
|
241
|
+
{
|
|
242
|
+
"key": e.key,
|
|
243
|
+
"operationId": e.operation_id,
|
|
244
|
+
"method": e.method,
|
|
245
|
+
"path": e.path,
|
|
246
|
+
"scope": e.scope,
|
|
247
|
+
"environments": list(e.environments),
|
|
248
|
+
"summary": e.summary,
|
|
249
|
+
}
|
|
208
250
|
for e in ENDPOINTS
|
|
209
251
|
]
|
|
210
252
|
print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
|
|
@@ -218,6 +260,9 @@ def handle_docs(args: argparse.Namespace) -> int:
|
|
|
218
260
|
"scope": endpoint.scope,
|
|
219
261
|
"summary": endpoint.summary,
|
|
220
262
|
"requiresConfirmation": endpoint.requires_confirmation,
|
|
263
|
+
"environments": list(endpoint.environments),
|
|
264
|
+
"preflightOperationId": endpoint.preflight_operation_id,
|
|
265
|
+
"auditEvent": endpoint.audit_event,
|
|
221
266
|
"example": endpoint.example,
|
|
222
267
|
}
|
|
223
268
|
print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
|
|
@@ -268,10 +313,170 @@ def handle_fields(args: argparse.Namespace) -> int:
|
|
|
268
313
|
return run_request(args, f"fields.{action}", path_params=path)
|
|
269
314
|
|
|
270
315
|
|
|
316
|
+
def handle_pages(args: argparse.Namespace) -> int:
|
|
317
|
+
action = args.pages_action
|
|
318
|
+
if action == "list":
|
|
319
|
+
return run_request(
|
|
320
|
+
args,
|
|
321
|
+
"pages.list",
|
|
322
|
+
query={
|
|
323
|
+
"status": args.status,
|
|
324
|
+
"objectId": args.object_id,
|
|
325
|
+
"type": args.type,
|
|
326
|
+
"keyword": args.keyword,
|
|
327
|
+
"page": args.page,
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
if action == "options":
|
|
331
|
+
return run_request(args, "pages.options")
|
|
332
|
+
if action == "get":
|
|
333
|
+
return run_request(args, "pages.get", path_params={"page_id": args.page_id})
|
|
334
|
+
if action in ("create", "update"):
|
|
335
|
+
body = load_json_payload(args.json, args.json_file)
|
|
336
|
+
require_fields(body, "baseRevision", "page")
|
|
337
|
+
if not isinstance(body["page"], dict):
|
|
338
|
+
raise CliError("Request field 'page' must be an object.")
|
|
339
|
+
return run_request(
|
|
340
|
+
args,
|
|
341
|
+
f"pages.{action}",
|
|
342
|
+
path_params={"page_id": args.page_id} if action == "update" else None,
|
|
343
|
+
body=body,
|
|
344
|
+
)
|
|
345
|
+
return run_request(
|
|
346
|
+
args,
|
|
347
|
+
f"pages.{action}",
|
|
348
|
+
path_params={"page_id": args.page_id},
|
|
349
|
+
body={"baseRevision": args.base_revision},
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
271
353
|
def handle_changes(args: argparse.Namespace) -> int:
|
|
354
|
+
if args.changes_action == "direct-release":
|
|
355
|
+
client = client_from_args(args)
|
|
356
|
+
body = {"message": args.message}
|
|
357
|
+
preflight_data = client.request(
|
|
358
|
+
"changes.direct-release-preflight",
|
|
359
|
+
body=body,
|
|
360
|
+
)
|
|
361
|
+
try:
|
|
362
|
+
preflight = json.loads(preflight_data)
|
|
363
|
+
except json.JSONDecodeError as exc:
|
|
364
|
+
raise CliError("Direct-release preflight returned invalid JSON.") from exc
|
|
365
|
+
token = preflight.get("confirmationToken")
|
|
366
|
+
if not isinstance(token, str):
|
|
367
|
+
raise CliError("Direct-release preflight did not return a confirmation token.")
|
|
368
|
+
_print_direct_release_preflight(preflight)
|
|
369
|
+
endpoint = get_endpoint("changes.direct-release")
|
|
370
|
+
confirm_change(endpoint, args.yes)
|
|
371
|
+
data = client.request(
|
|
372
|
+
"changes.direct-release",
|
|
373
|
+
body={"message": args.message, "confirmationToken": token},
|
|
374
|
+
)
|
|
375
|
+
print_output(data, pretty=args.pretty, raw=args.raw)
|
|
376
|
+
return 0
|
|
272
377
|
return run_request(args, f"changes.{args.changes_action}", body={"message": args.message})
|
|
273
378
|
|
|
274
379
|
|
|
380
|
+
def handle_versions(args: argparse.Namespace) -> int:
|
|
381
|
+
if args.versions_action == "show":
|
|
382
|
+
return run_request(
|
|
383
|
+
args,
|
|
384
|
+
"versions.get",
|
|
385
|
+
path_params={"version_id": args.version_id},
|
|
386
|
+
)
|
|
387
|
+
if args.versions_action == "releases":
|
|
388
|
+
return run_request(args, "releases.list")
|
|
389
|
+
return run_request(
|
|
390
|
+
args,
|
|
391
|
+
"versions.list",
|
|
392
|
+
query={
|
|
393
|
+
"q": args.query,
|
|
394
|
+
"resource": args.resource,
|
|
395
|
+
"changeType": args.change_type,
|
|
396
|
+
"releaseStatus": args.release_status,
|
|
397
|
+
"channel": args.channel,
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _print_direct_release_preflight(preflight: dict[str, Any]) -> None:
|
|
403
|
+
print("Production direct-release preflight:", file=sys.stderr)
|
|
404
|
+
print(f" Current main: {preflight.get('mainCommit', 'unknown')}", file=sys.stderr)
|
|
405
|
+
print(f" Draft revision: {preflight.get('draftRevision', 'unknown')}", file=sys.stderr)
|
|
406
|
+
print(
|
|
407
|
+
f" Changes: {preflight.get('semanticCount', 0)} semantic, "
|
|
408
|
+
f"{preflight.get('fileCount', 0)} files",
|
|
409
|
+
file=sys.stderr,
|
|
410
|
+
)
|
|
411
|
+
for check in preflight.get("checks", []):
|
|
412
|
+
if isinstance(check, dict):
|
|
413
|
+
mark = "OK" if check.get("ok") else "FAIL"
|
|
414
|
+
print(f" [{mark}] {check.get('label', '')}", file=sys.stderr)
|
|
415
|
+
print(f" RISK: {preflight.get('risk', 'This bypasses test environments.')}", file=sys.stderr)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _environment_confirmation(args: argparse.Namespace, label: str) -> str:
|
|
419
|
+
value = (args.confirm or "").strip()
|
|
420
|
+
if not value:
|
|
421
|
+
if not sys.stdin.isatty():
|
|
422
|
+
raise CliError(
|
|
423
|
+
"Refusing to restart without --confirm '<environment name>' in non-interactive mode."
|
|
424
|
+
)
|
|
425
|
+
value = input(f"Type {label} to confirm: ").strip()
|
|
426
|
+
if value != label:
|
|
427
|
+
raise CliError("Environment confirmation does not match the preflight result.")
|
|
428
|
+
return value
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _print_environment_restart_preflight(preflight: dict[str, Any]) -> None:
|
|
432
|
+
current = str(preflight.get("currentCommit", "unknown"))
|
|
433
|
+
main = str(preflight.get("mainCommit", "unknown"))
|
|
434
|
+
print("Test-environment restart preflight:", file=sys.stderr)
|
|
435
|
+
print(f" Environment: {preflight.get('environmentLabel', 'unknown')}", file=sys.stderr)
|
|
436
|
+
print(f" Current test version: {current[:7]}", file=sys.stderr)
|
|
437
|
+
print(f" Latest production version: {main[:7]}", file=sys.stderr)
|
|
438
|
+
print(f" Drafts: {preflight.get('draftCount', 0)}", file=sys.stderr)
|
|
439
|
+
print(f" Test records: {preflight.get('recordCount', 0)}", file=sys.stderr)
|
|
440
|
+
for check in preflight.get("checks", []):
|
|
441
|
+
if isinstance(check, dict):
|
|
442
|
+
mark = "OK" if check.get("ok") else "FAIL"
|
|
443
|
+
print(f" [{mark}] {check.get('label', '')}", file=sys.stderr)
|
|
444
|
+
print(
|
|
445
|
+
f" RISK: {preflight.get('risk', 'All test configuration and records will be cleared.')}",
|
|
446
|
+
file=sys.stderr,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def handle_environments(args: argparse.Namespace) -> int:
|
|
451
|
+
client = client_from_args(args)
|
|
452
|
+
preflight_data = client.request("environments.restart-preflight")
|
|
453
|
+
try:
|
|
454
|
+
preflight = json.loads(preflight_data)
|
|
455
|
+
except json.JSONDecodeError as exc:
|
|
456
|
+
raise CliError("Environment restart preflight returned invalid JSON.") from exc
|
|
457
|
+
_print_environment_restart_preflight(preflight)
|
|
458
|
+
if not preflight.get("canRestart"):
|
|
459
|
+
raise CliError("Environment restart preflight failed. Resolve failed checks first.")
|
|
460
|
+
label = preflight.get("environmentLabel")
|
|
461
|
+
if not isinstance(label, str) or not label:
|
|
462
|
+
raise CliError("Environment restart preflight did not return an environment label.")
|
|
463
|
+
confirm = _environment_confirmation(args, label)
|
|
464
|
+
endpoint = get_endpoint("environments.restart")
|
|
465
|
+
confirm_change(endpoint, args.yes)
|
|
466
|
+
body = {
|
|
467
|
+
"confirm": confirm,
|
|
468
|
+
"expectedCurrentCommit": preflight.get("currentCommit"),
|
|
469
|
+
"expectedMainCommit": preflight.get("mainCommit"),
|
|
470
|
+
"expectedDraftRevision": preflight.get("draftRevision"),
|
|
471
|
+
"expectedRecordCount": preflight.get("recordCount"),
|
|
472
|
+
}
|
|
473
|
+
if any(value is None for value in body.values()):
|
|
474
|
+
raise CliError("Environment restart preflight returned incomplete binding state.")
|
|
475
|
+
data = client.request("environments.restart", body=body)
|
|
476
|
+
print_output(data, pretty=args.pretty, raw=args.raw)
|
|
477
|
+
return 0
|
|
478
|
+
|
|
479
|
+
|
|
275
480
|
def handle_records(args: argparse.Namespace) -> int:
|
|
276
481
|
action = args.records_action
|
|
277
482
|
path = {"object_id": args.object_id}
|
|
@@ -317,6 +522,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
317
522
|
docs = groups.add_parser("docs", help="Inspect the offline endpoint catalog.")
|
|
318
523
|
docs_actions = docs.add_subparsers(dest="docs_action", required=True)
|
|
319
524
|
docs_actions.add_parser("list")
|
|
525
|
+
docs_actions.add_parser("operations")
|
|
320
526
|
docs_show = docs_actions.add_parser("show")
|
|
321
527
|
docs_show.add_argument("endpoint", choices=sorted(ENDPOINT_BY_KEY))
|
|
322
528
|
docs.set_defaults(handler=handle_docs)
|
|
@@ -354,14 +560,69 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
354
560
|
add_yes(item)
|
|
355
561
|
fields.set_defaults(handler=handle_fields)
|
|
356
562
|
|
|
357
|
-
|
|
563
|
+
pages = groups.add_parser("pages", help="Manage custom Page metadata.")
|
|
564
|
+
page_actions = pages.add_subparsers(dest="pages_action", required=True)
|
|
565
|
+
page_list = page_actions.add_parser("list")
|
|
566
|
+
page_list.add_argument("--status", choices=("active", "deleted"), default="active")
|
|
567
|
+
page_list.add_argument("--object-id")
|
|
568
|
+
page_list.add_argument("--type", choices=("create", "detail"))
|
|
569
|
+
page_list.add_argument("--keyword")
|
|
570
|
+
page_list.add_argument("--page", type=int, default=1)
|
|
571
|
+
page_actions.add_parser("options")
|
|
572
|
+
page_get = page_actions.add_parser("get")
|
|
573
|
+
page_get.add_argument("page_id")
|
|
574
|
+
page_create = page_actions.add_parser("create")
|
|
575
|
+
add_json_args(page_create)
|
|
576
|
+
page_update = page_actions.add_parser("update")
|
|
577
|
+
page_update.add_argument("page_id")
|
|
578
|
+
add_json_args(page_update)
|
|
579
|
+
for action in ("set-default", "delete", "restore"):
|
|
580
|
+
item = page_actions.add_parser(action)
|
|
581
|
+
item.add_argument("page_id")
|
|
582
|
+
item.add_argument("--base-revision", required=True, type=int)
|
|
583
|
+
add_yes(item)
|
|
584
|
+
pages.set_defaults(handler=handle_pages)
|
|
585
|
+
|
|
586
|
+
changes = groups.add_parser("changes", help="Commit or release metadata versions.")
|
|
358
587
|
change_actions = changes.add_subparsers(dest="changes_action", required=True)
|
|
359
|
-
for action in ("commit", "release"):
|
|
588
|
+
for action in ("commit", "release", "direct-release"):
|
|
360
589
|
item = change_actions.add_parser(action)
|
|
361
590
|
item.add_argument("--message", required=True)
|
|
362
591
|
add_yes(item)
|
|
363
592
|
changes.set_defaults(handler=handle_changes)
|
|
364
593
|
|
|
594
|
+
versions = groups.add_parser("versions", help="Inspect read-only configuration history.")
|
|
595
|
+
version_actions = versions.add_subparsers(dest="versions_action", required=True)
|
|
596
|
+
version_list = version_actions.add_parser("list")
|
|
597
|
+
version_list.add_argument("--query")
|
|
598
|
+
version_list.add_argument("--resource")
|
|
599
|
+
version_list.add_argument(
|
|
600
|
+
"--change-type",
|
|
601
|
+
choices=("business-change", "format-migration"),
|
|
602
|
+
)
|
|
603
|
+
version_list.add_argument(
|
|
604
|
+
"--release-status",
|
|
605
|
+
choices=("released", "unreleased"),
|
|
606
|
+
)
|
|
607
|
+
version_list.add_argument(
|
|
608
|
+
"--channel",
|
|
609
|
+
choices=("web", "openapi", "cli", "ai", "system-migration"),
|
|
610
|
+
)
|
|
611
|
+
version_show = version_actions.add_parser("show")
|
|
612
|
+
version_show.add_argument("version_id")
|
|
613
|
+
version_actions.add_parser("releases")
|
|
614
|
+
versions.set_defaults(handler=handle_versions)
|
|
615
|
+
|
|
616
|
+
environments = groups.add_parser("environments", help="Manage the current test environment.")
|
|
617
|
+
environment_actions = environments.add_subparsers(dest="environments_action", required=True)
|
|
618
|
+
environment_restart = environment_actions.add_parser("restart")
|
|
619
|
+
environment_restart.add_argument(
|
|
620
|
+
"--confirm",
|
|
621
|
+
help="Exact environment name returned by preflight. Required in non-interactive mode.",
|
|
622
|
+
)
|
|
623
|
+
add_yes(environment_restart)
|
|
624
|
+
environments.set_defaults(handler=handle_environments)
|
|
625
|
+
|
|
365
626
|
records = groups.add_parser("records", help="Manage business records.")
|
|
366
627
|
record_actions = records.add_subparsers(dest="records_action", required=True)
|
|
367
628
|
record_list = record_actions.add_parser("list")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flowbase-cli
|
|
3
|
+
Version: 0.1.3
|
|
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 pages list --object-id obj_xxx
|
|
26
|
+
flowbase pages create --json-file page.json
|
|
27
|
+
flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
|
|
28
|
+
flowbase versions list --resource customer --change-type business-change
|
|
29
|
+
flowbase versions show abc1234
|
|
30
|
+
flowbase versions releases
|
|
31
|
+
flowbase changes commit --message "Add customer field" --yes
|
|
32
|
+
flowbase changes release --message "Release customer field" --yes
|
|
33
|
+
flowbase environments restart --confirm "A 测试环境" --yes
|
|
34
|
+
flowbase docs list
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--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,309 @@
|
|
|
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
|
+
|
|
92
|
+
def test_page_set_default_sends_revision_and_requires_yes(self):
|
|
93
|
+
stderr = io.StringIO()
|
|
94
|
+
with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
|
|
95
|
+
code = main(["--api-key", "fbk_test", "pages", "set-default", "pag_1", "--base-revision", "4"])
|
|
96
|
+
self.assertEqual(code, 1)
|
|
97
|
+
self.assertIn("without --yes", stderr.getvalue())
|
|
98
|
+
|
|
99
|
+
stdout = io.StringIO()
|
|
100
|
+
with (
|
|
101
|
+
patch("flowbase_cli.main.OpenApiClient.request", return_value=b'{"draftRevision":5}') as request,
|
|
102
|
+
patch("sys.stdout", stdout),
|
|
103
|
+
):
|
|
104
|
+
code = main([
|
|
105
|
+
"--api-key", "fbk_test", "pages", "set-default", "pag_1",
|
|
106
|
+
"--base-revision", "4", "--yes",
|
|
107
|
+
])
|
|
108
|
+
self.assertEqual(code, 0)
|
|
109
|
+
self.assertEqual(request.call_args.args[0], "pages.set-default")
|
|
110
|
+
self.assertEqual(request.call_args.kwargs["body"], {"baseRevision": 4})
|
|
111
|
+
|
|
112
|
+
def test_page_create_requires_full_request_body(self):
|
|
113
|
+
stderr = io.StringIO()
|
|
114
|
+
with patch("sys.stderr", stderr):
|
|
115
|
+
code = main(["pages", "create", "--json", '{"baseRevision":0}'])
|
|
116
|
+
self.assertEqual(code, 1)
|
|
117
|
+
self.assertIn("page", stderr.getvalue())
|
|
118
|
+
|
|
119
|
+
def test_version_show_uses_read_only_version_endpoint(self):
|
|
120
|
+
stdout = io.StringIO()
|
|
121
|
+
with (
|
|
122
|
+
patch(
|
|
123
|
+
"flowbase_cli.main.OpenApiClient.request",
|
|
124
|
+
return_value=b'{"commit":"abcdef0"}',
|
|
125
|
+
) as request,
|
|
126
|
+
patch("sys.stdout", stdout),
|
|
127
|
+
):
|
|
128
|
+
code = main([
|
|
129
|
+
"--api-key",
|
|
130
|
+
"fbk_test",
|
|
131
|
+
"versions",
|
|
132
|
+
"show",
|
|
133
|
+
"abcdef0",
|
|
134
|
+
])
|
|
135
|
+
self.assertEqual(code, 0)
|
|
136
|
+
self.assertEqual(request.call_args.args[0], "versions.get")
|
|
137
|
+
self.assertEqual(
|
|
138
|
+
request.call_args.kwargs["path_params"],
|
|
139
|
+
{"version_id": "abcdef0"},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def test_management_operation_catalog_identifies_pending_gaps(self):
|
|
143
|
+
stdout = io.StringIO()
|
|
144
|
+
with patch("sys.stdout", stdout):
|
|
145
|
+
code = main(["docs", "operations"])
|
|
146
|
+
self.assertEqual(code, 0)
|
|
147
|
+
payload = json.loads(stdout.getvalue())
|
|
148
|
+
self.assertTrue(any(item["key"] == "changes.direct-release" and item["status"] == "delivered" for item in payload))
|
|
149
|
+
self.assertTrue(any(item["key"] == "changes.clear-drafts" and item["status"] == "pending" for item in payload))
|
|
150
|
+
|
|
151
|
+
def test_object_create_requires_business_description(self):
|
|
152
|
+
stderr = io.StringIO()
|
|
153
|
+
with patch("sys.stderr", stderr):
|
|
154
|
+
code = main(["objects", "create", "--label", "Customer", "--key", "customer"])
|
|
155
|
+
self.assertEqual(code, 1)
|
|
156
|
+
self.assertIn("description", stderr.getvalue())
|
|
157
|
+
|
|
158
|
+
def test_noninteractive_delete_requires_yes(self):
|
|
159
|
+
stderr = io.StringIO()
|
|
160
|
+
with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
|
|
161
|
+
code = main(["--api-key", "fbk_test", "records", "delete", "obj_1", "rec_1"])
|
|
162
|
+
self.assertEqual(code, 1)
|
|
163
|
+
self.assertIn("without --yes", stderr.getvalue())
|
|
164
|
+
|
|
165
|
+
def test_noninteractive_commit_requires_yes(self):
|
|
166
|
+
stderr = io.StringIO()
|
|
167
|
+
with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
|
|
168
|
+
code = main(["--api-key", "fbk_test", "changes", "commit", "--message", "Add customer field"])
|
|
169
|
+
self.assertEqual(code, 1)
|
|
170
|
+
self.assertIn("without --yes", stderr.getvalue())
|
|
171
|
+
|
|
172
|
+
def test_direct_release_always_preflights_before_execution(self):
|
|
173
|
+
preflight = {
|
|
174
|
+
"confirmationToken": "fbc_secret",
|
|
175
|
+
"mainCommit": "abc1234",
|
|
176
|
+
"draftRevision": 4,
|
|
177
|
+
"semanticCount": 2,
|
|
178
|
+
"fileCount": 1,
|
|
179
|
+
"checks": [{"label": "Configuration valid", "ok": True}],
|
|
180
|
+
"risk": "Bypasses test environments.",
|
|
181
|
+
}
|
|
182
|
+
stdout = io.StringIO()
|
|
183
|
+
stderr = io.StringIO()
|
|
184
|
+
with (
|
|
185
|
+
patch(
|
|
186
|
+
"flowbase_cli.main.OpenApiClient.request",
|
|
187
|
+
side_effect=[
|
|
188
|
+
json.dumps(preflight).encode(),
|
|
189
|
+
b'{"mainCommit":"def5678"}',
|
|
190
|
+
],
|
|
191
|
+
) as request,
|
|
192
|
+
patch("sys.stdout", stdout),
|
|
193
|
+
patch("sys.stderr", stderr),
|
|
194
|
+
):
|
|
195
|
+
code = main([
|
|
196
|
+
"--api-key",
|
|
197
|
+
"fbk_test",
|
|
198
|
+
"changes",
|
|
199
|
+
"direct-release",
|
|
200
|
+
"--message",
|
|
201
|
+
"Production fix",
|
|
202
|
+
"--yes",
|
|
203
|
+
])
|
|
204
|
+
self.assertEqual(code, 0)
|
|
205
|
+
self.assertEqual(request.call_count, 2)
|
|
206
|
+
self.assertEqual(
|
|
207
|
+
request.call_args_list[0].args[0],
|
|
208
|
+
"changes.direct-release-preflight",
|
|
209
|
+
)
|
|
210
|
+
self.assertEqual(
|
|
211
|
+
request.call_args_list[1].kwargs["body"]["confirmationToken"],
|
|
212
|
+
"fbc_secret",
|
|
213
|
+
)
|
|
214
|
+
self.assertNotIn("fbc_secret", stdout.getvalue())
|
|
215
|
+
self.assertNotIn("fbc_secret", stderr.getvalue())
|
|
216
|
+
self.assertIn("Bypasses test environments", stderr.getvalue())
|
|
217
|
+
|
|
218
|
+
def test_environment_restart_preflights_and_binds_execution(self):
|
|
219
|
+
preflight = {
|
|
220
|
+
"environmentId": "test-a",
|
|
221
|
+
"environmentLabel": "A 测试环境",
|
|
222
|
+
"currentCommit": "a" * 40,
|
|
223
|
+
"mainCommit": "b" * 40,
|
|
224
|
+
"draftRevision": 7,
|
|
225
|
+
"draftCount": 0,
|
|
226
|
+
"recordCount": 3,
|
|
227
|
+
"checks": [
|
|
228
|
+
{"label": "当前环境为已启用测试环境", "ok": True},
|
|
229
|
+
{"label": "当前环境无待提交草稿", "ok": True},
|
|
230
|
+
],
|
|
231
|
+
"canRestart": True,
|
|
232
|
+
"risk": "All test records will be deleted.",
|
|
233
|
+
}
|
|
234
|
+
stdout = io.StringIO()
|
|
235
|
+
stderr = io.StringIO()
|
|
236
|
+
with (
|
|
237
|
+
patch(
|
|
238
|
+
"flowbase_cli.main.OpenApiClient.request",
|
|
239
|
+
side_effect=[
|
|
240
|
+
json.dumps(preflight).encode(),
|
|
241
|
+
b'{"currentCommit":"bbbbbbb","clearedRecordCount":3}',
|
|
242
|
+
],
|
|
243
|
+
) as request,
|
|
244
|
+
patch("sys.stdout", stdout),
|
|
245
|
+
patch("sys.stderr", stderr),
|
|
246
|
+
):
|
|
247
|
+
code = main([
|
|
248
|
+
"--api-key",
|
|
249
|
+
"fbk_test",
|
|
250
|
+
"environments",
|
|
251
|
+
"restart",
|
|
252
|
+
"--confirm",
|
|
253
|
+
"A 测试环境",
|
|
254
|
+
"--yes",
|
|
255
|
+
])
|
|
256
|
+
|
|
257
|
+
self.assertEqual(code, 0)
|
|
258
|
+
self.assertEqual(request.call_count, 2)
|
|
259
|
+
self.assertEqual(request.call_args_list[0].args[0], "environments.restart-preflight")
|
|
260
|
+
self.assertEqual(request.call_args_list[1].args[0], "environments.restart")
|
|
261
|
+
self.assertEqual(
|
|
262
|
+
request.call_args_list[1].kwargs["body"],
|
|
263
|
+
{
|
|
264
|
+
"confirm": "A 测试环境",
|
|
265
|
+
"expectedCurrentCommit": "a" * 40,
|
|
266
|
+
"expectedMainCommit": "b" * 40,
|
|
267
|
+
"expectedDraftRevision": 7,
|
|
268
|
+
"expectedRecordCount": 3,
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
self.assertIn("All test records will be deleted", stderr.getvalue())
|
|
272
|
+
|
|
273
|
+
def test_environment_restart_noninteractive_requires_name_confirmation(self):
|
|
274
|
+
preflight = {
|
|
275
|
+
"environmentId": "test-a",
|
|
276
|
+
"environmentLabel": "A 测试环境",
|
|
277
|
+
"currentCommit": "a" * 40,
|
|
278
|
+
"mainCommit": "b" * 40,
|
|
279
|
+
"draftRevision": 0,
|
|
280
|
+
"draftCount": 0,
|
|
281
|
+
"recordCount": 0,
|
|
282
|
+
"checks": [],
|
|
283
|
+
"canRestart": True,
|
|
284
|
+
"risk": "Destructive.",
|
|
285
|
+
}
|
|
286
|
+
stderr = io.StringIO()
|
|
287
|
+
with (
|
|
288
|
+
patch(
|
|
289
|
+
"flowbase_cli.main.OpenApiClient.request",
|
|
290
|
+
return_value=json.dumps(preflight).encode(),
|
|
291
|
+
) as request,
|
|
292
|
+
patch("sys.stderr", stderr),
|
|
293
|
+
patch("sys.stdin.isatty", return_value=False),
|
|
294
|
+
):
|
|
295
|
+
code = main([
|
|
296
|
+
"--api-key",
|
|
297
|
+
"fbk_test",
|
|
298
|
+
"environments",
|
|
299
|
+
"restart",
|
|
300
|
+
"--yes",
|
|
301
|
+
])
|
|
302
|
+
|
|
303
|
+
self.assertEqual(code, 1)
|
|
304
|
+
self.assertEqual(request.call_count, 1)
|
|
305
|
+
self.assertIn("--confirm", stderr.getvalue())
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
unittest.main()
|
flowbase_cli-0.1.1/PKG-INFO
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: flowbase-cli
|
|
3
|
-
Version: 0.1.1
|
|
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.
|
flowbase_cli-0.1.1/README.md
DELETED
|
@@ -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,39 +0,0 @@
|
|
|
1
|
-
"""CLI endpoint catalog, contract-tested against the server OpenAPI schema."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@dataclass(frozen=True)
|
|
8
|
-
class Endpoint:
|
|
9
|
-
key: str
|
|
10
|
-
operation_id: str
|
|
11
|
-
method: str
|
|
12
|
-
path: str
|
|
13
|
-
scope: str
|
|
14
|
-
summary: str
|
|
15
|
-
example: str
|
|
16
|
-
requires_confirmation: bool = False
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
ENDPOINTS = (
|
|
20
|
-
Endpoint("objects.list", "flowbase_objects_list", "GET", "/objects", "metadata:read", "List object metadata.", "flowbase objects list"),
|
|
21
|
-
Endpoint("objects.get", "flowbase_objects_get", "GET", "/objects/{object_id}", "metadata:read", "Get object and field metadata.", "flowbase objects get obj_xxx"),
|
|
22
|
-
Endpoint("objects.create", "flowbase_objects_create", "POST", "/objects", "metadata:write", "Create an object draft.", "flowbase objects create --json-file object.json"),
|
|
23
|
-
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'"),
|
|
24
|
-
Endpoint("objects.delete", "flowbase_objects_delete", "DELETE", "/objects/{object_id}", "metadata:write", "Logically delete an object.", "flowbase objects delete obj_xxx --yes", True),
|
|
25
|
-
Endpoint("objects.restore", "flowbase_objects_restore", "POST", "/objects/{object_id}/restore", "metadata:write", "Restore an object.", "flowbase objects restore obj_xxx --yes", True),
|
|
26
|
-
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"),
|
|
27
|
-
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
|
-
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
|
-
Endpoint("fields.restore", "flowbase_fields_restore", "POST", "/objects/{object_id}/fields/{field_id}/restore", "metadata:write", "Restore a field.", "flowbase fields restore obj_xxx fld_xxx --yes", True),
|
|
30
|
-
Endpoint("changes.commit", "flowbase_changes_commit", "POST", "/changes/commit", "metadata:write", "Commit current test-environment drafts.", "flowbase changes commit --message 'Add customer field' --yes", True),
|
|
31
|
-
Endpoint("changes.release", "flowbase_changes_release", "POST", "/changes/release", "metadata:write", "Release the current test-environment version.", "flowbase changes release --message 'Release customer field' --yes", True),
|
|
32
|
-
Endpoint("records.list", "flowbase_records_list", "GET", "/objects/{object_id}/records", "records:read", "List records.", "flowbase records list obj_xxx --page 1"),
|
|
33
|
-
Endpoint("records.get", "flowbase_records_get", "GET", "/objects/{object_id}/records/{record_id}", "records:read", "Get a record.", "flowbase records get obj_xxx rec_xxx"),
|
|
34
|
-
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
|
-
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"),
|
|
36
|
-
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),
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
ENDPOINT_BY_KEY = {endpoint.key: endpoint for endpoint in ENDPOINTS}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: flowbase-cli
|
|
3
|
-
Version: 0.1.1
|
|
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,101 +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_object_create_requires_business_description(self):
|
|
79
|
-
stderr = io.StringIO()
|
|
80
|
-
with patch("sys.stderr", stderr):
|
|
81
|
-
code = main(["objects", "create", "--label", "Customer", "--key", "customer"])
|
|
82
|
-
self.assertEqual(code, 1)
|
|
83
|
-
self.assertIn("description", stderr.getvalue())
|
|
84
|
-
|
|
85
|
-
def test_noninteractive_delete_requires_yes(self):
|
|
86
|
-
stderr = io.StringIO()
|
|
87
|
-
with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
|
|
88
|
-
code = main(["--api-key", "fbk_test", "records", "delete", "obj_1", "rec_1"])
|
|
89
|
-
self.assertEqual(code, 1)
|
|
90
|
-
self.assertIn("without --yes", stderr.getvalue())
|
|
91
|
-
|
|
92
|
-
def test_noninteractive_commit_requires_yes(self):
|
|
93
|
-
stderr = io.StringIO()
|
|
94
|
-
with patch("sys.stderr", stderr), patch("sys.stdin.isatty", return_value=False):
|
|
95
|
-
code = main(["--api-key", "fbk_test", "changes", "commit", "--message", "Add customer field"])
|
|
96
|
-
self.assertEqual(code, 1)
|
|
97
|
-
self.assertIn("without --yes", stderr.getvalue())
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if __name__ == "__main__":
|
|
101
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|