buildify-cli 1.0.0__py3-none-any.whl
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.
- buildify_cli/__init__.py +1 -0
- buildify_cli/__main__.py +4 -0
- buildify_cli/cli.py +1206 -0
- buildify_cli/client.py +288 -0
- buildify_cli/config.py +593 -0
- buildify_cli/credential_form.py +156 -0
- buildify_cli/exit_codes.py +8 -0
- buildify_cli/output.py +46 -0
- buildify_cli/publish_view.py +122 -0
- buildify_cli/usage.md +245 -0
- buildify_cli-1.0.0.dist-info/METADATA +95 -0
- buildify_cli-1.0.0.dist-info/RECORD +15 -0
- buildify_cli-1.0.0.dist-info/WHEEL +5 -0
- buildify_cli-1.0.0.dist-info/entry_points.txt +2 -0
- buildify_cli-1.0.0.dist-info/top_level.txt +1 -0
buildify_cli/cli.py
ADDED
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from buildify_cli import __version__
|
|
14
|
+
from buildify_cli import exit_codes as ec
|
|
15
|
+
from buildify_cli.client import OpenApiClient, interpret_r_body, operation_ok
|
|
16
|
+
from buildify_cli.config import (
|
|
17
|
+
ENV_API_KEY,
|
|
18
|
+
ENV_BASE_URL,
|
|
19
|
+
ENV_JSON,
|
|
20
|
+
ENV_NO_PROMPT,
|
|
21
|
+
ENV_PROFILE,
|
|
22
|
+
API_KEY_SETUP_GUIDE,
|
|
23
|
+
MISSING_API_KEY_USER_MESSAGE,
|
|
24
|
+
RC_KEYS,
|
|
25
|
+
ResolvedConfig,
|
|
26
|
+
apply_key_test_metadata,
|
|
27
|
+
config_file_path,
|
|
28
|
+
env_flag,
|
|
29
|
+
list_stored_profiles,
|
|
30
|
+
load_file_config,
|
|
31
|
+
mask_api_key,
|
|
32
|
+
missing_api_key_payload,
|
|
33
|
+
normalize_api_key,
|
|
34
|
+
profile_public,
|
|
35
|
+
remove_profile,
|
|
36
|
+
resolve_config,
|
|
37
|
+
save_profile,
|
|
38
|
+
set_current_profile,
|
|
39
|
+
validate_profile_name,
|
|
40
|
+
write_config_file,
|
|
41
|
+
write_rc_key,
|
|
42
|
+
)
|
|
43
|
+
from buildify_cli.credential_form import (
|
|
44
|
+
default_credential_label,
|
|
45
|
+
default_credential_name,
|
|
46
|
+
form_fields,
|
|
47
|
+
prompt_payload,
|
|
48
|
+
)
|
|
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
|
|
51
|
+
|
|
52
|
+
app = typer.Typer(
|
|
53
|
+
help="Buildify open API CLI: discover nodes, author workflows, validate, test run and deploy.",
|
|
54
|
+
epilog=(
|
|
55
|
+
"Default base URL: https://openapi.buildify.cn\n"
|
|
56
|
+
"Auth: Bearer <keyId>.<secret> — keys are issued in the console "
|
|
57
|
+
"(workspace settings → Open API keys), not via this CLI.\n"
|
|
58
|
+
"If no key is configured, an interactive TTY prompts and saves to ~/.buildifyrc (mode 600).\n\n"
|
|
59
|
+
"Subcommands:\n"
|
|
60
|
+
" health GET /openapi/v1/health (no key)\n"
|
|
61
|
+
" key test Validate the configured key\n"
|
|
62
|
+
" 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"
|
|
64
|
+
" workflow list, create, get-draft, save-draft, validate, test-run, cancel-test,\n"
|
|
65
|
+
" deploy, published, deployment\n"
|
|
66
|
+
" config Manage the [openapi] section of ~/.buildifyrc\n"
|
|
67
|
+
" version Print CLI version\n"
|
|
68
|
+
" help Markdown reference on stdout (for AI/agents)\n\n"
|
|
69
|
+
f"Env: {ENV_BASE_URL}, {ENV_API_KEY}, {ENV_PROFILE}, {ENV_JSON}, {ENV_NO_PROMPT}\n"
|
|
70
|
+
"Exit: 0 ok | 1 generic | 2 auth/config | 3 local | 4 HTTP 4xx | 5 network"
|
|
71
|
+
),
|
|
72
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.callback()
|
|
77
|
+
def main(
|
|
78
|
+
ctx: typer.Context,
|
|
79
|
+
base_url: Optional[str] = typer.Option(
|
|
80
|
+
None, "--base-url", help=f"API base URL (default https://openapi.buildify.cn or {ENV_BASE_URL})."
|
|
81
|
+
),
|
|
82
|
+
api_key: Optional[str] = typer.Option(
|
|
83
|
+
None,
|
|
84
|
+
"--api-key",
|
|
85
|
+
help=f"Bearer secret keyId.secret. Insecure in argv; prefer {ENV_API_KEY}, stdin, or ~/.buildifyrc.",
|
|
86
|
+
),
|
|
87
|
+
json_out: bool = typer.Option(False, "--json", help="Print one JSON envelope on stdout; errors also JSON."),
|
|
88
|
+
profile: Optional[str] = typer.Option(
|
|
89
|
+
None,
|
|
90
|
+
"--profile",
|
|
91
|
+
help=f"Named key in ~/.buildifyrc. Overrides the current profile. Env: {ENV_PROFILE}.",
|
|
92
|
+
),
|
|
93
|
+
quiet: bool = typer.Option(False, "--quiet", help="Less stderr noise."),
|
|
94
|
+
) -> None:
|
|
95
|
+
ctx.ensure_object(dict)
|
|
96
|
+
if api_key:
|
|
97
|
+
human_err(
|
|
98
|
+
"warning: --api-key is visible in process lists and shell history; "
|
|
99
|
+
f"prefer `buildify config set-key api_key` or {ENV_API_KEY}"
|
|
100
|
+
)
|
|
101
|
+
ctx.obj["resolved"] = resolve_config(
|
|
102
|
+
cli_base_url=base_url,
|
|
103
|
+
cli_api_key=api_key,
|
|
104
|
+
cli_json=json_out,
|
|
105
|
+
cli_profile=profile,
|
|
106
|
+
)
|
|
107
|
+
ctx.obj["quiet"] = quiet
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _cfg(ctx: typer.Context) -> ResolvedConfig:
|
|
111
|
+
return ctx.obj["resolved"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _fail_local(ctx: typer.Context, msg: str, code: int = ec.LOCAL_VALIDATION, data: Any = None) -> typer.Exit:
|
|
115
|
+
"""Report a client-side problem without ever reaching the network."""
|
|
116
|
+
if _cfg(ctx).json_output:
|
|
117
|
+
print_json(ok=False, code=0, msg=msg, http_status=0, data=data)
|
|
118
|
+
else:
|
|
119
|
+
human_err(msg)
|
|
120
|
+
return typer.Exit(code)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _can_prompt_for_key(ctx: typer.Context) -> bool:
|
|
124
|
+
if _cfg(ctx).json_output or env_flag(ENV_NO_PROMPT):
|
|
125
|
+
return False
|
|
126
|
+
return is_tty(sys.stdin) and is_tty(sys.stderr)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _prompt_api_key_secret() -> str:
|
|
130
|
+
"""Hidden prompt; prints how-to-get + security notes on stderr. Never echoes the secret."""
|
|
131
|
+
human_err(API_KEY_SETUP_GUIDE)
|
|
132
|
+
human_err("")
|
|
133
|
+
raw = typer.prompt("Paste API key (input hidden)", hide_input=True, err=True)
|
|
134
|
+
return normalize_api_key(raw)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _save_api_key_locally(ctx: typer.Context, secret: str) -> str:
|
|
138
|
+
key = normalize_api_key(secret)
|
|
139
|
+
path = write_rc_key("api_key", key)
|
|
140
|
+
ctx.obj["resolved"] = replace(_cfg(ctx), api_key=key)
|
|
141
|
+
if not _cfg(ctx).json_output:
|
|
142
|
+
human_err(f"Saved api_key to {path} (mode 600, masked {mask_api_key(key)})")
|
|
143
|
+
return key
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _require_api_key(ctx: typer.Context) -> str:
|
|
147
|
+
blocked = _cfg(ctx).auth_error
|
|
148
|
+
if blocked:
|
|
149
|
+
raise _fail_local(
|
|
150
|
+
ctx,
|
|
151
|
+
str(blocked.get("userMessage") or "api key profile is not selected"),
|
|
152
|
+
ec.AUTH_CONFIG,
|
|
153
|
+
data=blocked,
|
|
154
|
+
)
|
|
155
|
+
existing = _cfg(ctx).api_key
|
|
156
|
+
if existing:
|
|
157
|
+
return existing
|
|
158
|
+
if _can_prompt_for_key(ctx):
|
|
159
|
+
try:
|
|
160
|
+
return _save_api_key_locally(ctx, _prompt_api_key_secret())
|
|
161
|
+
except (ValueError, typer.Abort) as e:
|
|
162
|
+
msg = str(e) if isinstance(e, ValueError) else "api key not provided"
|
|
163
|
+
raise _fail_local(ctx, msg, ec.AUTH_CONFIG, data=missing_api_key_payload())
|
|
164
|
+
raise _fail_local(
|
|
165
|
+
ctx,
|
|
166
|
+
MISSING_API_KEY_USER_MESSAGE.strip(),
|
|
167
|
+
ec.AUTH_CONFIG,
|
|
168
|
+
data=missing_api_key_payload(),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _client(ctx: typer.Context, *, need_auth: bool = True) -> OpenApiClient:
|
|
173
|
+
cfg = _cfg(ctx)
|
|
174
|
+
key = _require_api_key(ctx) if need_auth else None
|
|
175
|
+
return OpenApiClient(cfg.base_url, key)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _emit(
|
|
179
|
+
ctx: typer.Context,
|
|
180
|
+
*,
|
|
181
|
+
http_status: int,
|
|
182
|
+
body: Any,
|
|
183
|
+
ok_override: Optional[bool] = None,
|
|
184
|
+
msg_override: Optional[str] = None,
|
|
185
|
+
) -> int:
|
|
186
|
+
"""Print result; return process exit code."""
|
|
187
|
+
cfg = _cfg(ctx)
|
|
188
|
+
code, smsg, data = interpret_r_body(body)
|
|
189
|
+
if http_status == 404:
|
|
190
|
+
data = _with_workspace_hint(ctx, data)
|
|
191
|
+
ok = ok_override if ok_override is not None else operation_ok(http_status, body)
|
|
192
|
+
msg = msg_override if msg_override is not None else (smsg or "")
|
|
193
|
+
if not msg and isinstance(body, dict):
|
|
194
|
+
msg = str(body.get("msg") or body.get("message") or "")
|
|
195
|
+
if not msg and not ok:
|
|
196
|
+
msg = str(body)[:500] if body is not None else "request failed"
|
|
197
|
+
|
|
198
|
+
if cfg.json_output:
|
|
199
|
+
print_json(
|
|
200
|
+
ok=ok,
|
|
201
|
+
code=code if code is not None else (200 if ok else http_status),
|
|
202
|
+
msg=msg,
|
|
203
|
+
http_status=http_status,
|
|
204
|
+
data=data,
|
|
205
|
+
)
|
|
206
|
+
elif ok:
|
|
207
|
+
payload = data if data is not None else body
|
|
208
|
+
if is_publish_payload(payload):
|
|
209
|
+
typer.echo(format_publish_report(payload))
|
|
210
|
+
else:
|
|
211
|
+
typer.echo(json.dumps(payload, ensure_ascii=False, indent=2) if payload is not None else "{}")
|
|
212
|
+
else:
|
|
213
|
+
human_err(f"[{http_status}] {msg}")
|
|
214
|
+
|
|
215
|
+
if ok:
|
|
216
|
+
return ec.OK
|
|
217
|
+
if http_status in (401, 403) or code in (401, 403):
|
|
218
|
+
return ec.AUTH_CONFIG
|
|
219
|
+
if 400 <= http_status < 600:
|
|
220
|
+
return ec.SERVER_CLIENT_ERROR
|
|
221
|
+
if code is not None and code != 200:
|
|
222
|
+
return ec.SERVER_CLIENT_ERROR
|
|
223
|
+
return ec.GENERIC
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _with_workspace_hint(ctx: typer.Context, data: Any) -> Any:
|
|
227
|
+
"""404 in the wrong workspace should tell the agent which key was used."""
|
|
228
|
+
cfg = _cfg(ctx)
|
|
229
|
+
hint = {
|
|
230
|
+
"tenantId": cfg.tenant_id,
|
|
231
|
+
"profile": cfg.profile,
|
|
232
|
+
"hint": (
|
|
233
|
+
"Resource was not found in the workspace bound to this key. "
|
|
234
|
+
"Run `buildify --json config profiles` before creating a project in another workspace."
|
|
235
|
+
),
|
|
236
|
+
}
|
|
237
|
+
if isinstance(data, dict):
|
|
238
|
+
return {**data, **hint}
|
|
239
|
+
return {"body": data, **hint}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _emit_network_error(ctx: typer.Context, exc: BaseException) -> int:
|
|
243
|
+
msg = str(exc) or exc.__class__.__name__
|
|
244
|
+
if _cfg(ctx).json_output:
|
|
245
|
+
print_json(ok=False, code=0, msg=msg, http_status=0, data=None)
|
|
246
|
+
else:
|
|
247
|
+
human_err(msg)
|
|
248
|
+
return ec.NETWORK
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _call(ctx: typer.Context, fn, *args, **kwargs) -> None:
|
|
252
|
+
"""Run one client call and exit with the mapped code."""
|
|
253
|
+
try:
|
|
254
|
+
status, body = fn(*args, **kwargs)
|
|
255
|
+
except httpx.RequestError as e:
|
|
256
|
+
raise typer.Exit(_emit_network_error(ctx, e))
|
|
257
|
+
raise typer.Exit(_emit(ctx, http_status=status, body=body))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _fetch(ctx: typer.Context, fn, *args, **kwargs) -> Any:
|
|
261
|
+
"""Run one client call and return R.data, or exit on failure."""
|
|
262
|
+
try:
|
|
263
|
+
status, body = fn(*args, **kwargs)
|
|
264
|
+
except httpx.RequestError as e:
|
|
265
|
+
raise typer.Exit(_emit_network_error(ctx, e))
|
|
266
|
+
if not operation_ok(status, body):
|
|
267
|
+
raise typer.Exit(_emit(ctx, http_status=status, body=body))
|
|
268
|
+
_, _, data = interpret_r_body(body)
|
|
269
|
+
return data
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _read_json_arg(ctx: typer.Context, value: Optional[str], file: Optional[Path], *, what: str) -> Any:
|
|
273
|
+
"""Accept inline JSON, a file path, or '-' for stdin."""
|
|
274
|
+
if value is not None and file is not None:
|
|
275
|
+
raise _fail_local(ctx, f"{what}: pass either the inline value or --file, not both")
|
|
276
|
+
raw: Optional[str] = None
|
|
277
|
+
if file is not None:
|
|
278
|
+
if str(file) == "-":
|
|
279
|
+
raw = sys.stdin.read()
|
|
280
|
+
else:
|
|
281
|
+
if not file.is_file():
|
|
282
|
+
raise _fail_local(ctx, f"{what}: not a file: {file}")
|
|
283
|
+
try:
|
|
284
|
+
raw = file.read_text(encoding="utf-8")
|
|
285
|
+
except OSError as e:
|
|
286
|
+
raise _fail_local(ctx, f"{what}: cannot read {file}: {e}")
|
|
287
|
+
elif value is not None:
|
|
288
|
+
raw = sys.stdin.read() if value == "-" else value
|
|
289
|
+
if raw is None:
|
|
290
|
+
raise _fail_local(ctx, f"{what} is required")
|
|
291
|
+
try:
|
|
292
|
+
return json.loads(raw)
|
|
293
|
+
except json.JSONDecodeError as e:
|
|
294
|
+
raise _fail_local(ctx, f"{what}: invalid JSON: {e}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.command("version")
|
|
298
|
+
def cmd_version() -> None:
|
|
299
|
+
"""Print CLI version (from package metadata when installed)."""
|
|
300
|
+
try:
|
|
301
|
+
from importlib.metadata import version
|
|
302
|
+
|
|
303
|
+
typer.echo(version("buildify-cli"))
|
|
304
|
+
except Exception:
|
|
305
|
+
typer.echo(__version__)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@app.command("help")
|
|
309
|
+
def cmd_help() -> None:
|
|
310
|
+
"""Print Markdown usage reference on stdout (for AI agents and scripts)."""
|
|
311
|
+
typer.echo((Path(__file__).resolve().parent / "usage.md").read_text(encoding="utf-8"))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.command("health")
|
|
315
|
+
def cmd_health(ctx: typer.Context) -> None:
|
|
316
|
+
"""GET /openapi/v1/health — no authentication."""
|
|
317
|
+
with _client(ctx, need_auth=False) as c:
|
|
318
|
+
_call(ctx, c.health)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- key ---
|
|
322
|
+
key_app = typer.Typer(help="API key operations.")
|
|
323
|
+
app.add_typer(key_app, name="key")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@key_app.command("test")
|
|
327
|
+
def cmd_key_test(ctx: typer.Context) -> None:
|
|
328
|
+
"""Validate the configured key and echo the workspace it belongs to.
|
|
329
|
+
|
|
330
|
+
Example: buildify --json key test
|
|
331
|
+
"""
|
|
332
|
+
with _client(ctx) as c:
|
|
333
|
+
try:
|
|
334
|
+
status, body = c.key_test()
|
|
335
|
+
except httpx.RequestError as e:
|
|
336
|
+
raise typer.Exit(_emit_network_error(ctx, e))
|
|
337
|
+
if operation_ok(status, body):
|
|
338
|
+
_, _, data = interpret_r_body(body)
|
|
339
|
+
if isinstance(data, dict):
|
|
340
|
+
apply_key_test_metadata(_cfg(ctx).profile, data)
|
|
341
|
+
raise typer.Exit(_emit(ctx, http_status=status, body=body))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# --- bundle ---
|
|
345
|
+
bundle_app = typer.Typer(help="Bundle and node discovery (read-only).")
|
|
346
|
+
app.add_typer(bundle_app, name="bundle")
|
|
347
|
+
|
|
348
|
+
_OPT_BUNDLE = typer.Option(..., "--bundle", "-b", help="Bundle logical name (may contain /).")
|
|
349
|
+
_OPT_BUNDLE_VERSION = typer.Option(
|
|
350
|
+
None, "--bundle-version", "-V", help="Bundle version; omit to use the latest installed version."
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@bundle_app.command("list")
|
|
355
|
+
def cmd_bundle_list(
|
|
356
|
+
ctx: typer.Context,
|
|
357
|
+
keyword: Optional[str] = typer.Option(None, "--keyword", "-k", help="Filter by name or label substring."),
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Bundles usable by this workspace (owned + granted + system)."""
|
|
360
|
+
with _client(ctx) as c:
|
|
361
|
+
_call(ctx, c.bundle_list, keyword)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@bundle_app.command("nodes")
|
|
365
|
+
def cmd_bundle_nodes(
|
|
366
|
+
ctx: typer.Context,
|
|
367
|
+
bundle: str = _OPT_BUNDLE,
|
|
368
|
+
bundle_version: Optional[str] = _OPT_BUNDLE_VERSION,
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Flattened node catalog: name, label, summary, icon, isTrigger."""
|
|
371
|
+
with _client(ctx) as c:
|
|
372
|
+
_call(ctx, c.bundle_nodes, bundle, bundle_version)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@bundle_app.command("node-properties")
|
|
376
|
+
def cmd_node_properties(
|
|
377
|
+
ctx: typer.Context,
|
|
378
|
+
bundle: str = _OPT_BUNDLE,
|
|
379
|
+
name: str = typer.Option(..., "--name", "-n", help="Node name (@FlowNodeDescription.name)."),
|
|
380
|
+
bundle_version: Optional[str] = _OPT_BUNDLE_VERSION,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Dynamic form schema of one node — also returns icon/label/summary for the canvas."""
|
|
383
|
+
with _client(ctx) as c:
|
|
384
|
+
_call(ctx, c.node_properties, bundle, bundle_version, name)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@bundle_app.command("node-relations")
|
|
388
|
+
def cmd_node_relations(
|
|
389
|
+
ctx: typer.Context,
|
|
390
|
+
bundle: str = _OPT_BUNDLE,
|
|
391
|
+
name: str = typer.Option(..., "--name", "-n", help="Node name (@FlowNodeDescription.name)."),
|
|
392
|
+
bundle_version: Optional[str] = _OPT_BUNDLE_VERSION,
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Output relations of one node — copy the whole object onto `edges[].data.relations[]` (name/label/description, plus icon/_id for custom MCP relations)."""
|
|
395
|
+
with _client(ctx) as c:
|
|
396
|
+
_call(ctx, c.node_relations, bundle, bundle_version, name)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@bundle_app.command("doc")
|
|
400
|
+
def cmd_bundle_doc(
|
|
401
|
+
ctx: typer.Context,
|
|
402
|
+
bundle: str = _OPT_BUNDLE,
|
|
403
|
+
bundle_version: Optional[str] = _OPT_BUNDLE_VERSION,
|
|
404
|
+
) -> None:
|
|
405
|
+
"""Bundle README / usage document (Markdown)."""
|
|
406
|
+
with _client(ctx) as c:
|
|
407
|
+
_call(ctx, c.bundle_doc, bundle, bundle_version)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@bundle_app.command("cred-types")
|
|
411
|
+
def cmd_cred_types(
|
|
412
|
+
ctx: typer.Context,
|
|
413
|
+
bundle: str = _OPT_BUNDLE,
|
|
414
|
+
bundle_version: Optional[str] = _OPT_BUNDLE_VERSION,
|
|
415
|
+
) -> None:
|
|
416
|
+
"""Credential types declared by a bundle."""
|
|
417
|
+
with _client(ctx) as c:
|
|
418
|
+
_call(ctx, c.credentials_types, bundle, bundle_version)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@bundle_app.command("cred-properties")
|
|
422
|
+
def cmd_cred_properties(
|
|
423
|
+
ctx: typer.Context,
|
|
424
|
+
bundle: str = _OPT_BUNDLE,
|
|
425
|
+
type_: str = typer.Option(..., "--type", "-t", help="Credential type (PascalCase)."),
|
|
426
|
+
bundle_version: Optional[str] = _OPT_BUNDLE_VERSION,
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Dynamic form schema of one credential type."""
|
|
429
|
+
with _client(ctx) as c:
|
|
430
|
+
_call(ctx, c.credentials_properties, bundle, bundle_version, type_)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# --- project ---
|
|
434
|
+
project_app = typer.Typer(help="Projects, credentials and workers.")
|
|
435
|
+
app.add_typer(project_app, name="project")
|
|
436
|
+
|
|
437
|
+
_OPT_PROJECT = typer.Option(..., "--project", "-p", help="Project public id (proj_...).")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@project_app.command("list")
|
|
441
|
+
def cmd_project_list(ctx: typer.Context) -> None:
|
|
442
|
+
"""Projects in the workspace bound to the API key."""
|
|
443
|
+
with _client(ctx) as c:
|
|
444
|
+
_call(ctx, c.project_list)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@project_app.command("create")
|
|
448
|
+
def cmd_project_create(
|
|
449
|
+
ctx: typer.Context,
|
|
450
|
+
name: str = typer.Option(..., "--name", "-n", help="Project name."),
|
|
451
|
+
remark: Optional[str] = typer.Option(None, "--remark", help="Optional description."),
|
|
452
|
+
) -> None:
|
|
453
|
+
"""Create a project."""
|
|
454
|
+
with _client(ctx) as c:
|
|
455
|
+
_call(ctx, c.project_create, name, remark)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@project_app.command("get")
|
|
459
|
+
def cmd_project_get(ctx: typer.Context, project: str = _OPT_PROJECT) -> None:
|
|
460
|
+
"""Project detail."""
|
|
461
|
+
with _client(ctx) as c:
|
|
462
|
+
_call(ctx, c.project_get, project)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@project_app.command("credentials")
|
|
466
|
+
def cmd_project_credentials(
|
|
467
|
+
ctx: typer.Context,
|
|
468
|
+
project: str = _OPT_PROJECT,
|
|
469
|
+
bundle: Optional[str] = typer.Option(None, "--bundle", "-b", help="Filter by bundle name."),
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Credentials referenceable from a flow. Secrets are never returned.
|
|
472
|
+
|
|
473
|
+
Use `bundleName` / `type` / `name` from here to fill a node's `data.credentials`.
|
|
474
|
+
"""
|
|
475
|
+
with _client(ctx) as c:
|
|
476
|
+
_call(ctx, c.project_credentials, project, bundle)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@project_app.command("create-credential")
|
|
480
|
+
def cmd_project_create_credential(
|
|
481
|
+
ctx: typer.Context,
|
|
482
|
+
project: str = _OPT_PROJECT,
|
|
483
|
+
bundle: str = _OPT_BUNDLE,
|
|
484
|
+
type_: str = typer.Option(..., "--type", "-t", help="Credential type (from `bundle cred-types`)."),
|
|
485
|
+
name: Optional[str] = typer.Option(None, "--name", "-n", help="Instance name referenced by flows. Confirm before create."),
|
|
486
|
+
label: Optional[str] = typer.Option(None, "--label", help="Display name. Confirm before create."),
|
|
487
|
+
description: Optional[str] = typer.Option(None, "--description", help="Optional remark."),
|
|
488
|
+
bundle_version: Optional[str] = _OPT_BUNDLE_VERSION,
|
|
489
|
+
data_inline: Optional[str] = typer.Option(
|
|
490
|
+
None,
|
|
491
|
+
"--data",
|
|
492
|
+
help="JSON object of form fields. Insecure in argv; prefer --file - / stdin.",
|
|
493
|
+
),
|
|
494
|
+
file: Optional[Path] = typer.Option(
|
|
495
|
+
None,
|
|
496
|
+
"--file",
|
|
497
|
+
"-f",
|
|
498
|
+
help="JSON file of form fields, or '-' for stdin.",
|
|
499
|
+
),
|
|
500
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the TTY confirmation prompt."),
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Create a project credential. Requires -p. Prompts for missing name/label/data.
|
|
503
|
+
|
|
504
|
+
Secrets are never printed. Agent: show returned defaults, wait for user confirm, then
|
|
505
|
+
pipe data JSON via stdin.
|
|
506
|
+
"""
|
|
507
|
+
if data_inline:
|
|
508
|
+
human_err("warning: --data is visible in process lists and shell history; prefer --file -")
|
|
509
|
+
data: Optional[dict[str, Any]] = None
|
|
510
|
+
if data_inline is not None or file is not None:
|
|
511
|
+
parsed = _read_json_arg(ctx, data_inline, file, what="credential data")
|
|
512
|
+
if not isinstance(parsed, dict):
|
|
513
|
+
raise _fail_local(ctx, "credential data must be a JSON object")
|
|
514
|
+
data = parsed
|
|
515
|
+
|
|
516
|
+
with _client(ctx) as c:
|
|
517
|
+
project_row = _fetch(ctx, c.project_get, project)
|
|
518
|
+
project_name = str((project_row or {}).get("projectName") or project) if isinstance(project_row, dict) else project
|
|
519
|
+
types = _fetch(ctx, c.credentials_types, bundle, bundle_version) or []
|
|
520
|
+
type_row = next((row for row in types if isinstance(row, dict) and row.get("type") == type_), None)
|
|
521
|
+
if type_row is None and isinstance(types, list) and types:
|
|
522
|
+
raise _fail_local(
|
|
523
|
+
ctx,
|
|
524
|
+
f"凭证类型不存在: {type_}(请先 `bundle cred-types -b {bundle}`)",
|
|
525
|
+
)
|
|
526
|
+
type_label = str((type_row or {}).get("label") or type_)
|
|
527
|
+
resolved_version = bundle_version or (type_row or {}).get("bundleVersion")
|
|
528
|
+
props = _fetch(ctx, c.credentials_properties, bundle, resolved_version, type_) or {}
|
|
529
|
+
schema = props.get("schema") if isinstance(props, dict) else None
|
|
530
|
+
if not bundle_version and isinstance(props, dict) and props.get("bundleVersion"):
|
|
531
|
+
resolved_version = props.get("bundleVersion")
|
|
532
|
+
fields = form_fields(schema)
|
|
533
|
+
suggested_name = default_credential_name(bundle, type_)
|
|
534
|
+
suggested_label = default_credential_label(type_label, type_)
|
|
535
|
+
|
|
536
|
+
missing: list[str] = []
|
|
537
|
+
if not (name or "").strip():
|
|
538
|
+
missing.append("name")
|
|
539
|
+
if not (label or "").strip():
|
|
540
|
+
missing.append("label")
|
|
541
|
+
if data is None:
|
|
542
|
+
missing.append("data")
|
|
543
|
+
|
|
544
|
+
if missing and not _can_prompt_for_key(ctx):
|
|
545
|
+
payload = prompt_payload(
|
|
546
|
+
project_id=project,
|
|
547
|
+
project_name=project_name,
|
|
548
|
+
bundle_name=bundle,
|
|
549
|
+
bundle_version=str(resolved_version) if resolved_version else None,
|
|
550
|
+
type_name=type_,
|
|
551
|
+
type_label=type_label,
|
|
552
|
+
default_name=suggested_name,
|
|
553
|
+
default_label=suggested_label,
|
|
554
|
+
fields=fields,
|
|
555
|
+
missing=missing,
|
|
556
|
+
)
|
|
557
|
+
raise _fail_local(ctx, payload["userMessage"], ec.LOCAL_VALIDATION, data=payload)
|
|
558
|
+
|
|
559
|
+
chosen_name = (name or "").strip() or suggested_name
|
|
560
|
+
chosen_label = (label or "").strip() or suggested_label
|
|
561
|
+
if _can_prompt_for_key(ctx):
|
|
562
|
+
if not (name or "").strip():
|
|
563
|
+
chosen_name = str(typer.prompt("凭证 name(引用名)", default=suggested_name, err=True)).strip()
|
|
564
|
+
if not (label or "").strip():
|
|
565
|
+
chosen_label = str(typer.prompt("凭证 label(显示名)", default=suggested_label, err=True)).strip()
|
|
566
|
+
if data is None:
|
|
567
|
+
data = _prompt_credential_data(fields)
|
|
568
|
+
if not yes:
|
|
569
|
+
human_err("")
|
|
570
|
+
human_err("即将创建访问凭证,请确认:")
|
|
571
|
+
human_err(f" 项目:{project_name}")
|
|
572
|
+
human_err(f" 软件包:{bundle}")
|
|
573
|
+
human_err(f" 类型:{type_label}({type_})")
|
|
574
|
+
human_err(f" name:{chosen_name}" + (" (默认)" if chosen_name == suggested_name else ""))
|
|
575
|
+
human_err(f" label:{chosen_label}" + (" (默认)" if chosen_label == suggested_label else ""))
|
|
576
|
+
keys = ", ".join(field["name"] for field in fields) or "(无)"
|
|
577
|
+
human_err(f" 参数键:{keys}(值已隐藏)")
|
|
578
|
+
if not typer.confirm("确认创建?", default=False, err=True):
|
|
579
|
+
raise _fail_local(ctx, "已取消创建", ec.LOCAL_VALIDATION)
|
|
580
|
+
|
|
581
|
+
if not chosen_name or not chosen_label:
|
|
582
|
+
raise _fail_local(ctx, "name 和 label 不能为空")
|
|
583
|
+
if data is None:
|
|
584
|
+
data = {}
|
|
585
|
+
_call(
|
|
586
|
+
ctx,
|
|
587
|
+
c.project_create_credential,
|
|
588
|
+
project,
|
|
589
|
+
bundle_name=bundle,
|
|
590
|
+
type_=type_,
|
|
591
|
+
name=chosen_name,
|
|
592
|
+
label=chosen_label,
|
|
593
|
+
data=data,
|
|
594
|
+
bundle_version=str(resolved_version) if resolved_version else None,
|
|
595
|
+
description=description,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _prompt_credential_data(fields: list[dict[str, Any]]) -> dict[str, Any]:
|
|
600
|
+
values: dict[str, Any] = {}
|
|
601
|
+
if not fields:
|
|
602
|
+
return values
|
|
603
|
+
human_err("请填写凭证参数(密钥类输入已隐藏):")
|
|
604
|
+
for field in fields:
|
|
605
|
+
hint = f"{field['label']}({field['name']})"
|
|
606
|
+
if field.get("required"):
|
|
607
|
+
hint += " *"
|
|
608
|
+
default = field.get("default")
|
|
609
|
+
default_text = "" if default is None else str(default)
|
|
610
|
+
while True:
|
|
611
|
+
raw = typer.prompt(
|
|
612
|
+
hint,
|
|
613
|
+
default=default_text,
|
|
614
|
+
hide_input=bool(field.get("secret")),
|
|
615
|
+
show_default=not bool(field.get("secret")) and default_text != "",
|
|
616
|
+
err=True,
|
|
617
|
+
)
|
|
618
|
+
text = "" if raw is None else str(raw).strip()
|
|
619
|
+
if not text:
|
|
620
|
+
if field.get("required"):
|
|
621
|
+
human_err("该项为必填")
|
|
622
|
+
continue
|
|
623
|
+
break
|
|
624
|
+
values[field["name"]] = _coerce_credential_value(text, field)
|
|
625
|
+
break
|
|
626
|
+
return values
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _coerce_credential_value(text: str, field: dict[str, Any]) -> Any:
|
|
630
|
+
ui = str(field.get("uiComponent") or "")
|
|
631
|
+
if ui == "InputNumber":
|
|
632
|
+
try:
|
|
633
|
+
return int(text) if text.lstrip("-").isdigit() else float(text)
|
|
634
|
+
except ValueError:
|
|
635
|
+
return text
|
|
636
|
+
if ui == "Switch":
|
|
637
|
+
lowered = text.lower()
|
|
638
|
+
if lowered in ("1", "true", "yes", "y"):
|
|
639
|
+
return True
|
|
640
|
+
if lowered in ("0", "false", "no", "n"):
|
|
641
|
+
return False
|
|
642
|
+
return text
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
@project_app.command("workers")
|
|
646
|
+
def cmd_project_workers(
|
|
647
|
+
ctx: typer.Context,
|
|
648
|
+
project: str = _OPT_PROJECT,
|
|
649
|
+
online_only: bool = typer.Option(False, "--online-only", help="Only workers currently online."),
|
|
650
|
+
) -> None:
|
|
651
|
+
"""Workers available to the project; a workerId is required to create or test run a flow."""
|
|
652
|
+
with _client(ctx) as c:
|
|
653
|
+
_call(ctx, c.project_workers, project, online_only)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# --- flow ---
|
|
657
|
+
flow_app = typer.Typer(help="Workflow authoring: create, draft, validate, test run, deploy.")
|
|
658
|
+
app.add_typer(flow_app, name="workflow")
|
|
659
|
+
|
|
660
|
+
_OPT_FLOW = typer.Option(..., "--flow", "-w", help="Workflow public id (wf_...).")
|
|
661
|
+
_OPT_FLOW_JSON = typer.Option(None, "--flow-json", help="Inline canvas JSON, or '-' to read stdin.")
|
|
662
|
+
_OPT_FLOW_FILE = typer.Option(None, "--file", "-f", help="Path to canvas JSON file, or '-' for stdin.")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@flow_app.command("list")
|
|
666
|
+
def cmd_flow_list(
|
|
667
|
+
ctx: typer.Context,
|
|
668
|
+
project: str = _OPT_PROJECT,
|
|
669
|
+
keyword: Optional[str] = typer.Option(None, "--keyword", "-k", help="Filter by workflow name."),
|
|
670
|
+
) -> None:
|
|
671
|
+
"""Workflows in a project."""
|
|
672
|
+
with _client(ctx) as c:
|
|
673
|
+
_call(ctx, c.flow_list, project, keyword)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@flow_app.command("create")
|
|
677
|
+
def cmd_flow_create(
|
|
678
|
+
ctx: typer.Context,
|
|
679
|
+
project: str = _OPT_PROJECT,
|
|
680
|
+
name: str = typer.Option(..., "--name", "-n", help="Workflow name."),
|
|
681
|
+
worker: list[str] = typer.Option(
|
|
682
|
+
..., "--worker", help="Worker public id; repeat for multiple. See `buildify project workers`."
|
|
683
|
+
),
|
|
684
|
+
remark: Optional[str] = typer.Option(None, "--remark", help="Optional description."),
|
|
685
|
+
) -> None:
|
|
686
|
+
"""Create a workflow with an empty draft."""
|
|
687
|
+
with _client(ctx) as c:
|
|
688
|
+
_call(ctx, c.flow_create, project, workflow_name=name, worker_ids=list(worker), remark=remark)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@flow_app.command("get-draft")
|
|
692
|
+
def cmd_flow_get_draft(ctx: typer.Context, project: str = _OPT_PROJECT, flow: str = _OPT_FLOW) -> None:
|
|
693
|
+
"""Draft canvas JSON plus the optimistic-lock `version` needed to save it back."""
|
|
694
|
+
with _client(ctx) as c:
|
|
695
|
+
_call(ctx, c.flow_get_draft, project, flow)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
@flow_app.command("save-draft")
|
|
699
|
+
def cmd_flow_save_draft(
|
|
700
|
+
ctx: typer.Context,
|
|
701
|
+
project: str = _OPT_PROJECT,
|
|
702
|
+
flow: str = _OPT_FLOW,
|
|
703
|
+
flow_json: Optional[str] = _OPT_FLOW_JSON,
|
|
704
|
+
file: Optional[Path] = _OPT_FLOW_FILE,
|
|
705
|
+
version: Optional[int] = typer.Option(
|
|
706
|
+
None, "--version", help="Optimistic-lock version; omit to read the current draft first."
|
|
707
|
+
),
|
|
708
|
+
) -> None:
|
|
709
|
+
"""Save the canvas JSON as the draft.
|
|
710
|
+
|
|
711
|
+
Without --version the current draft is fetched to obtain it, so a concurrent edit
|
|
712
|
+
between read and write surfaces as a conflict instead of being silently overwritten.
|
|
713
|
+
"""
|
|
714
|
+
payload = _read_json_arg(ctx, flow_json, file, what="flow json")
|
|
715
|
+
with _client(ctx) as c:
|
|
716
|
+
resolved_version = version
|
|
717
|
+
if resolved_version is None:
|
|
718
|
+
try:
|
|
719
|
+
status, body = c.flow_get_draft(project, flow)
|
|
720
|
+
except httpx.RequestError as e:
|
|
721
|
+
raise typer.Exit(_emit_network_error(ctx, e))
|
|
722
|
+
if not operation_ok(status, body):
|
|
723
|
+
raise typer.Exit(_emit(ctx, http_status=status, body=body))
|
|
724
|
+
_code, _msg, data = interpret_r_body(body)
|
|
725
|
+
resolved_version = (data or {}).get("version") if isinstance(data, dict) else None
|
|
726
|
+
if resolved_version is None:
|
|
727
|
+
raise _fail_local(ctx, "draft response has no `version`; pass --version explicitly", data=data)
|
|
728
|
+
_call(ctx, c.flow_save_draft, project, flow, workflow_json=payload, version=int(resolved_version))
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@flow_app.command("validate")
|
|
732
|
+
def cmd_flow_validate(
|
|
733
|
+
ctx: typer.Context,
|
|
734
|
+
project: str = _OPT_PROJECT,
|
|
735
|
+
flow_json: Optional[str] = _OPT_FLOW_JSON,
|
|
736
|
+
file: Optional[Path] = _OPT_FLOW_FILE,
|
|
737
|
+
) -> None:
|
|
738
|
+
"""Check the canvas JSON against installed node schemas. No side effects.
|
|
739
|
+
|
|
740
|
+
Exits 4 when the flow has errors, so a script can loop until it passes.
|
|
741
|
+
"""
|
|
742
|
+
payload = _read_json_arg(ctx, flow_json, file, what="flow json")
|
|
743
|
+
with _client(ctx) as c:
|
|
744
|
+
try:
|
|
745
|
+
status, body = c.flow_validate(project, payload)
|
|
746
|
+
except httpx.RequestError as e:
|
|
747
|
+
raise typer.Exit(_emit_network_error(ctx, e))
|
|
748
|
+
_code, _msg, data = interpret_r_body(body)
|
|
749
|
+
has_errors = isinstance(data, dict) and not data.get("valid", True)
|
|
750
|
+
code = _emit(ctx, http_status=status, body=body)
|
|
751
|
+
raise typer.Exit(ec.SERVER_CLIENT_ERROR if (code == ec.OK and has_errors) else code)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@flow_app.command("test-run")
|
|
755
|
+
def cmd_flow_test_run(
|
|
756
|
+
ctx: typer.Context,
|
|
757
|
+
project: str = _OPT_PROJECT,
|
|
758
|
+
worker: str = typer.Option(..., "--worker", help="Worker public id that runs the test."),
|
|
759
|
+
flow_json: Optional[str] = _OPT_FLOW_JSON,
|
|
760
|
+
file: Optional[Path] = _OPT_FLOW_FILE,
|
|
761
|
+
flow: Optional[str] = typer.Option(None, "--flow", "-w", help="Workflow public id, for logging context."),
|
|
762
|
+
start_node: Optional[str] = typer.Option(None, "--start-node", help="Node id to start from (single-node test)."),
|
|
763
|
+
inputs: Optional[str] = typer.Option(None, "--inputs", help="Inline JSON passed to the start node."),
|
|
764
|
+
timeout: Optional[int] = typer.Option(None, "--timeout", help="Seconds to wait for completion (server default 60)."),
|
|
765
|
+
) -> None:
|
|
766
|
+
"""Run the flow on a worker and wait for the aggregated result.
|
|
767
|
+
|
|
768
|
+
The call blocks until the worker reports completion or the timeout elapses;
|
|
769
|
+
node outputs are collected server-side from the execution event stream.
|
|
770
|
+
"""
|
|
771
|
+
payload = _read_json_arg(ctx, flow_json, file, what="flow json")
|
|
772
|
+
parsed_inputs = json.loads(inputs) if inputs else None
|
|
773
|
+
with _client(ctx) as c:
|
|
774
|
+
_call(
|
|
775
|
+
ctx,
|
|
776
|
+
c.flow_test_run,
|
|
777
|
+
project,
|
|
778
|
+
workflow_json=payload,
|
|
779
|
+
worker_id=worker,
|
|
780
|
+
workflow_id=flow,
|
|
781
|
+
start_node_id=start_node,
|
|
782
|
+
inputs=parsed_inputs,
|
|
783
|
+
timeout_seconds=timeout,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
@flow_app.command("cancel-test")
|
|
788
|
+
def cmd_flow_cancel_test(
|
|
789
|
+
ctx: typer.Context,
|
|
790
|
+
project: str = _OPT_PROJECT,
|
|
791
|
+
execution_id: str = typer.Option(..., "--execution-id", help="executionId from a test run."),
|
|
792
|
+
worker: str = typer.Option(..., "--worker", help="Worker the test was submitted to."),
|
|
793
|
+
) -> None:
|
|
794
|
+
"""Cancel a running test execution."""
|
|
795
|
+
with _client(ctx) as c:
|
|
796
|
+
_call(ctx, c.flow_cancel_test, project, execution_id, worker)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
_TERMINAL_DEPLOY = {"success", "success_with_warnings", "failed", "timeout", "canceled"}
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
@flow_app.command("deploy")
|
|
803
|
+
def cmd_flow_deploy(
|
|
804
|
+
ctx: typer.Context,
|
|
805
|
+
project: str = _OPT_PROJECT,
|
|
806
|
+
flow: str = _OPT_FLOW,
|
|
807
|
+
worker: list[str] = typer.Option(
|
|
808
|
+
..., "--worker", help="Worker public id already bound to the flow; repeat for multiple."
|
|
809
|
+
),
|
|
810
|
+
flow_json: Optional[str] = _OPT_FLOW_JSON,
|
|
811
|
+
file: Optional[Path] = _OPT_FLOW_FILE,
|
|
812
|
+
version: Optional[int] = typer.Option(
|
|
813
|
+
None, "--version", help="Optimistic-lock version; omit to read the current draft first."
|
|
814
|
+
),
|
|
815
|
+
wait: bool = typer.Option(True, "--wait/--no-wait", help="Poll deployment until workers finish (default wait)."),
|
|
816
|
+
wait_seconds: int = typer.Option(60, "--wait-seconds", help="Max seconds to poll when --wait."),
|
|
817
|
+
remark: Optional[str] = typer.Option(None, "--remark", help="发布说明(写入该版本,最多 500 字;不改流程本身的描述)。"),
|
|
818
|
+
version_name: Optional[str] = typer.Option(
|
|
819
|
+
None, "--version-name", help="发布版本号,如 v1.0.0;省略则服务端生成 vyyyyMMdd-HHmmss。"
|
|
820
|
+
),
|
|
821
|
+
) -> None:
|
|
822
|
+
"""Publish the saved draft to the given workers and return a human summary.
|
|
823
|
+
|
|
824
|
+
After save-draft, omit --file: the server publishes the current draft.
|
|
825
|
+
Set --version-name / --remark at publish time (version label and release note).
|
|
826
|
+
Pass --file only when you want to publish JSON that is not yet saved.
|
|
827
|
+
Uses the same workers the flow was created/test-run with. Does not pick servers.
|
|
828
|
+
"""
|
|
829
|
+
payload: Optional[Any] = None
|
|
830
|
+
if flow_json is not None or file is not None:
|
|
831
|
+
payload = _read_json_arg(ctx, flow_json, file, what="flow json")
|
|
832
|
+
with _client(ctx) as c:
|
|
833
|
+
resolved_version = version
|
|
834
|
+
if payload is not None and resolved_version is None:
|
|
835
|
+
try:
|
|
836
|
+
status, body = c.flow_get_draft(project, flow)
|
|
837
|
+
except httpx.RequestError as e:
|
|
838
|
+
raise typer.Exit(_emit_network_error(ctx, e))
|
|
839
|
+
if not operation_ok(status, body):
|
|
840
|
+
raise typer.Exit(_emit(ctx, http_status=status, body=body))
|
|
841
|
+
_code, _msg, data = interpret_r_body(body)
|
|
842
|
+
draft = data if isinstance(data, dict) else {}
|
|
843
|
+
resolved_version = draft.get("version")
|
|
844
|
+
if resolved_version is None:
|
|
845
|
+
raise _fail_local(ctx, "draft response has no `version`; pass --version explicitly", data=data)
|
|
846
|
+
try:
|
|
847
|
+
status, body = c.flow_deploy(
|
|
848
|
+
project,
|
|
849
|
+
flow,
|
|
850
|
+
worker_ids=list(worker),
|
|
851
|
+
workflow_json=payload,
|
|
852
|
+
version=int(resolved_version) if resolved_version is not None else None,
|
|
853
|
+
remark=remark,
|
|
854
|
+
version_name=version_name,
|
|
855
|
+
)
|
|
856
|
+
except httpx.RequestError as e:
|
|
857
|
+
raise typer.Exit(_emit_network_error(ctx, e))
|
|
858
|
+
if not operation_ok(status, body) or not wait:
|
|
859
|
+
raise typer.Exit(_emit(ctx, http_status=status, body=body))
|
|
860
|
+
_code, _msg, data = interpret_r_body(body)
|
|
861
|
+
deployment_id = (data or {}).get("deploymentId") if isinstance(data, dict) else None
|
|
862
|
+
deploy_status = (data or {}).get("deployStatus") if isinstance(data, dict) else None
|
|
863
|
+
if not deployment_id:
|
|
864
|
+
raise typer.Exit(_emit(ctx, http_status=status, body=body))
|
|
865
|
+
deadline = time.monotonic() + max(1, wait_seconds)
|
|
866
|
+
last_status, last_body = status, body
|
|
867
|
+
while time.monotonic() < deadline:
|
|
868
|
+
try:
|
|
869
|
+
last_status, last_body = c.flow_deployment(project, flow, str(deployment_id), status_only=True)
|
|
870
|
+
except httpx.RequestError as e:
|
|
871
|
+
raise typer.Exit(_emit_network_error(ctx, e))
|
|
872
|
+
if not operation_ok(last_status, last_body):
|
|
873
|
+
raise typer.Exit(_emit(ctx, http_status=last_status, body=last_body))
|
|
874
|
+
_, _, snap = interpret_r_body(last_body)
|
|
875
|
+
deploy_status = (snap or {}).get("deployStatus") if isinstance(snap, dict) else None
|
|
876
|
+
if deploy_status in _TERMINAL_DEPLOY:
|
|
877
|
+
break
|
|
878
|
+
time.sleep(1)
|
|
879
|
+
try:
|
|
880
|
+
full_status, full_body = c.flow_deployment(project, flow, str(deployment_id))
|
|
881
|
+
except httpx.RequestError as e:
|
|
882
|
+
raise typer.Exit(_emit_network_error(ctx, e))
|
|
883
|
+
if operation_ok(full_status, full_body):
|
|
884
|
+
last_status, last_body = full_status, full_body
|
|
885
|
+
_, _, snap = interpret_r_body(last_body)
|
|
886
|
+
deploy_status = (snap or {}).get("deployStatus") if isinstance(snap, dict) else deploy_status
|
|
887
|
+
else:
|
|
888
|
+
last_status, last_body = full_status, full_body
|
|
889
|
+
code = _emit(ctx, http_status=last_status, body=last_body)
|
|
890
|
+
if deploy_status in {"failed", "timeout", "canceled"}:
|
|
891
|
+
raise typer.Exit(ec.SERVER_CLIENT_ERROR if code == ec.OK else code)
|
|
892
|
+
if deploy_status not in _TERMINAL_DEPLOY:
|
|
893
|
+
raise typer.Exit(ec.SERVER_CLIENT_ERROR if code == ec.OK else code)
|
|
894
|
+
raise typer.Exit(code)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
@flow_app.command("published")
|
|
898
|
+
def cmd_flow_published(ctx: typer.Context, project: str = _OPT_PROJECT, flow: str = _OPT_FLOW) -> None:
|
|
899
|
+
"""Current live version: project/workflow names, servers, nodes, credentials (no secrets)."""
|
|
900
|
+
with _client(ctx) as c:
|
|
901
|
+
_call(ctx, c.flow_published, project, flow)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
@flow_app.command("deployment")
|
|
905
|
+
def cmd_flow_deployment(
|
|
906
|
+
ctx: typer.Context,
|
|
907
|
+
project: str = _OPT_PROJECT,
|
|
908
|
+
flow: str = _OPT_FLOW,
|
|
909
|
+
deployment: str = typer.Option(..., "--deployment", "-d", help="Deployment public id (deploy_...)."),
|
|
910
|
+
) -> None:
|
|
911
|
+
"""One deployment's overall status and per-server result."""
|
|
912
|
+
with _client(ctx) as c:
|
|
913
|
+
_call(ctx, c.flow_deployment, project, flow, deployment)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
# --- config ---
|
|
917
|
+
config_app = typer.Typer(help="Write/read the openapi section of ~/.buildifyrc (overridden by env at runtime).")
|
|
918
|
+
app.add_typer(config_app, name="config")
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _read_secret_from_stdin() -> str:
|
|
922
|
+
return sys.stdin.read().strip()
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
@config_app.command("init")
|
|
926
|
+
def cmd_config_init(
|
|
927
|
+
ctx: typer.Context,
|
|
928
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Initial base_url (optional)."),
|
|
929
|
+
api_key: Optional[str] = typer.Option(
|
|
930
|
+
None, "--api-key", help="Initial api_key (optional; avoid in argv, prefer hidden prompt or stdin)."
|
|
931
|
+
),
|
|
932
|
+
) -> None:
|
|
933
|
+
"""Create config with optional base_url / api_key; reads api_key from stdin when not a TTY."""
|
|
934
|
+
k = api_key
|
|
935
|
+
if k:
|
|
936
|
+
human_err("warning: --api-key is visible in process lists and shell history; prefer a hidden prompt or stdin")
|
|
937
|
+
if k is None:
|
|
938
|
+
if is_tty(sys.stdin) and not env_flag(ENV_NO_PROMPT):
|
|
939
|
+
if typer.confirm("Set API key now?", default=True, err=True):
|
|
940
|
+
try:
|
|
941
|
+
k = _prompt_api_key_secret()
|
|
942
|
+
except (ValueError, typer.Abort) as e:
|
|
943
|
+
raise _fail_local(ctx, str(e) if isinstance(e, ValueError) else "api key not provided", ec.AUTH_CONFIG)
|
|
944
|
+
else:
|
|
945
|
+
k = _read_secret_from_stdin() or None
|
|
946
|
+
try:
|
|
947
|
+
path = write_config_file(base_url=base_url, api_key=k)
|
|
948
|
+
except ValueError as e:
|
|
949
|
+
raise _fail_local(ctx, str(e))
|
|
950
|
+
masked = mask_api_key(k) if k else None
|
|
951
|
+
if _cfg(ctx).json_output:
|
|
952
|
+
print_json(
|
|
953
|
+
ok=True,
|
|
954
|
+
code=200,
|
|
955
|
+
msg="ok",
|
|
956
|
+
http_status=200,
|
|
957
|
+
data={"path": str(path), "api_key_masked": masked, "saved": bool(k)},
|
|
958
|
+
)
|
|
959
|
+
else:
|
|
960
|
+
typer.echo(f"Wrote {path} (mode 600)")
|
|
961
|
+
if masked:
|
|
962
|
+
typer.echo(f"api_key: {masked}")
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
@config_app.command("set-key")
|
|
966
|
+
def cmd_config_set_key(
|
|
967
|
+
ctx: typer.Context,
|
|
968
|
+
key: str = typer.Argument(..., help=f"One of: {', '.join(sorted(RC_KEYS))}."),
|
|
969
|
+
value: Optional[str] = typer.Argument(
|
|
970
|
+
None, help="For api_key: omit or '-' reads stdin (preferred); for base_url: required URL."
|
|
971
|
+
),
|
|
972
|
+
) -> None:
|
|
973
|
+
"""Set one key in the openapi section (merge). Env vars still override at runtime."""
|
|
974
|
+
k = key.strip()
|
|
975
|
+
if k == "api_key":
|
|
976
|
+
if value is None or value == "-":
|
|
977
|
+
if is_tty(sys.stdin) and not env_flag(ENV_NO_PROMPT):
|
|
978
|
+
try:
|
|
979
|
+
v = _prompt_api_key_secret()
|
|
980
|
+
except (ValueError, typer.Abort) as e:
|
|
981
|
+
raise _fail_local(
|
|
982
|
+
ctx,
|
|
983
|
+
str(e) if isinstance(e, ValueError) else "api key not provided",
|
|
984
|
+
ec.AUTH_CONFIG,
|
|
985
|
+
data=missing_api_key_payload(),
|
|
986
|
+
)
|
|
987
|
+
else:
|
|
988
|
+
v = _read_secret_from_stdin()
|
|
989
|
+
else:
|
|
990
|
+
human_err(
|
|
991
|
+
"warning: passing api_key as a command argument exposes it in process lists and shell history; "
|
|
992
|
+
"prefer stdin or the hidden prompt"
|
|
993
|
+
)
|
|
994
|
+
v = value
|
|
995
|
+
if not v:
|
|
996
|
+
raise _fail_local(ctx, "empty api key", ec.AUTH_CONFIG, data=missing_api_key_payload())
|
|
997
|
+
elif k == "base_url":
|
|
998
|
+
if value is None or value == "-":
|
|
999
|
+
raise _fail_local(ctx, "base_url requires a URL (e.g. https://openapi.buildify.cn)")
|
|
1000
|
+
v = value
|
|
1001
|
+
else:
|
|
1002
|
+
raise _fail_local(ctx, f"unsupported key {k!r}; allowed: {', '.join(sorted(RC_KEYS))}")
|
|
1003
|
+
|
|
1004
|
+
try:
|
|
1005
|
+
path = write_rc_key(k, v)
|
|
1006
|
+
except ValueError as e:
|
|
1007
|
+
raise _fail_local(ctx, str(e))
|
|
1008
|
+
|
|
1009
|
+
payload: dict[str, Any] = {"path": str(path), "key": k}
|
|
1010
|
+
if k == "api_key":
|
|
1011
|
+
payload["api_key_masked"] = mask_api_key(v)
|
|
1012
|
+
payload["mode"] = "600"
|
|
1013
|
+
if _cfg(ctx).json_output:
|
|
1014
|
+
print_json(ok=True, code=200, msg="ok", http_status=200, data=payload)
|
|
1015
|
+
else:
|
|
1016
|
+
typer.echo(f"Updated {k} in {path} (mode 600)")
|
|
1017
|
+
if k == "api_key":
|
|
1018
|
+
typer.echo(f"api_key: {mask_api_key(v)}")
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _read_api_key_value(ctx: typer.Context, value: Optional[str]) -> str:
|
|
1022
|
+
if value is None or value == "-":
|
|
1023
|
+
if is_tty(sys.stdin) and not env_flag(ENV_NO_PROMPT):
|
|
1024
|
+
try:
|
|
1025
|
+
return _prompt_api_key_secret()
|
|
1026
|
+
except (ValueError, typer.Abort) as e:
|
|
1027
|
+
raise _fail_local(
|
|
1028
|
+
ctx,
|
|
1029
|
+
str(e) if isinstance(e, ValueError) else "api key not provided",
|
|
1030
|
+
ec.AUTH_CONFIG,
|
|
1031
|
+
data=missing_api_key_payload(),
|
|
1032
|
+
)
|
|
1033
|
+
v = _read_secret_from_stdin()
|
|
1034
|
+
else:
|
|
1035
|
+
human_err(
|
|
1036
|
+
"warning: passing api_key as a command argument exposes it in process lists and shell history; "
|
|
1037
|
+
"prefer stdin or the hidden prompt"
|
|
1038
|
+
)
|
|
1039
|
+
v = value
|
|
1040
|
+
if not v:
|
|
1041
|
+
raise _fail_local(ctx, "empty api key", ec.AUTH_CONFIG, data=missing_api_key_payload())
|
|
1042
|
+
return v
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
@config_app.command("add-profile")
|
|
1046
|
+
def cmd_config_add_profile(
|
|
1047
|
+
ctx: typer.Context,
|
|
1048
|
+
name: str = typer.Argument(..., help="Profile name (letters, digits, ., _, -). Does not change the default."),
|
|
1049
|
+
value: Optional[str] = typer.Argument(
|
|
1050
|
+
None, help="Omit or '-' reads the key from stdin (preferred)."
|
|
1051
|
+
),
|
|
1052
|
+
) -> None:
|
|
1053
|
+
"""Save a workspace key under a name. Does not change the default profile."""
|
|
1054
|
+
try:
|
|
1055
|
+
profile_name = validate_profile_name(name)
|
|
1056
|
+
except ValueError as e:
|
|
1057
|
+
raise _fail_local(ctx, str(e))
|
|
1058
|
+
secret = _read_api_key_value(ctx, value)
|
|
1059
|
+
try:
|
|
1060
|
+
path = save_profile(profile_name, secret)
|
|
1061
|
+
key = normalize_api_key(secret)
|
|
1062
|
+
except ValueError as e:
|
|
1063
|
+
raise _fail_local(ctx, str(e), ec.AUTH_CONFIG, data=missing_api_key_payload())
|
|
1064
|
+
|
|
1065
|
+
metadata_saved = False
|
|
1066
|
+
try:
|
|
1067
|
+
with OpenApiClient(_cfg(ctx).base_url, key) as client:
|
|
1068
|
+
status, body = client.key_test()
|
|
1069
|
+
if operation_ok(status, body):
|
|
1070
|
+
_, _, data = interpret_r_body(body)
|
|
1071
|
+
if isinstance(data, dict):
|
|
1072
|
+
metadata_saved = apply_key_test_metadata(profile_name, data)
|
|
1073
|
+
except httpx.RequestError:
|
|
1074
|
+
metadata_saved = False
|
|
1075
|
+
|
|
1076
|
+
payload = {
|
|
1077
|
+
"path": str(path),
|
|
1078
|
+
"profile": profile_name,
|
|
1079
|
+
"api_key_masked": mask_api_key(key),
|
|
1080
|
+
"mode": "600",
|
|
1081
|
+
"metadataSaved": metadata_saved,
|
|
1082
|
+
}
|
|
1083
|
+
if _cfg(ctx).json_output:
|
|
1084
|
+
print_json(ok=True, code=200, msg="ok", http_status=200, data=payload)
|
|
1085
|
+
else:
|
|
1086
|
+
typer.echo(f"Saved profile {profile_name} to {path} (mode 600, masked {mask_api_key(key)})")
|
|
1087
|
+
if not metadata_saved:
|
|
1088
|
+
human_err("key test did not cache workspace metadata; run `buildify --profile "
|
|
1089
|
+
f"{profile_name} key test` when the API is reachable")
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
@config_app.command("use")
|
|
1093
|
+
def cmd_config_use(
|
|
1094
|
+
ctx: typer.Context,
|
|
1095
|
+
name: str = typer.Argument(..., help="Profile name to make the default."),
|
|
1096
|
+
) -> None:
|
|
1097
|
+
"""Set the default profile. Agents should pass --profile instead of calling this."""
|
|
1098
|
+
try:
|
|
1099
|
+
path = set_current_profile(name)
|
|
1100
|
+
except ValueError as e:
|
|
1101
|
+
raise _fail_local(ctx, str(e), ec.AUTH_CONFIG)
|
|
1102
|
+
if _cfg(ctx).json_output:
|
|
1103
|
+
print_json(
|
|
1104
|
+
ok=True,
|
|
1105
|
+
code=200,
|
|
1106
|
+
msg="ok",
|
|
1107
|
+
http_status=200,
|
|
1108
|
+
data={"path": str(path), "current": name.strip()},
|
|
1109
|
+
)
|
|
1110
|
+
else:
|
|
1111
|
+
typer.echo(f"current profile: {name.strip()} ({path})")
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
@config_app.command("profiles")
|
|
1115
|
+
def cmd_config_profiles(ctx: typer.Context) -> None:
|
|
1116
|
+
"""List saved profiles. JSON output has no api_key."""
|
|
1117
|
+
section = load_file_config()
|
|
1118
|
+
current = section.get("current")
|
|
1119
|
+
current_name = str(current).strip() if current else None
|
|
1120
|
+
rows = [profile_public(p, current_name) for p in list_stored_profiles(section)]
|
|
1121
|
+
if _cfg(ctx).json_output:
|
|
1122
|
+
print_json(
|
|
1123
|
+
ok=True,
|
|
1124
|
+
code=200,
|
|
1125
|
+
msg="ok",
|
|
1126
|
+
http_status=200,
|
|
1127
|
+
data={"current": current_name, "profiles": rows},
|
|
1128
|
+
)
|
|
1129
|
+
else:
|
|
1130
|
+
if not rows:
|
|
1131
|
+
typer.echo("(no profiles)")
|
|
1132
|
+
return
|
|
1133
|
+
for row in rows:
|
|
1134
|
+
mark = "*" if row["current"] else " "
|
|
1135
|
+
tenant = row["tenantId"] or "-"
|
|
1136
|
+
key_name = row["keyName"] or "-"
|
|
1137
|
+
prefix = row["keyPrefix"] or "-"
|
|
1138
|
+
typer.echo(f"{mark} {row['name']}\ttenant={tenant}\tkey={key_name}\tprefix={prefix}")
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
@config_app.command("remove-profile")
|
|
1142
|
+
def cmd_config_remove_profile(
|
|
1143
|
+
ctx: typer.Context,
|
|
1144
|
+
name: str = typer.Argument(..., help="Profile name to delete."),
|
|
1145
|
+
) -> None:
|
|
1146
|
+
"""Delete one saved profile. If it was current, the default is cleared."""
|
|
1147
|
+
try:
|
|
1148
|
+
path = remove_profile(name)
|
|
1149
|
+
except ValueError as e:
|
|
1150
|
+
raise _fail_local(ctx, str(e), ec.AUTH_CONFIG)
|
|
1151
|
+
if _cfg(ctx).json_output:
|
|
1152
|
+
print_json(
|
|
1153
|
+
ok=True,
|
|
1154
|
+
code=200,
|
|
1155
|
+
msg="ok",
|
|
1156
|
+
http_status=200,
|
|
1157
|
+
data={"path": str(path), "removed": name.strip()},
|
|
1158
|
+
)
|
|
1159
|
+
else:
|
|
1160
|
+
typer.echo(f"removed profile {name.strip()} from {path}")
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
@config_app.command("show")
|
|
1164
|
+
def cmd_config_show(ctx: typer.Context) -> None:
|
|
1165
|
+
"""Show base_url and masked api_key stored in ~/.buildifyrc (not merged with env)."""
|
|
1166
|
+
path = config_file_path()
|
|
1167
|
+
data = load_file_config()
|
|
1168
|
+
base = data.get("base_url") or "(not set)"
|
|
1169
|
+
current = data.get("current")
|
|
1170
|
+
current_name = str(current).strip() if current else None
|
|
1171
|
+
profiles = list_stored_profiles(data)
|
|
1172
|
+
chosen = next((p for p in profiles if p.name == current_name), None)
|
|
1173
|
+
if chosen is None and len(profiles) == 1:
|
|
1174
|
+
chosen = profiles[0]
|
|
1175
|
+
masked = mask_api_key(chosen.api_key) if chosen else "(not set)"
|
|
1176
|
+
mode = oct(path.stat().st_mode & 0o777) if path.is_file() else None
|
|
1177
|
+
public_rows = [profile_public(p, current_name) for p in profiles]
|
|
1178
|
+
if _cfg(ctx).json_output:
|
|
1179
|
+
print_json(
|
|
1180
|
+
ok=True,
|
|
1181
|
+
code=200,
|
|
1182
|
+
msg="ok",
|
|
1183
|
+
http_status=200,
|
|
1184
|
+
data={
|
|
1185
|
+
"path": str(path),
|
|
1186
|
+
"section": "openapi",
|
|
1187
|
+
"base_url": base,
|
|
1188
|
+
"current": current_name,
|
|
1189
|
+
"profile": chosen.name if chosen else None,
|
|
1190
|
+
"api_key_masked": masked,
|
|
1191
|
+
"profiles": public_rows,
|
|
1192
|
+
"mode": mode,
|
|
1193
|
+
},
|
|
1194
|
+
)
|
|
1195
|
+
else:
|
|
1196
|
+
typer.echo(f"path: {path} [openapi]")
|
|
1197
|
+
typer.echo(f"base_url: {base}")
|
|
1198
|
+
typer.echo(f"current: {current_name or '(not set)'}")
|
|
1199
|
+
typer.echo(f"api_key: {masked}")
|
|
1200
|
+
if mode:
|
|
1201
|
+
typer.echo(f"mode: {mode}")
|
|
1202
|
+
typer.echo("(runtime: BUILDIFY_OPENAPI_* env overrides file)")
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
if __name__ == "__main__":
|
|
1206
|
+
app()
|