buildify-cli 1.0.0__tar.gz → 1.1.0__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.
Files changed (24) hide show
  1. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/PKG-INFO +2 -2
  2. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/README.md +1 -1
  3. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/pyproject.toml +1 -1
  4. buildify_cli-1.1.0/src/buildify_cli/__init__.py +1 -0
  5. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli/cli.py +159 -3
  6. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli/client.py +86 -0
  7. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli/config.py +2 -2
  8. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli/publish_view.py +71 -0
  9. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli/usage.md +44 -3
  10. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli.egg-info/PKG-INFO +2 -2
  11. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli.egg-info/SOURCES.txt +2 -1
  12. buildify_cli-1.1.0/tests/test_history_view.py +57 -0
  13. buildify_cli-1.0.0/src/buildify_cli/__init__.py +0 -1
  14. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/setup.cfg +0 -0
  15. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli/__main__.py +0 -0
  16. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli/credential_form.py +0 -0
  17. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli/exit_codes.py +0 -0
  18. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli/output.py +0 -0
  19. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli.egg-info/dependency_links.txt +0 -0
  20. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli.egg-info/entry_points.txt +0 -0
  21. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli.egg-info/requires.txt +0 -0
  22. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/src/buildify_cli.egg-info/top_level.txt +0 -0
  23. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/tests/test_auth_config.py +0 -0
  24. {buildify_cli-1.0.0 → buildify_cli-1.1.0}/tests/test_credential_form.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildify-cli
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: CLI for the Buildify open API: discover bundle nodes, author and test workflows (machine Bearer auth)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -66,7 +66,7 @@ interactive prompt. `config set-key api_key` still updates the current profile,
66
66
  | `key` | `test` |
67
67
  | `bundle` | `list`, `nodes`, `node-properties`, `node-relations`, `doc`, `cred-types`, `cred-properties` |
68
68
  | `project` | `list`, `create`, `get`, `credentials`, `create-credential`, `workers` |
69
- | `workflow` | `list`, `create`, `get-draft`, `save-draft`, `validate`, `test-run`, `cancel-test`, `deploy`, `published`, `deployment` |
69
+ | `workflow` | `list`, `create`, `get-draft`, `save-draft`, `validate`, `test-run`, `cancel-test`, `deploy`, `published`, `deployments`, `deployment`, `versions`, `get-version` |
70
70
  | `config` | `init`, `set-key`, `add-profile`, `use`, `profiles`, `remove-profile`, `show` |
71
71
 
72
72
  Every command accepts `--json` for a single-line envelope
@@ -53,7 +53,7 @@ interactive prompt. `config set-key api_key` still updates the current profile,
53
53
  | `key` | `test` |
54
54
  | `bundle` | `list`, `nodes`, `node-properties`, `node-relations`, `doc`, `cred-types`, `cred-properties` |
55
55
  | `project` | `list`, `create`, `get`, `credentials`, `create-credential`, `workers` |
56
- | `workflow` | `list`, `create`, `get-draft`, `save-draft`, `validate`, `test-run`, `cancel-test`, `deploy`, `published`, `deployment` |
56
+ | `workflow` | `list`, `create`, `get-draft`, `save-draft`, `validate`, `test-run`, `cancel-test`, `deploy`, `published`, `deployments`, `deployment`, `versions`, `get-version` |
57
57
  | `config` | `init`, `set-key`, `add-profile`, `use`, `profiles`, `remove-profile`, `show` |
58
58
 
59
59
  Every command accepts `--json` for a single-line envelope
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "buildify-cli"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "CLI for the Buildify open API: discover bundle nodes, author and test workflows (machine Bearer auth)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0"
@@ -47,7 +47,12 @@ from buildify_cli.credential_form import (
47
47
  prompt_payload,
48
48
  )
49
49
  from buildify_cli.output import human_err, is_tty, print_json
50
- from buildify_cli.publish_view import format_publish_report, is_publish_payload
50
+ from buildify_cli.publish_view import (
51
+ format_deployment_records,
52
+ format_publish_report,
53
+ format_version_records,
54
+ is_publish_payload,
55
+ )
51
56
 
