buildify-cli 1.0.0__tar.gz → 1.2.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.2.0}/PKG-INFO +3 -3
  2. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/README.md +2 -2
  3. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/pyproject.toml +1 -1
  4. buildify_cli-1.2.0/src/buildify_cli/__init__.py +1 -0
  5. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli/cli.py +257 -4
  6. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli/client.py +123 -0
  7. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli/config.py +2 -2
  8. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli/publish_view.py +71 -0
  9. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli/usage.md +57 -3
  10. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli.egg-info/PKG-INFO +3 -3
  11. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli.egg-info/SOURCES.txt +2 -1
  12. buildify_cli-1.2.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.2.0}/setup.cfg +0 -0
  15. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli/__main__.py +0 -0
  16. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli/credential_form.py +0 -0
  17. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli/exit_codes.py +0 -0
  18. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli/output.py +0 -0
  19. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli.egg-info/dependency_links.txt +0 -0
  20. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli.egg-info/entry_points.txt +0 -0
  21. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli.egg-info/requires.txt +0 -0
  22. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/src/buildify_cli.egg-info/top_level.txt +0 -0
  23. {buildify_cli-1.0.0 → buildify_cli-1.2.0}/tests/test_auth_config.py +0 -0
  24. {buildify_cli-1.0.0 → buildify_cli-1.2.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.2.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
@@ -65,8 +65,8 @@ interactive prompt. `config set-key api_key` still updates the current profile,
65
65
  | — | `health`, `version`, `help` |
66
66
  | `key` | `test` |
67
67
  | `bundle` | `list`, `nodes`, `node-properties`, `node-relations`, `doc`, `cred-types`, `cred-properties` |
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` |
68
+ | `project` | `list`, `create`, `get`, `update`, `delete`, `credentials`, `create-credential`, `update-credential`, `delete-credential`, `credential-workflows`, `workers` |
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
@@ -52,8 +52,8 @@ interactive prompt. `config set-key api_key` still updates the current profile,
52
52
  | — | `health`, `version`, `help` |
53
53
  | `key` | `test` |
54
54
  | `bundle` | `list`, `nodes`, `node-properties`, `node-relations`, `doc`, `cred-types`, `cred-properties` |
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` |
55
+ | `project` | `list`, `create`, `get`, `update`, `delete`, `credentials`, `create-credential`, `update-credential`, `delete-credential`, `credential-workflows`, `workers` |
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.2.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.2.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.",
@@ -60,9 +65,10 @@ app = typer.Typer(
60
65
  " health GET /openapi/v1/health (no key)\n"
61
66
  " key test Validate the configured key\n"
62
67
  " bundle Bundle / node discovery: list, nodes, node-properties, node-relations, doc, cred-types, cred-properties\n"
63
- " project list, create, get, credentials, create-credential, workers\n"
68
+ " project list, create, get, update, delete, credentials, create-credential,\n"
69
+ " update-credential, delete-credential, credential-workflows, workers\n"
64
70
  " workflow list, create, get-draft, save-draft, validate, test-run, cancel-test,\n"
65
- " deploy, published, deployment\n"
71
+ " deploy, published, deployments, deployment, versions, get-version\n"
66
72
  " config Manage the [openapi] section of ~/.buildifyrc\n"
67
73
  " version Print CLI version\n"
68
74
  " help Markdown reference on stdout (for AI/agents)\n\n"
@@ -248,6 +254,15 @@ def _emit_network_error(ctx: typer.Context, exc: BaseException) -> int:
248
254
  return ec.NETWORK
249
255
 
250
256
 
257
+ def _show_page(ctx: typer.Context, data: Any, formatter) -> None:
258
+ """Pretty-print a page for humans; `--json` keeps the server payload."""
259
+ if _cfg(ctx).json_output:
260
+ print_json(ok=True, code=200, msg="ok", http_status=200, data=data)
261
+ else:
262
+ typer.echo(formatter(data))
263
+ raise typer.Exit(ec.OK)
264
+
265
+
251
266
  def _call(ctx: typer.Context, fn, *args, **kwargs) -> None:
252
267
  """Run one client call and exit with the mapped code."""
253
268
  try:
@@ -435,6 +450,7 @@ project_app = typer.Typer(help="Projects, credentials and workers.")
435
450
  app.add_typer(project_app, name="project")
436
451
 
437
452
  _OPT_PROJECT = typer.Option(..., "--project", "-p", help="Project public id (proj_...).")
453
+ _OPT_CREDENTIALS = typer.Option(..., "--credentials", help="Credential public id (cred_...).")
438
454
 
439
455
 
440
456
  @project_app.command("list")
@@ -462,6 +478,25 @@ def cmd_project_get(ctx: typer.Context, project: str = _OPT_PROJECT) -> None:
462
478
  _call(ctx, c.project_get, project)
463
479
 
464
480
 
481
+ @project_app.command("update")
482
+ def cmd_project_update(
483
+ ctx: typer.Context,
484
+ project: str = _OPT_PROJECT,
485
+ name: str = typer.Option(..., "--name", "-n", help="New project name."),
486
+ remark: Optional[str] = typer.Option(None, "--remark", help="New description. Omit to keep the current one."),
487
+ ) -> None:
488
+ """Rename a project or change its description."""
489
+ with _client(ctx) as c:
490
+ _call(ctx, c.project_update, project, name, remark)
491
+
492
+
493
+ @project_app.command("delete")
494
+ def cmd_project_delete(ctx: typer.Context, project: str = _OPT_PROJECT) -> None:
495
+ """Delete a project that has no workflows. Confirm with the user first."""
496
+ with _client(ctx) as c:
497
+ _call(ctx, c.project_delete, project)
498
+
499
+
465
500
  @project_app.command("credentials")
466
501
  def cmd_project_credentials(
467
502
  ctx: typer.Context,
@@ -596,6 +631,101 @@ def cmd_project_create_credential(
596
631
  )
597
632
 
598
633
 
634
+ @project_app.command("update-credential")
635
+ def cmd_project_update_credential(
636
+ ctx: typer.Context,
637
+ project: str = _OPT_PROJECT,
638
+ credentials: str = _OPT_CREDENTIALS,
639
+ label: Optional[str] = typer.Option(None, "--label", help="New display name. Omit to keep the current one."),
640
+ description: Optional[str] = typer.Option(
641
+ None, "--description", help="New remark. Pass an empty string to clear it. Omit to keep the current one."
642
+ ),
643
+ bundle_version: Optional[str] = typer.Option(
644
+ None, "--bundle-version", "-V", help="Bind another version of the same bundle. Omit to keep the current one."
645
+ ),
646
+ data_inline: Optional[str] = typer.Option(
647
+ None,
648
+ "--data",
649
+ help="JSON object of form fields. Insecure in argv; prefer --file - / stdin.",
650
+ ),
651
+ file: Optional[Path] = typer.Option(
652
+ None,
653
+ "--file",
654
+ "-f",
655
+ help="JSON file of form fields to merge, or '-' for stdin. Omit to keep stored secrets.",
656
+ ),
657
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the TTY confirmation prompt."),
658
+ ) -> None:
659
+ """Update a credential's label, remark, bundle version, or form data.
660
+
661
+ Omitted fields stay as they are. Secrets are never printed. Masked or blank secret fields
662
+ keep the stored value. Confirm with the user before running.
663
+ """
664
+ if data_inline:
665
+ human_err("warning: --data is visible in process lists and shell history; prefer --file -")
666
+ data: Optional[dict[str, Any]] = None
667
+ if data_inline is not None or file is not None:
668
+ parsed = _read_json_arg(ctx, data_inline, file, what="credential data")
669
+ if not isinstance(parsed, dict):
670
+ raise _fail_local(ctx, "credential data must be a JSON object")
671
+ data = parsed
672
+ if label is None and description is None and bundle_version is None and data is None:
673
+ raise _fail_local(ctx, "至少提供 --label、--description、--bundle-version 或 --file 之一")
674
+ if label is not None and not label.strip():
675
+ raise _fail_local(ctx, "label 不能为空")
676
+ if _can_prompt_for_key(ctx) and not yes:
677
+ human_err("即将更新访问凭证,请确认:")
678
+ human_err(f" credentials:{credentials}")
679
+ if label is not None:
680
+ human_err(f" label:{label}")
681
+ if description is not None:
682
+ human_err(f" description:{description if description else '(清空)'}")
683
+ if bundle_version is not None:
684
+ human_err(f" bundleVersion:{bundle_version}")
685
+ if data is not None:
686
+ human_err(f" 参数键:{', '.join(data) or '(空)'}(值已隐藏)")
687
+ if not typer.confirm("确认更新?", default=False, err=True):
688
+ raise _fail_local(ctx, "已取消更新", ec.LOCAL_VALIDATION)
689
+ with _client(ctx) as c:
690
+ _call(
691
+ ctx,
692
+ c.project_update_credential,
693
+ project,
694
+ credentials,
695
+ label=label.strip() if label is not None else None,
696
+ description=description,
697
+ bundle_version=bundle_version,
698
+ data=data,
699
+ )
700
+
701
+
702
+ @project_app.command("delete-credential")
703
+ def cmd_project_delete_credential(
704
+ ctx: typer.Context,
705
+ project: str = _OPT_PROJECT,
706
+ credentials: str = _OPT_CREDENTIALS,
707
+ ) -> None:
708
+ """Soft-delete a credential that no workflow references. Confirm with the user first.
709
+
710
+ HTTP 409 when a flow still references it.
711
+ """
712
+ with _client(ctx) as c:
713
+ _call(ctx, c.project_delete_credential, project, credentials)
714
+
715
+
716
+ @project_app.command("credential-workflows")
717
+ def cmd_project_credential_workflows(
718
+ ctx: typer.Context,
719
+ project: str = _OPT_PROJECT,
720
+ credentials: str = _OPT_CREDENTIALS,
721
+ page: int = typer.Option(1, "--page", min=1, help="Page number, starting at 1."),
722
+ page_size: int = typer.Option(10, "--page-size", min=1, max=100, help="Page size, at most 100."),
723
+ ) -> None:
724
+ """Workflows that reference a credential. No secrets. Requires credentials view."""
725
+ with _client(ctx) as c:
726
+ _call(ctx, c.project_credential_workflows, project, credentials, page=page, page_size=page_size)
727
+
728
+
599
729
  def _prompt_credential_data(fields: list[dict[str, Any]]) -> dict[str, Any]:
600
730
  values: dict[str, Any] = {}
601
731
  if not fields:
@@ -688,6 +818,74 @@ def cmd_flow_create(
688
818
  _call(ctx, c.flow_create, project, workflow_name=name, worker_ids=list(worker), remark=remark)
689
819
 
690
820
 
821
+ _OPT_WORKERS = typer.Option(
822
+ ..., "--worker", help="Worker public id; repeat for multiple. See `buildify project workers`."
823
+ )
824
+
825
+
826
+ @flow_app.command("update")
827
+ def cmd_flow_update(
828
+ ctx: typer.Context,
829
+ project: str = _OPT_PROJECT,
830
+ flow: str = _OPT_FLOW,
831
+ name: str = typer.Option(..., "--name", "-n", help="New workflow name."),
832
+ remark: Optional[str] = typer.Option(None, "--remark", help="New description. Omit to keep the current one."),
833
+ ) -> None:
834
+ """Rename a workflow or change its description."""
835
+ with _client(ctx) as c:
836
+ _call(ctx, c.flow_update, project, flow, workflow_name=name, remark=remark)
837
+
838
+
839
+ @flow_app.command("delete")
840
+ def cmd_flow_delete(ctx: typer.Context, project: str = _OPT_PROJECT, flow: str = _OPT_FLOW) -> None:
841
+ """Delete a workflow that has no bound servers. Confirm with the user first."""
842
+ with _client(ctx) as c:
843
+ _call(ctx, c.flow_delete, project, flow)
844
+
845
+
846
+ @flow_app.command("workers")
847
+ def cmd_flow_workers(ctx: typer.Context, project: str = _OPT_PROJECT, flow: str = _OPT_FLOW) -> None:
848
+ """Servers bound to a workflow, including ones waiting to be removed."""
849
+ with _client(ctx) as c:
850
+ _call(ctx, c.flow_workers, project, flow)
851
+
852
+
853
+ @flow_app.command("assign-workers")
854
+ def cmd_flow_assign_workers(
855
+ ctx: typer.Context,
856
+ project: str = _OPT_PROJECT,
857
+ flow: str = _OPT_FLOW,
858
+ worker: list[str] = _OPT_WORKERS,
859
+ ) -> None:
860
+ """Bind extra servers. A published flow is deployed to the new servers."""
861
+ with _client(ctx) as c:
862
+ _call(ctx, c.flow_assign_workers, project, flow, list(worker))
863
+
864
+
865
+ @flow_app.command("unassign-workers")
866
+ def cmd_flow_unassign_workers(
867
+ ctx: typer.Context,
868
+ project: str = _OPT_PROJECT,
869
+ flow: str = _OPT_FLOW,
870
+ worker: list[str] = _OPT_WORKERS,
871
+ ) -> None:
872
+ """Unbind servers. A published flow is undeployed and removed after the server confirms."""
873
+ with _client(ctx) as c:
874
+ _call(ctx, c.flow_unassign_workers, project, flow, list(worker))
875
+
876
+
877
+ @flow_app.command("force-unassign-workers")
878
+ def cmd_flow_force_unassign_workers(
879
+ ctx: typer.Context,
880
+ project: str = _OPT_PROJECT,
881
+ flow: str = _OPT_FLOW,
882
+ worker: list[str] = _OPT_WORKERS,
883
+ ) -> None:
884
+ """Drop server bindings immediately. Use when the server is gone or a normal unassign is stuck."""
885
+ with _client(ctx) as c:
886
+ _call(ctx, c.flow_force_unassign_workers, project, flow, list(worker))
887
+
888
+
691
889
  @flow_app.command("get-draft")
692
890
  def cmd_flow_get_draft(ctx: typer.Context, project: str = _OPT_PROJECT, flow: str = _OPT_FLOW) -> None:
693
891
  """Draft canvas JSON plus the optimistic-lock `version` needed to save it back."""
@@ -901,6 +1099,35 @@ def cmd_flow_published(ctx: typer.Context, project: str = _OPT_PROJECT, flow: st
901
1099
  _call(ctx, c.flow_published, project, flow)
902
1100
 
903
1101
 
1102
+ @flow_app.command("deployments")
1103
+ def cmd_flow_deployments(
1104
+ ctx: typer.Context,
1105
+ project: str = _OPT_PROJECT,
1106
+ flow: str = _OPT_FLOW,
1107
+ deployment: Optional[str] = typer.Option(
1108
+ None, "--deployment", "-d", help="Only this deployment public id (deploy_...)."
1109
+ ),
1110
+ page: int = typer.Option(1, "--page", min=1, help="Page number, starting at 1."),
1111
+ page_size: int = typer.Option(10, "--page-size", min=1, max=100, help="Page size, at most 100."),
1112
+ begin: Optional[str] = typer.Option(None, "--begin", help="Created at or after, yyyy-MM-dd HH:mm:ss."),
1113
+ end: Optional[str] = typer.Option(None, "--end", help="Created at or before, yyyy-MM-dd HH:mm:ss."),
1114
+ ) -> None:
1115
+ """List deployment records for a flow (newest first). Requires view_deployment."""
1116
+ with _client(ctx) as c:
1117
+ data = _fetch(
1118
+ ctx,
1119
+ c.flow_deployments,
1120
+ project,
1121
+ flow,
1122
+ deployment_id=deployment,
1123
+ page=page,
1124
+ page_size=page_size,
1125
+ begin_time=begin,
1126
+ end_time=end,
1127
+ )
1128
+ _show_page(ctx, data, format_deployment_records)
1129
+
1130
+
904
1131
  @flow_app.command("deployment")
905
1132
  def cmd_flow_deployment(
906
1133
  ctx: typer.Context,
@@ -908,11 +1135,37 @@ def cmd_flow_deployment(
908
1135
  flow: str = _OPT_FLOW,
909
1136
  deployment: str = typer.Option(..., "--deployment", "-d", help="Deployment public id (deploy_...)."),
910
1137
  ) -> None:
911
- """One deployment's overall status and per-server result."""
1138
+ """One deployment record: counts, per-server tasks, and the publish summary."""
912
1139
  with _client(ctx) as c:
913
1140
  _call(ctx, c.flow_deployment, project, flow, deployment)
914
1141
 
915
1142
 
1143
+ @flow_app.command("versions")
1144
+ def cmd_flow_versions(
1145
+ ctx: typer.Context,
1146
+ project: str = _OPT_PROJECT,
1147
+ flow: str = _OPT_FLOW,
1148
+ page: int = typer.Option(1, "--page", min=1, help="Page number, starting at 1."),
1149
+ page_size: int = typer.Option(10, "--page-size", min=1, max=100, help="Page size, at most 100."),
1150
+ ) -> None:
1151
+ """List historical versions (newest first), including the draft. Requires view_version."""
1152
+ with _client(ctx) as c:
1153
+ data = _fetch(ctx, c.flow_versions, project, flow, page=page, page_size=page_size)
1154
+ _show_page(ctx, data, format_version_records)
1155
+
1156
+
1157
+ @flow_app.command("get-version")
1158
+ def cmd_flow_get_version(
1159
+ ctx: typer.Context,
1160
+ project: str = _OPT_PROJECT,
1161
+ flow: str = _OPT_FLOW,
1162
+ version_id: str = typer.Option(..., "--version-id", help="Version public id (v-...)."),
1163
+ ) -> None:
1164
+ """Canvas JSON of one historical version. Read-only; not a draft lock. Requires view_version."""
1165
+ with _client(ctx) as c:
1166
+ _call(ctx, c.flow_version, project, flow, version_id)
1167
+
1168
+
916
1169
  # --- config ---
917
1170
  config_app = typer.Typer(help="Write/read the openapi section of ~/.buildifyrc (overridden by env at runtime).")
918
1171
  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",
@@ -147,6 +156,43 @@ class OpenApiClient:
147
156
  payload["description"] = description
148
157
  return self._post(f"{API_PREFIX}/projects/{project_id}/credentials", payload)
149
158
 
159
+ def project_update_credential(
160
+ self,
161
+ project_id: str,
162
+ credentials_id: str,
163
+ *,
164
+ label: Optional[str] = None,
165
+ description: Optional[str] = None,
166
+ bundle_version: Optional[str] = None,
167
+ data: Optional[dict[str, Any]] = None,
168
+ ) -> tuple[int, Any]:
169
+ payload: dict[str, Any] = {}
170
+ if label is not None:
171
+ payload["label"] = label
172
+ if description is not None:
173
+ payload["description"] = description
174
+ if bundle_version is not None:
175
+ payload["bundleVersion"] = bundle_version
176
+ if data is not None:
177
+ payload["data"] = data
178
+ return self._post(f"{API_PREFIX}/projects/{project_id}/credentials/{credentials_id}", payload)
179
+
180
+ def project_delete_credential(self, project_id: str, credentials_id: str) -> tuple[int, Any]:
181
+ return self._delete(f"{API_PREFIX}/projects/{project_id}/credentials/{credentials_id}")
182
+
183
+ def project_credential_workflows(
184
+ self,
185
+ project_id: str,
186
+ credentials_id: str,
187
+ *,
188
+ page: int = 1,
189
+ page_size: int = 10,
190
+ ) -> tuple[int, Any]:
191
+ return self._get(
192
+ f"{API_PREFIX}/projects/{project_id}/credentials/{credentials_id}/workflows",
193
+ {"page": page, "pageSize": page_size},
194
+ )
195
+
150
196
  def project_workers(self, project_id: str, online_only: bool = False) -> tuple[int, Any]:
151
197
  return self._get(
152
198
  f"{API_PREFIX}/projects/{project_id}/workers",
@@ -171,6 +217,43 @@ class OpenApiClient:
171
217
  {"workflowName": workflow_name, "workerIds": worker_ids, "remark": remark},
172
218
  )
173
219
 
220
+ def flow_update(
221
+ self,
222
+ project_id: str,
223
+ workflow_id: str,
224
+ *,
225
+ workflow_name: str,
226
+ remark: Optional[str],
227
+ ) -> tuple[int, Any]:
228
+ payload: dict[str, Any] = {"workflowName": workflow_name}
229
+ if remark is not None:
230
+ payload["remark"] = remark
231
+ return self._post(f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/settings", payload)
232
+
233
+ def flow_delete(self, project_id: str, workflow_id: str) -> tuple[int, Any]:
234
+ return self._delete(f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}")
235
+
236
+ def flow_workers(self, project_id: str, workflow_id: str) -> tuple[int, Any]:
237
+ return self._get(f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/workers")
238
+
239
+ def flow_assign_workers(self, project_id: str, workflow_id: str, worker_ids: list[str]) -> tuple[int, Any]:
240
+ return self._post(
241
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/assign_workers",
242
+ {"workerIds": worker_ids},
243
+ )
244
+
245
+ def flow_unassign_workers(self, project_id: str, workflow_id: str, worker_ids: list[str]) -> tuple[int, Any]:
246
+ return self._post(
247
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/unassign_workers",
248
+ {"workerIds": worker_ids},
249
+ )
250
+
251
+ def flow_force_unassign_workers(self, project_id: str, workflow_id: str, worker_ids: list[str]) -> tuple[int, Any]:
252
+ return self._post(
253
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/force_unassign_workers",
254
+ {"workerIds": worker_ids},
255
+ )
256
+
174
257
  def flow_get_draft(self, project_id: str, workflow_id: str) -> tuple[int, Any]:
175
258
  return self._get(f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/draft")
176
259
 
@@ -250,6 +333,46 @@ class OpenApiClient:
250
333
  def flow_published(self, project_id: str, workflow_id: str) -> tuple[int, Any]:
251
334
  return self._get(f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/published")
252
335
 
336
+ def flow_deployments(
337
+ self,
338
+ project_id: str,
339
+ workflow_id: str,
340
+ *,
341
+ deployment_id: Optional[str] = None,
342
+ page: Optional[int] = None,
343
+ page_size: Optional[int] = None,
344
+ begin_time: Optional[str] = None,
345
+ end_time: Optional[str] = None,
346
+ ) -> tuple[int, Any]:
347
+ return self._get(
348
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/deployments",
349
+ {
350
+ "deploymentId": deployment_id,
351
+ "page": page,
352
+ "pageSize": page_size,
353
+ "beginTime": begin_time,
354
+ "endTime": end_time,
355
+ },
356
+ )
357
+
358
+ def flow_versions(
359
+ self,
360
+ project_id: str,
361
+ workflow_id: str,
362
+ *,
363
+ page: Optional[int] = None,
364
+ page_size: Optional[int] = None,
365
+ ) -> tuple[int, Any]:
366
+ return self._get(
367
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/versions",
368
+ {"page": page, "pageSize": page_size},
369
+ )
370
+
371
+ def flow_version(self, project_id: str, workflow_id: str, version_id: str) -> tuple[int, Any]:
372
+ return self._get(
373
+ f"{API_PREFIX}/projects/{project_id}/workflows/{workflow_id}/versions/{version_id}"
374
+ )
375
+
253
376
  def flow_deployment(
254
377
  self,
255
378
  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,31 @@ 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` |
101
+ | `project update-credential` | POST | `/projects/{id}/credentials/{credentialsId}` |
102
+ | `project delete-credential` | DELETE | `/projects/{id}/credentials/{credentialsId}` |
103
+ | `project credential-workflows` | GET | `/projects/{id}/credentials/{credentialsId}/workflows` |
93
104
  | `project workers` | GET | `/projects/{id}/workers` |
94
105
  | `workflow list` / `create` | GET / POST | `/projects/{id}/workflows` |
106
+ | `workflow update` | POST | `/projects/{id}/workflows/{wid}/settings` |
107
+ | `workflow workers` | GET | `/projects/{id}/workflows/{wid}/workers` |
108
+ | `workflow assign-workers` | POST | `/projects/{id}/workflows/{wid}/assign_workers` |
109
+ | `workflow unassign-workers` | POST | `/projects/{id}/workflows/{wid}/unassign_workers` |
110
+ | `workflow force-unassign-workers` | POST | `/projects/{id}/workflows/{wid}/force_unassign_workers` |
111
+ | `workflow delete` | DELETE | `/projects/{id}/workflows/{wid}` |
95
112
  | `workflow get-draft` / `save-draft` | GET / POST | `/projects/{id}/workflows/{wid}/draft` |
96
113
  | `workflow validate` | POST | `/projects/{id}/workflows/validate` |
97
114
  | `workflow test-run` | POST | `/projects/{id}/workflows/test_runs` |
98
115
  | `workflow cancel-test` | POST | `/projects/{id}/workflows/test_runs/{executionId}/cancel` |
99
116
  | `workflow deploy` | POST | `/projects/{id}/workflows/{wid}/deploy` |
100
117
  | `workflow published` | GET | `/projects/{id}/workflows/{wid}/published` |
118
+ | `workflow deployments` | GET | `/projects/{id}/workflows/{wid}/deployments` |
101
119
  | `workflow deployment` | GET | `/projects/{id}/workflows/{wid}/deployments/{deployId}` |
120
+ | `workflow versions` | GET | `/projects/{id}/workflows/{wid}/versions` |
121
+ | `workflow get-version` | GET | `/projects/{id}/workflows/{wid}/versions/{versionId}` |
102
122
 
103
123
  ## Typical session
104
124
 
@@ -140,6 +160,19 @@ buildify --json workflow deploy -p proj_xxx -w wf_xxx --worker wk_xxx \
140
160
 
141
161
  `--file -` reads JSON from stdin (flow canvas or credential `data`).
142
162
 
163
+ ## Changing projects, servers, and deleting flows
164
+
165
+ Ask the user before any of these. They change where a flow runs or remove data.
166
+
167
+ - `project update -p ID -n "新名称" [--remark 简介]` renames a project. Omit `--remark` to keep the current description.
168
+ - `project delete -p ID` deletes a project only when it has no workflows (otherwise HTTP 409).
169
+ - `workflow update -p ID -w WF -n "新名称" [--remark 简介]` renames a workflow.
170
+ - `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.
171
+ - `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.
172
+ - `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.
173
+ - `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.
174
+ - `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).
175
+
143
176
  ## project create-credential
144
177
 
145
178
  Requires `--project`. Also pass `--bundle` and `--type`. If `--name` / `--label` / credential
@@ -154,6 +187,16 @@ Requires `--project`. Also pass `--bundle` and `--type`. If `--name` / `--label`
154
187
  Response is metadata only (`name` / `label` / `type` / `credentialsId`). Duplicate
155
188
  `bundle+type+name` in the same project returns HTTP 409.
156
189
 
190
+ `project credentials` includes `credentialsId` for the commands below. Secrets are never returned.
191
+
192
+ ## project update-credential / delete-credential / credential-workflows
193
+
194
+ Ask the user before update or delete.
195
+
196
+ - `project update-credential -p ID --credentials cred_…` changes label, remark, bundle version, or form data. Pass at least one of `--label`, `--description`, `--bundle-version`, `--file`. Omitted fields stay unchanged. An empty `--description` clears the remark. `--file -` merges form fields; masked or blank secret fields keep the stored value. Secrets are never printed. A TTY asks for confirmation unless `--yes`; `--json` does not prompt.
197
+ - `project delete-credential -p ID --credentials cred_…` soft-deletes a credential. HTTP 409 when a workflow still references it.
198
+ - `project credential-workflows -p ID --credentials cred_… [--page 1] [--page-size 10]` lists workflows that use the credential. No secrets. Same view permission as `project credentials`.
199
+
157
200
  ## Version conflicts on save-draft
158
201
 
159
202
  `workflow save-draft` reads the current draft to obtain the optimistic-lock `version` unless you pass
@@ -166,7 +209,7 @@ rather than retrying blindly.
166
209
  Checks, in this order: canvas structure, node existence in the workspace, `data.parameters`
167
210
  against each node's `properties_json`, `edges[].data.relations[].name` against the source node's
168
211
  `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),
212
+ against the project's credentials, graph shape (trigger present, orphan nodes),
170
213
  and WARNINGs if catalog `icon` is missing on a node or an edge relation is missing `label` /
171
214
  custom `icon`/`_id`.
172
215
 
@@ -232,7 +275,18 @@ Without `--json`, `workflow deploy` / `published` / `deployment` print Markdown
232
275
  (项目 / 服务器 / 凭证;凭证表含 `name` 与 `label`,不含节点表)。
233
276
  With `--json`, agents should render the same tables (see buildify-flow-dev skill).
234
277
 
235
- `workflow published` is the live snapshot after deploy. `workflow deployment -d deploy_…` is one attempt.
278
+ `workflow published` is the live snapshot after deploy. `workflow deployment -d deploy_…` is one attempt:
279
+ counts (`successCount` includes warnings), per-server `items`, and the same `summary` tables.
280
+
281
+ `workflow deployments` lists records newest first. Needs scope `project:workflow:view_deployment`.
282
+ `--page` / `--page-size` (max 100), optional `--deployment deploy_…`, and `--begin` / `--end`
283
+ (`yyyy-MM-dd HH:mm:ss`, quote the space). Without `--json` it prints a table; `--json` returns the
284
+ page (`total`, `page`, `pageSize`, `data`, `hasNext`).
285
+
286
+ `workflow versions` lists historical versions newest first, including the current draft (`isDraft`)
287
+ and any version still bound to a server (`isRunning`). Needs `project:workflow:view_version`.
288
+ `workflow get-version --version-id v-…` returns that version's canvas (`workflowJson`). It is
289
+ read-only and does not return the draft optimistic-lock `version`.
236
290
 
237
291
  ## JSON output schema
238
292
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildify-cli
3
- Version: 1.0.0
3
+ Version: 1.2.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
@@ -65,8 +65,8 @@ interactive prompt. `config set-key api_key` still updates the current profile,
65
65
  | — | `health`, `version`, `help` |
66
66
  | `key` | `test` |
67
67
  | `bundle` | `list`, `nodes`, `node-properties`, `node-relations`, `doc`, `cred-types`, `cred-properties` |
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` |
68
+ | `project` | `list`, `create`, `get`, `update`, `delete`, `credentials`, `create-credential`, `update-credential`, `delete-credential`, `credential-workflows`, `workers` |
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