52
57
  app = typer.Typer(
53
58
  help="Buildify open API CLI: discover nodes, author workflows, validate, test run and deploy.",
@@ -62,7 +67,7 @@ app = typer.Typer(
62
67
  " bundle Bundle / node discovery: list, nodes, node-properties, node-relations, doc, cred-types, cred-properties\n"
63
68
  " project list, create, get, credentials, create-credential, workers\n"
64
69
  " workflow list, create, get-draft, save-draft, validate, test-run, cancel-test,\n"
65
- " deploy, published, deployment\n"
70
+ " deploy, published, deployments, deployment, versions, get-version\n"
66
71
  " config Manage the [openapi] section of ~/.buildifyrc\n"
67
72
  " version Print CLI version\n"
68
73
  " help Markdown reference on stdout (for AI/agents)\n\n"
@@ -248,6 +253,15 @@ def _emit_network_error(ctx: typer.Context, exc: BaseException) -> int:
248
253
  return ec.NETWORK
249
254
 
250
255
 
256
+ def _show_page(ctx: typer.Context, data: Any, formatter) -> None:
257
+ """Pretty-print a page for humans; `--json` keeps the server payload."""
258
+ if _cfg(ctx).json_output:
259
+ print_json(ok=True, code=200, msg="ok", http_status=200, data=data)
260
+ else:
261
+ typer.echo(formatter(data))
262
+ raise typer.Exit(ec.OK)
263
+
264
+
251
265
  def _call(ctx: typer.Context, fn, *args, **kwargs) -> None:
252
266
  """Run one client call and exit with the mapped code."""
253
267
  try:
@@ -462,6 +476,25 @@ def cmd_project_get(ctx: typer.Context, project: str = _OPT_PROJECT) -> None:
462
476
  _call(ctx, c.project_get, project)
463
477
 
464
478
 
479
+ @project_app.command("update")
480
+ def cmd_project_update(
481
+ ctx: typer.Context,
482
+ project: str = _OPT_PROJECT,
483
+ name: str = typer.Option(..., "--name", "-n", help="New project name."),
484
+ remark: Optional[str] = typer.Option(None, "--remark", help="New description. Omit to keep the current one."),
485
+ ) -> None:
486
+ """Rename a project or change its description."""
487
+ with _client(ctx) as c:
488
+ _call(ctx, c.project_update, project, name, remark)
489
+
490
+
491
+ @project_app.command("delete")
492
+ def cmd_project_delete(ctx: typer.Context, project: str = _OPT_PROJECT) -> None:
493
+ """Delete a project that has no workflows. Confirm with the user first."""
494
+ with _client(ctx) as c:
495
+ _call(ctx, c.project_delete, project)
496
+
497
+
465
498
  @project_app.command("credentials")
466
499
  def cmd_project_credentials(
467
500
  ctx: typer.Context,
@@ -688,6 +721,74 @@ def cmd_flow_create(
688
721
  _call(ctx, c.flow_create, project, workflow_name=name, worker_ids=list(worker), remark=remark)
689
722
 
690
723
 
724
+ _OPT_WORKERS = typer.Option(
725
+ ..., "--worker", help="Worker public id; repeat for multiple. See `buildify project workers`."
726
+ )
727
+
728
+
729
+ @flow_app.command("update")
730
+ def cmd_flow_update(
731
+ ctx: typer.Context,
732
+ project: str = _OPT_PROJECT,
733
+ flow: str = _OPT_FLOW,
734
+ name: str = typer.Option(..., "--name", "-n", help="New workflow name."),
735
+ remark: Optional[str] = typer.Option(None, "--remark", help="New description. Omit to keep the current one."),
736
+ ) -> None:
737
+ """Rename a workflow or change its description."""
738
+ with _client(ctx) as c:
739
+ _call(ctx, c.flow_update, project, flow, workflow_name=name, remark=remark)
740
+
741
+
742
+ @flow_app.command("delete")
743
+ def cmd_flow_delete(ctx: typer.Context, project: str = _OPT_PROJECT, flow: str = _OPT_FLOW) -> None:
744
+ """Delete a workflow that has no bound servers. Confirm with the user first."""
745
+ with _client(ctx) as c:
746
+ _call(ctx, c.flow_delete, project, flow)
747
+
748
+
749
+ @flow_app.command("workers")
750
+ def cmd_flow_workers(ctx: typer.Context, project: str = _OPT_PROJECT, flow: str = _OPT_FLOW) -> None:
751
+ """Servers bound to a workflow, including ones waiting to be removed."""
752
+ with _client(ctx) as c:
753
+ _call(ctx, c.flow_workers, project, flow)
754
+
755
+
756
+ @flow_app.command("assign-workers")
757
+ def cmd_flow_assign_workers(
758
+ ctx: typer.Context,
759
+ project: str = _OPT_PROJECT,
760
+ flow: str = _OPT_FLOW,
761
+ worker: list[str] = _OPT_WORKERS,
762
+ ) -> None:
763
+ """Bind extra servers. A published flow is deployed to the new servers."""
764
+ with _client(ctx) as c:
765
+ _call(ctx, c.flow_assign_workers, project, flow, list(worker))
766
+
767
+
768
+ @flow_app.command("unassign-workers")
769
+ def cmd_flow_unassign_workers(
770
+ ctx: typer.Context,
771
+ project: str = _OPT_PROJECT,
772
+ flow: str = _OPT_FLOW,
773
+ worker: list[str] = _OPT_WORKERS,
774
+ ) -> None:
775
+ """Unbind servers. A published flow is undeployed and removed after the server confirms."""
776
+ with _client(ctx) as c:
777
+ _call(ctx, c.flow_unassign_workers, project, flow, list(worker))
778
+
779
+
780
+ @flow_app.command("force-unassign-workers")
781
+ def cmd_flow_force_unassign_workers(
782
+ ctx: typer.Context,
783
+ project: str = _OPT_PROJECT,
784
+ flow: str = _OPT_FLOW,
785
+ worker: list[str] = _OPT_WORKERS,
786
+ ) -> None:
787
+ """Drop server bindings immediately. Use when the server is gone or a normal unassign is stuck."""
788
+ with _client(ctx) as c:
789
+ _call(ctx, c.flow_force_unassign_workers, project, flow, list(worker))
790
+
791
+
691
792
  @flow_app.command("get-draft")
692
793
  def cmd_flow_get_draft(ctx: typer.Context, project: str = _OPT_PROJECT, flow: str = _OPT_FLOW) -> None:
693
794
  """Draft canvas JSON plus the optimistic-lock `version` needed to save it back."""
@@ -901,6 +1002,35 @@ def cmd_flow_published(ctx: typer.Context, project: str = _OPT_PROJECT, flow: st
901
1002
  _call(ctx, c.flow_published, project, flow)
902
1003
 
903
1004
 
1005
+ @flow_app.command("deployments")
1006
+ def cmd_flow_deployments(
1007
+ ctx: typer.Context,
1008
+ project: str = _OPT_PROJECT,
1009
+ flow: str = _OPT_FLOW,
1010
+ deployment: Optional[str] = typer.Option(
1011
+ None, "--deployment", "-d", help="Only this deployment public id (deploy_...)."
1012
+ ),
1013
+ page: int = typer.Option(1, "--page", min=1, help="Page number, starting at 1."),
1014
+ page_size: int = typer.Option(10, "--page-size", min=1, max=100, help="Page size, at most 100."),
1015
+ begin: Optional[str] = typer.Option(None, "--begin", help="Created at or after, yyyy-MM-dd HH:mm:ss."),
1016
+ end: Optional[str] = typer.Option(None, "--end", help="Created at or before, yyyy-MM-dd HH:mm:ss."),
1017
+ ) -> None:
1018
+ """List deployment records for a flow (newest first). Requires view_deployment."""
1019
+ with _client(ctx) as c:
1020
+ data = _fetch(
1021
+ ctx,
1022
+ c.flow_deployments,
1023
+ project,
1024
+ flow,
1025
+ deployment_id=deployment,
1026
+ page=page,
1027
+ page_size=page_size,
1028
+ begin_time=begin,
1029
+ end_time=end,
1030
+ )
1031
+ _show_page(ctx, data, format_deployment_records)
1032
+
1033
+
904
1034
  @flow_app.command("deployment")
905
1035
  def cmd_flow_deployment(
906
1036
  ctx: typer.Context,
@@ -908,11 +1038,37 @@ def cmd_flow_deployment(
908
1038
  flow: str = _OPT_FLOW,
909
1039
  deployment: str = typer.Option(..., "--deployment", "-d", help="Deployment public id (deploy_...)."),
910
1040
  ) -> None:
911
- """One deployment's overall status and per-server result."""
1041
+ """One deployment record: counts, per-server tasks, and the publish summary."""
912
1042
  with _client(ctx) as c:
913
1043
  _call(ctx, c.flow_deployment, project, flow, deployment)
914
1044
 
915
1045
 
1046
+ @flow_app.command("versions")
1047
+ def cmd_flow_versions(
1048
+ ctx: typer.Context,
1049
+ project: str = _OPT_PROJECT,
1050
+ flow: str = _OPT_FLOW,
1051
+ page: int = typer.Option(1, "--page", min=1, help="Page number, starting at 1."),
1052
+ page_size: int = typer.Option(10, "--page-size", min=1, max=100, help="Page size, at most 100."),
1053
+ ) -> None:
1054
+ """List historical versions (newest first), including the draft. Requires view_version."""
1055
+ with _client(ctx) as c:
1056
+ data = _fetch(ctx, c.flow_versions, project, flow, page=page, page_size=page_size)
1057
+ _show_page(ctx, data, format_version_records)
1058
+
1059
+
1060
+ @flow_app.command("get-version")
1061
+ def cmd_flow_get_version(
1062
+ ctx: typer.Context,
1063
+ project: str = _OPT_PROJECT,
1064
+ flow: str = _OPT_FLOW,
1065
+ version_id: str = typer.Option(..., "--version-id", help="Version public id (v-...)."),
1066
+ ) -> None:
1067
+ """Canvas JSON of one historical version. Read-only; not a draft lock. Requires view_version."""
1068
+ with _client(ctx) as c:
1069
+ _call(ctx, c.flow_version, project, flow, version_id)
1070
+
1071
+
916
1072
  # --- config ---
917
1073
  config_app = typer.Typer(help="Write/read the openapi section of ~/.buildifyrc (overridden by env at runtime).")
918
1074
  app.add_typer(config_app, name="config")
@@ -116,6 +116,15 @@ class OpenApiClient:
116
116
  def project_get(self, project_id: str) -> tuple[int, Any]:
117
117
  return self._get(f"{API_PREFIX}/projects/{project_id}")
118
118
 
119
+ def project_update(self, project_id: str, name: str, remark: Optional[str]) -> tuple[int, Any]:
120
+ payload: dict[str, Any] = {"projectName": name}
121
+ if remark is not None:
122
+ payload["remark"] = remark
123
+ return self._post(f"{API_PREFIX}/projects/{project_id}", payload)
124
+
125
+ def project_delete(self, project_id: str) -> tuple[int, Any]:
126
+ return self._delete(f"{API_PREFIX}/projects/{project_id}")
127
+
119
128
  def project_credentials(self, project_id: str, bundle_name: Optional[str] = None) -> tuple[int, Any]:
120
129
  return self._get(
121
130
  f"{API_PREFIX}/projects/{project_id}/credentials",
@@ -171,6 +180,43 @@ class OpenApiClient:
171
180
  {"workflowName": workflow_name, "workerIds": worker_ids, "remark": remark},
172
181
  )
173
182
 
183
+ def flow_update(
184
+ self,
185
+ project_id: str,
186
+ workflow_id: str,
187
+ *,
188
+ workflow_name: str,
189
+ remark: Optional[str],
190
+ ) -> tuple[int, Any]:
191
+ payload: dict[str, Any] = {"workflowName": workflow_name}
192
+ if remark is not None:
193
+ payload["remark"] = remark
194
+ return self._post(f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/settings", payload)
195
+
196
+ def flow_delete(self, project_id: str, workflow_id: str) -> tuple[int, Any]:
197
+ return self._delete(f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}")
198
+
199
+ def flow_workers(self, project_id: str, workflow_id: str) -> tuple[int, Any]:
200
+ return self._get(f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/workers")
201
+
202
+ def flow_assign_workers(self, project_id: str, workflow_id: str, worker_ids: list[str]) -> tuple[int, Any]:
203
+ return self._post(
204
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/assign_workers",
205
+ {"workerIds": worker_ids},
206
+ )
207
+
208
+ def flow_unassign_workers(self, project_id: str, workflow_id: str, worker_ids: list[str]) -> tuple[int, Any]:
209
+ return self._post(
210
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/unassign_workers",
211
+ {"workerIds": worker_ids},
212
+ )
213
+
214
+ def flow_force_unassign_workers(self, project_id: str, workflow_id: str, worker_ids: list[str]) -> tuple[int, Any]:
215
+ return self._post(
216
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/force_unassign_workers",
217
+ {"workerIds": worker_ids},
218
+ )
219
+
174
220
  def flow_get_draft(self, project_id: str, workflow_id: str) -> tuple[int, Any]:
175
221
  return self._get(f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/draft")
176
222
 
@@ -250,6 +296,46 @@ class OpenApiClient:
250
296
  def flow_published(self, project_id: str, workflow_id: str) -> tuple[int, Any]:
251
297
  return self._get(f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/published")
252
298
 
299
+ def flow_deployments(
300
+ self,
301
+ project_id: str,
302
+ workflow_id: str,
303
+ *,
304
+ deployment_id: Optional[str] = None,
305
+ page: Optional[int] = None,
306
+ page_size: Optional[int] = None,
307
+ begin_time: Optional[str] = None,
308
+ end_time: Optional[str] = None,
309
+ ) -> tuple[int, Any]:
310
+ return self._get(
311
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/deployments",
312
+ {
313
+ "deploymentId": deployment_id,
314
+ "page": page,
315
+ "pageSize": page_size,
316
+ "beginTime": begin_time,
317
+ "endTime": end_time,
318
+ },
319
+ )
320
+
321
+ def flow_versions(
322
+ self,
323
+ project_id: str,
324
+ workflow_id: str,
325
+ *,
326
+ page: Optional[int] = None,
327
+ page_size: Optional[int] = None,
328
+ ) -> tuple[int, Any]:
329
+ return self._get(
330
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/versions",
331
+ {"page": page, "pageSize": page_size},
332
+ )
333
+
334
+ def flow_version(self, project_id: str, workflow_id: str, version_id: str) -> tuple[int, Any]:
335
+ return self._get(
336
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/versions/{version_id}"
337
+ )
338
+
253
339
  def flow_deployment(
254
340
  self,
255
341
  project_id: str,
@@ -22,8 +22,8 @@ ENV_JSON = "BUILDIFY_OPENAPI_JSON"
22
22
  # Set to 1/true/yes to never prompt for a missing key (CI / non-interactive).
23
23
  ENV_NO_PROMPT = "BUILDIFY_OPENAPI_NO_PROMPT"
24
24
 
25
- # ~/.buildifyrc 与 buildify-publish 共用;开放 API 的配置放在独立 section 下,
26
- # 避免两套密钥(发布 / 编排)互相覆盖。
25
+ # ~/.buildifyrc 与 buildify-publish 共用。开放 API 在 [openapi],发布配置在 [publish],
26
+ # 两套密钥不会互相覆盖。
27
27
  RC_SECTION = "openapi"
28
28
 
29
29
  RC_KEYS = frozenset({"base_url", "api_key"})
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  from typing import Any, Optional
4
5
 
5
6
  _STATUS_ICON = {
@@ -106,6 +107,76 @@ def format_publish_report(payload: dict[str, Any]) -> str:
106
107
  return "\n".join(lines).rstrip() + "\n"
107
108
 
108
109
 
110
+ def format_deployment_records(page: Any) -> str:
111
+ """Markdown table for a deployment-record page."""
112
+ if not isinstance(page, dict):
113
+ return json.dumps(page, ensure_ascii=False, indent=2) + "\n"
114
+ rows = page.get("data") if isinstance(page.get("data"), list) else []
115
+ lines = [
116
+ _page_heading(page, len(rows)),
117
+ "",
118
+ "| 部署 | 版本 | 状态 | 成功 | 失败 | 警告 | 时间 | 来源 |",
119
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
120
+ ]
121
+ if not rows:
122
+ lines.append("| — | — | — | — | — | — | — | — |")
123
+ for row in rows:
124
+ if not isinstance(row, dict):
125
+ continue
126
+ status = _status_key(row.get("deployStatus"))
127
+ icon = row.get("deployStatusIcon") or _STATUS_ICON.get(status, "⏳")
128
+ label = row.get("deployStatusLabel") or _STATUS_LABEL.get(status, "待部署")
129
+ lines.append(
130
+ f"| `{_cell(row.get('deploymentId'))}` | `{_cell(row.get('versionName'))}` | "
131
+ f"{icon} {label} | {row.get('successCount', 0)} | {row.get('failCount', 0)} | "
132
+ f"{row.get('warningCount', 0)} | {_cell(row.get('createdAt'))} | {_source(row)} |"
133
+ )
134
+ return "\n".join(lines).rstrip() + "\n"
135
+
136
+
137
+ def format_version_records(page: Any) -> str:
138
+ """Markdown table for a workflow version page."""
139
+ if not isinstance(page, dict):
140
+ return json.dumps(page, ensure_ascii=False, indent=2) + "\n"
141
+ rows = page.get("data") if isinstance(page.get("data"), list) else []
142
+ lines = [
143
+ _page_heading(page, len(rows)),
144
+ "",
145
+ "| 版本 | 名称 | 草稿 | 运行中 | 说明 | 更新时间 | 来源 |",
146
+ "| --- | --- | --- | --- | --- | --- | --- |",
147
+ ]
148
+ if not rows:
149
+ lines.append("| — | — | — | — | — | — | — |")
150
+ for row in rows:
151
+ if not isinstance(row, dict):
152
+ continue
153
+ lines.append(
154
+ f"| `{_cell(row.get('versionId'))}` | `{_cell(row.get('versionName'))}` | "
155
+ f"{_yes(row.get('isDraft'))} | {_yes(row.get('isRunning'))} | {_cell(row.get('remark'))} | "
156
+ f"{_cell(row.get('updatedAt') or row.get('createdAt'))} | {_source(row)} |"
157
+ )
158
+ return "\n".join(lines).rstrip() + "\n"
159
+
160
+
161
+ def _page_heading(page: dict[str, Any], shown: int) -> str:
162
+ total = page.get("total", shown)
163
+ current = page.get("page", 1)
164
+ pages = page.get("totalPages", 1)
165
+ return f"共 {total} 条,第 {current} / {pages} 页"
166
+
167
+
168
+ def _yes(value: Any) -> str:
169
+ return "是" if value is True else "否"
170
+
171
+
172
+ def _source(row: dict[str, Any]) -> str:
173
+ source = row.get("source") or "—"
174
+ key_name = row.get("sourceKeyName")
175
+ if key_name:
176
+ return _cell(f"{source} · {key_name}")
177
+ return _cell(source)
178
+
179
+
109
180
  def _status_key(value: Any) -> str:
110
181
  if value is None:
111
182
  return "pending"
@@ -8,6 +8,12 @@ and docs, reference existing credentials, then create / validate / test run / de
8
8
  **Auth:** `Authorization: Bearer <keyId>.<secret>`. Keys are **issued and revoked in the console**
9
9
  (workspace settings → Open API keys), not via this CLI. One key is bound to exactly one workspace;
10
10
  every command below is implicitly scoped to it. The plaintext is shown **only once** at creation.
11
+ A key may also be limited to scopes and projects. Scope values are the console permission codes
12
+ (`project:workflow:view`, `project:workflow:view_deployment`, `project:workflow:view_version`,
13
+ `project:workflow:deploy`, …). `*` is every capability; `project:*` and
14
+ `project:workflow:*` follow the console matcher (`project.*` is the same as `project:*`). HTTP 403
15
+ (`缺少权限: …`) means the key is valid but lacks that capability; it is not an invalid key (that stays
16
+ 401). A project outside the key's allowlist is 404 `项目不存在`.
11
17
 
12
18
  If no key is configured:
13
19
 
@@ -55,7 +61,7 @@ the user confirms project, name/label and form values. Publish with `workflow de
55
61
 
56
62
  **Config file:** `~/.buildifyrc`, section `[openapi]`: `base_url`, legacy `api_key`, `current`, and
57
63
  `profiles.<name>` (`api_key`, cached `tenant_id` / `key_name` / `key_prefix`). File mode `600`.
58
- Shared with `buildify-publish`, which uses the top-level keys — the two do not collide.
64
+ Shared with `buildify-publish`, which keeps its host and keys under `[publish]` — the two sections do not collide.
59
65
 
60
66
  **Precedence:** `--api-key` / `BUILDIFY_OPENAPI_API_KEY` > `--profile` / `BUILDIFY_OPENAPI_PROFILE` >
61
67
  `.buildify-workspace` > `[openapi].current` > the only saved key > `profile_required` or `missing_api_key`.
@@ -88,17 +94,28 @@ All paths are under `/openapi/v1`.
88
94
  | `bundle cred-types` | GET | `/bundles/credentials/types` |
89
95
  | `bundle cred-properties` | GET | `/bundles/credentials/properties` |
90
96
  | `project list` / `create` / `get` | GET / POST / GET | `/projects[...]` |
97
+ | `project update` | POST | `/projects/{id}` |
98
+ | `project delete` | DELETE | `/projects/{id}` |
91
99
  | `project credentials` | GET | `/projects/{id}/credentials` |
92
100
  | `project create-credential` | POST | `/projects/{id}/credentials` |
93
101
  | `project workers` | GET | `/projects/{id}/workers` |
94
102
  | `workflow list` / `create` | GET / POST | `/projects/{id}/workflows` |
103
+ | `workflow update` | POST | `/projects/{id}/workflows/{wid}/settings` |
104
+ | `workflow workers` | GET | `/projects/{id}/workflows/{wid}/workers` |
105
+ | `workflow assign-workers` | POST | `/projects/{id}/workflows/{wid}/assign_workers` |
106
+ | `workflow unassign-workers` | POST | `/projects/{id}/workflows/{wid}/unassign_workers` |
107
+ | `workflow force-unassign-workers` | POST | `/projects/{id}/workflows/{wid}/force_unassign_workers` |
108
+ | `workflow delete` | DELETE | `/projects/{id}/workflows/{wid}` |
95
109
  | `workflow get-draft` / `save-draft` | GET / POST | `/projects/{id}/workflows/{wid}/draft` |
96
110
  | `workflow validate` | POST | `/projects/{id}/workflows/validate` |
97
111
  | `workflow test-run` | POST | `/projects/{id}/workflows/test_runs` |
98
112
  | `workflow cancel-test` | POST | `/projects/{id}/workflows/test_runs/{executionId}/cancel` |
99
113
  | `workflow deploy` | POST | `/projects/{id}/workflows/{wid}/deploy` |
100
114
  | `workflow published` | GET | `/projects/{id}/workflows/{wid}/published` |
115
+ | `workflow deployments` | GET | `/projects/{id}/workflows/{wid}/deployments` |
101
116
  | `workflow deployment` | GET | `/projects/{id}/workflows/{wid}/deployments/{deployId}` |
117
+ | `workflow versions` | GET | `/projects/{id}/workflows/{wid}/versions` |
118
+ | `workflow get-version` | GET | `/projects/{id}/workflows/{wid}/versions/{versionId}` |
102
119
 
103
120
  ## Typical session
104
121
 
@@ -140,6 +157,19 @@ buildify --json workflow deploy -p proj_xxx -w wf_xxx --worker wk_xxx \
140
157
 
141
158
  `--file -` reads JSON from stdin (flow canvas or credential `data`).
142
159
 
160
+ ## Changing projects, servers, and deleting flows
161
+
162
+ Ask the user before any of these. They change where a flow runs or remove data.
163
+
164
+ - `project update -p ID -n "新名称" [--remark 简介]` renames a project. Omit `--remark` to keep the current description.
165
+ - `project delete -p ID` deletes a project only when it has no workflows (otherwise HTTP 409).
166
+ - `workflow update -p ID -w WF -n "新名称" [--remark 简介]` renames a workflow.
167
+ - `workflow workers -p ID -w WF` lists servers bound to the flow. `deleteStatus` `0` is bound, `1` is waiting for the server to finish removal.
168
+ - `workflow assign-workers -p ID -w WF --worker wk_…` adds servers. Repeat `--worker` for more than one. If the flow is already published, the current published version is deployed to the new servers (`needDeploy: true`, `deploymentId`). Servers already bound are skipped. A server still waiting to be removed returns HTTP 409.
169
+ - `workflow unassign-workers` starts removal. Unpublished flows drop the binding immediately. Published flows set `deleteStatus` to `1` and send `UNDEPLOY`; the binding row stays until the server confirms.
170
+ - `workflow force-unassign-workers` drops the binding immediately and writes a sync log so an offline server drops the flow on its next sync. Use it when the server is gone or a normal unassign is stuck.
171
+ - `workflow delete -p ID -w WF` deletes the flow only when no server is bound, including ones still waiting to be removed (otherwise HTTP 409).
172
+
143
173
  ## project create-credential
144
174
 
145
175
  Requires `--project`. Also pass `--bundle` and `--type`. If `--name` / `--label` / credential
@@ -166,7 +196,7 @@ rather than retrying blindly.
166
196
  Checks, in this order: canvas structure, node existence in the workspace, `data.parameters`
167
197
  against each node's `properties_json`, `edges[].data.relations[].name` against the source node's
168
198
  `relations_json` (or the source node's custom `data.relations` for MCP), `data.credentials`
169
- against the project's credentials, graph shape (trigger present, orphan nodes, cycles),
199
+ against the project's credentials, graph shape (trigger present, orphan nodes),
170
200
  and WARNINGs if catalog `icon` is missing on a node or an edge relation is missing `label` /
171
201
  custom `icon`/`_id`.
172
202
 
@@ -232,7 +262,18 @@ Without `--json`, `workflow deploy` / `published` / `deployment` print Markdown
232
262
  (项目 / 服务器 / 凭证;凭证表含 `name` 与 `label`,不含节点表)。
233
263
  With `--json`, agents should render the same tables (see buildify-flow-dev skill).
234
264
 
235
- `workflow published` is the live snapshot after deploy. `workflow deployment -d deploy_…` is one attempt.
265
+ `workflow published` is the live snapshot after deploy. `workflow deployment -d deploy_…` is one attempt:
266
+ counts (`successCount` includes warnings), per-server `items`, and the same `summary` tables.
267
+
268
+ `workflow deployments` lists records newest first. Needs scope `project:workflow:view_deployment`.
269
+ `--page` / `--page-size` (max 100), optional `--deployment deploy_…`, and `--begin` / `--end`
270
+ (`yyyy-MM-dd HH:mm:ss`, quote the space). Without `--json` it prints a table; `--json` returns the
271
+ page (`total`, `page`, `pageSize`, `data`, `hasNext`).
272
+
273
+ `workflow versions` lists historical versions newest first, including the current draft (`isDraft`)
274
+ and any version still bound to a server (`isRunning`). Needs `project:workflow:view_version`.
275
+ `workflow get-version --version-id v-…` returns that version's canvas (`workflowJson`). It is
276
+ read-only and does not return the draft optimistic-lock `version`.
236
277
 
237
278
  ## JSON output schema
238
279
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildify-cli
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: CLI for the Buildify open API: discover bundle nodes, author and test workflows (machine Bearer auth)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -66,7 +66,7 @@ interactive prompt. `config set-key api_key` still updates the current profile,
66
66
  | `key` | `test` |
67
67
  | `bundle` | `list`, `nodes`, `node-properties`, `node-relations`, `doc`, `cred-types`, `cred-properties` |
68
68
  | `project` | `list`, `create`, `get`, `credentials`, `create-credential`, `workers` |
69
- | `workflow` | `list`, `create`, `get-draft`, `save-draft`, `validate`, `test-run`, `cancel-test`, `deploy`, `published`, `deployment` |
69
+ | `workflow` | `list`, `create`, `get-draft`, `save-draft`, `validate`, `test-run`, `cancel-test`, `deploy`, `published`, `deployments`, `deployment`, `versions`, `get-version` |
70
70
  | `config` | `init`, `set-key`, `add-profile`, `use`, `profiles`, `remove-profile`, `show` |
71
71
 
72
72
  Every command accepts `--json` for a single-line envelope
@@ -17,4 +17,5 @@ src/buildify_cli.egg-info/entry_points.txt
17
17
  src/buildify_cli.egg-info/requires.txt
18
18
  src/buildify_cli.egg-info/top_level.txt
19
19
  tests/test_auth_config.py
20
- tests/test_credential_form.py
20
+ tests/test_credential_form.py
21
+ tests/test_history_view.py
@@ -0,0 +1,57 @@
1
+ from buildify_cli.publish_view import format_deployment_records, format_version_records
2
+
3
+
4
+ def test_deployment_records_table_includes_counts_and_empty_page():
5
+ text = format_deployment_records(
6
+ {
7
+ "total": 1,
8
+ "page": 1,
9
+ "totalPages": 1,
10
+ "data": [
11
+ {
12
+ "deploymentId": "deploy_abc",
13
+ "versionName": "v1",
14
+ "deployStatus": "failed",
15
+ "deployStatusIcon": "❌",
16
+ "deployStatusLabel": "失败",
17
+ "successCount": 1,
18
+ "failCount": 2,
19
+ "warningCount": 0,
20
+ "createdAt": "2026-11-01 10:00:00",
21
+ "source": "api",
22
+ "sourceKeyName": "ci",
23
+ }
24
+ ],
25
+ }
26
+ )
27
+ assert "共 1 条" in text
28
+ assert "deploy_abc" in text
29
+ assert "❌ 失败" in text
30
+ assert "api · ci" in text
31
+
32
+ empty = format_deployment_records({"total": 0, "page": 1, "totalPages": 0, "data": []})
33
+ assert "共 0 条" in empty
34
+
35
+
36
+ def test_version_records_mark_draft_and_running():
37
+ text = format_version_records(
38
+ {
39
+ "total": 1,
40
+ "page": 1,
41
+ "totalPages": 1,
42
+ "data": [
43
+ {
44
+ "versionId": "v-abc",
45
+ "versionName": "draft",
46
+ "isDraft": True,
47
+ "isRunning": False,
48
+ "remark": "note",
49
+ "updatedAt": "2026-11-01 10:00:00",
50
+ "source": "console",
51
+ }
52
+ ],
53
+ }
54
+ )
55
+ assert "v-abc" in text
56
+ assert "| 是 | 否 |" in text
57
+ assert "console" in text
@@ -1 +0,0 @@
1
- __version__ = "1.0.0"
File without changes