nace-cli 0.3.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nace-cli
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Nace CLI: calibrated decisions and document jobs from the shell
5
5
  Keywords: drex,nace,cli,document-intelligence
6
6
  Author: Nace AI
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: nace-sdk>=0.3.0,<1
19
+ Requires-Dist: nace-sdk>=0.6.2,<1
20
20
  Requires-Dist: truststore>=0.10.4,<1
21
21
  Requires-Python: >=3.11
22
22
  Project-URL: Homepage, https://console.nace.ai
@@ -151,3 +151,8 @@ Full guide: https://console.nace.ai/docs/developer-tools/cli (install, sign-in,
151
151
  ## License
152
152
 
153
153
  MIT
154
+
155
+
156
+ ## Custom and on-prem servers
157
+
158
+ Set `NACE_API_KEY` and `NACE_BASE_URL=https://nace.customer.example` in the process using the CLI or MCP server; no login or profile is required. Environment values take precedence over saved configuration. Alternatively, `nace login --base-url https://nace.customer.example --api-key - < key.txt` validates and saves the key and actual server URL for both tools. For unattended profile use, mount a mode-0600 file and set `NACE_CONFIG_PATH` to it.
@@ -126,3 +126,8 @@ Full guide: https://console.nace.ai/docs/developer-tools/cli (install, sign-in,
126
126
  ## License
127
127
 
128
128
  MIT
129
+
130
+
131
+ ## Custom and on-prem servers
132
+
133
+ Set `NACE_API_KEY` and `NACE_BASE_URL=https://nace.customer.example` in the process using the CLI or MCP server; no login or profile is required. Environment values take precedence over saved configuration. Alternatively, `nace login --base-url https://nace.customer.example --api-key - < key.txt` validates and saves the key and actual server URL for both tools. For unattended profile use, mount a mode-0600 file and set `NACE_CONFIG_PATH` to it.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nace-cli"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  description = "Nace CLI: calibrated decisions and document jobs from the shell"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "Typing :: Typed",
25
25
  ]
26
26
  dependencies = [
27
- "nace-sdk>=0.3.0,<1",
27
+ "nace-sdk>=0.6.2,<1",
28
28
  "truststore>=0.10.4,<1",
29
29
  ]
30
30
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nace-cli"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  description = "Nace CLI: calibrated decisions and document jobs from the shell"
5
5
  authors = [
6
6
  { name = "Nace AI", email = "engineering@nace.ai" }
@@ -27,7 +27,7 @@ classifiers = [
27
27
  "Typing :: Typed",
28
28
  ]
29
29
  dependencies = [
30
- "nace-sdk>=0.3.0,<1",
30
+ "nace-sdk>=0.6.2,<1",
31
31
  "truststore>=0.10.4,<1",
32
32
  ]
33
33
 
@@ -6,4 +6,4 @@ over ``nace-sdk``. Credentials come from
6
6
  ``$NACE_API_KEY`` / ``$NACE_BASE_URL`` or ``~/.nace/config.toml``.
7
7
  """
8
8
 
9
- __version__ = "0.3.0"
9
+ __version__ = "0.4.1"
@@ -16,7 +16,7 @@ from nace_sdk import NaceAPIConnectionError, NaceAPIError, NaceClient, NaceError
16
16
  from nace_sdk.constants import LOG_LEVEL_ENV, read_env
17
17
  from nace_sdk.credentials import CredentialsError, resolve_credentials
18
18
 
19
- from nace_cli import _browser, _decide, _docops, _domains, _jobs, _login, _schemas, _search, _upload, _workspaces
19
+ from nace_cli import _browser, _decide, _docops, _domains, _jobs, _login, _schemas, _search, _storage, _upload, _workspaces
20
20
  from nace_cli._inputs import check_one_stdin_flag
21
21
  from nace_cli._output import CommandFailed
22
22
 
@@ -109,6 +109,11 @@ _WORKSPACE_HINTS = {
109
109
  "session_busy": "The conversation's last turn is still running: wait for it with `nace job <id> --wait`, then ask again.",
110
110
  "rate_limited": "Automatic searches and QA turns share a limit of 60 per account every 10 minutes.",
111
111
  "access_denied": "Your key can use the workspace but not do this: ask an account owner or admin, or whoever created the workspace.",
112
+ "storage_connection_unavailable": (
113
+ "`nace storage list` lists your account's connections. A deployment without customer storage answers this to every call."
114
+ ),
115
+ "storage_connection_not_ready": "Validate the connection first: `nace storage validate --id ID --wait`.",
116
+ "storage_credentials_refused": "Check the role ARN, or the Entra tenant, client id and secret, then run `nace storage credentials` again.",
112
117
  }
113
118
 
114
119
 
@@ -152,7 +157,7 @@ def build_parser() -> argparse.ArgumentParser:
152
157
  parser = argparse.ArgumentParser(
153
158
  prog="nace",
154
159
  description=(
155
- "Nace CLI: calibrated decisions, document jobs, workspaces, search and browser runs. Credentials come from "
160
+ "Nace CLI: calibrated decisions, document jobs, workspaces, storage connections, search and browser runs. Credentials come from "
156
161
  "$NACE_API_KEY / $NACE_BASE_URL or ~/.nace/config.toml (`nace login`). "
157
162
  "Set $NACE_LOG_LEVEL=debug to log each request to stderr."
158
163
  ),
@@ -176,6 +181,7 @@ def build_parser() -> argparse.ArgumentParser:
176
181
  _schemas.add_parsers(sub, common)
177
182
  _workspaces.add_parsers(sub, common)
178
183
  _domains.add_parsers(sub, common)
184
+ _storage.add_parsers(sub, common)
179
185
  _search.add_parsers(sub, common)
180
186
  _browser.add_parsers(sub, common)
181
187
  return parser
@@ -26,6 +26,7 @@ STDIN_FLAGS = {
26
26
  "output_schema": "--output-schema",
27
27
  "clarification_answers": "--clarification-answers",
28
28
  "answer_schema": "--answer-schema",
29
+ "client_secret_file": "--client-secret-file",
29
30
  }
30
31
 
31
32
 
@@ -0,0 +1,342 @@
1
+ """Storage connections: your own S3 bucket or Azure Blob container a new workspace can keep its files in.
2
+
3
+ ``nace storage`` takes an action: ``list`` and ``get`` read connections; ``create`` makes a draft;
4
+ ``setup`` prints what to create in your cloud (saving the setup inputs it is given first);
5
+ ``credentials`` saves the role or service principal; ``validate`` checks Nace can use it, and ``--wait``
6
+ waits for the check; ``revoke`` and ``delete`` end it. Every input is a flag. ``nace workspace-create
7
+ --storage ID`` binds a new workspace to a ready one.
8
+
9
+ The Azure client secret is read from ``--client-secret-file`` (a path, or ``-`` for stdin) or
10
+ ``$AZURE_CLIENT_SECRET``, never from a flag's value, and is never printed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+ from typing import Any
20
+
21
+ from nace_sdk import (
22
+ AwsRoleCredential,
23
+ AzureBlobDestination,
24
+ AzureServicePrincipalCredential,
25
+ AzureSetupOptions,
26
+ DocumentJob,
27
+ NaceClient,
28
+ S3Destination,
29
+ S3SetupOptions,
30
+ StorageConnection,
31
+ StorageConnectionSetup,
32
+ )
33
+ from nace_sdk._core.endpoints import DEFAULT_STORAGE_VALIDATION_WAIT_SECONDS, STORAGE_CONNECTION_MAX_PAGE_SIZE
34
+
35
+ from nace_cli._inputs import read_secret
36
+ from nace_cli._output import CommandFailed, job_json, job_line
37
+ from nace_cli._schemas import _add_page_args
38
+ from nace_cli._workspaces import _emit_listing
39
+
40
+ #: The two kinds of destination, for ``nace storage create --provider``.
41
+ PROVIDERS = ("s3", "azure_blob")
42
+ #: Where the Azure client secret comes from when ``--client-secret-file`` isn't given.
43
+ AZURE_SECRET_ENV = "AZURE_CLIENT_SECRET"
44
+ #: Each provider's ``nace storage create`` flags, by argparse dest and flag.
45
+ DESTINATION_FLAGS = {"s3": {"bucket": "--bucket", "region": "--region"}, "azure_blob": {"account": "--account", "container": "--container"}}
46
+ #: Each provider's ``nace storage setup`` flags, by setup option and flag.
47
+ SETUP_FLAGS = {
48
+ "s3": {"access_method": "--access-method", "aws_account_id": "--aws-account-id", "role_name": "--role-name", "kms_key_arn": "--kms-key-arn"},
49
+ "azure_blob": {
50
+ "subscription_id": "--subscription-id",
51
+ "resource_group": "--resource-group",
52
+ "tenant_id": "--tenant-id",
53
+ "client_id": "--client-id",
54
+ },
55
+ }
56
+ #: The setup's one-line values, then its multi-line ones, in the order ``nace storage setup`` prints them.
57
+ SETUP_LINES = (("role_arn", "role arn"), ("launch_url", "launch url"))
58
+ SETUP_BLOCKS = (
59
+ ("trust_policy", "trust policy"),
60
+ ("permissions_policy", "permissions policy"),
61
+ ("cloudformation_template", "cloudformation template"),
62
+ ("azure_cli", "azure cli"),
63
+ )
64
+
65
+
66
+ def _add_id(parser: argparse.ArgumentParser) -> None:
67
+ parser.add_argument(
68
+ "--id", dest="connection_id", required=True, metavar="CONNECTION_ID", help="The connection's id, as `nace storage list` lists it."
69
+ )
70
+
71
+
72
+ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
73
+ storage = sub.add_parser("storage", help="Connect your own S3 bucket or Azure Blob container for new workspaces.")
74
+ names = "{list,get,create,setup,credentials,validate,revoke,delete}"
75
+ actions = storage.add_subparsers(dest="storage_action", metavar=names, required=True)
76
+
77
+ listing = actions.add_parser("list", parents=[common], help="List your account's storage connections.")
78
+ _add_page_args(listing, "Connections", default=50, maximum=STORAGE_CONNECTION_MAX_PAGE_SIZE)
79
+ listing.set_defaults(run=run_list, family="client")
80
+
81
+ show = actions.add_parser("get", parents=[common], help="Print a storage connection and its state.")
82
+ _add_id(show)
83
+ show.set_defaults(run=run_get, family="client")
84
+
85
+ create = actions.add_parser("create", parents=[common], help="Create a draft connection to a bucket or container you own.")
86
+ create.add_argument("--name", required=True, metavar="NAME", help="The name people see, 1 to 200 characters.")
87
+ create.add_argument("--provider", required=True, choices=PROVIDERS, help="An S3 bucket, or an Azure Blob container.")
88
+ create.add_argument("--bucket", default=None, metavar="BUCKET", help="s3: your bucket.")
89
+ create.add_argument("--region", default=None, metavar="REGION", help="s3: the bucket's AWS region, such as us-east-1.")
90
+ create.add_argument("--account", default=None, metavar="ACCOUNT", help="azure_blob: the storage account name.")
91
+ create.add_argument("--container", default=None, metavar="CONTAINER", help="azure_blob: your container.")
92
+ create.add_argument("--prefix", default=None, metavar="PREFIX", help="A base prefix inside it; each workspace gets its own folder beneath.")
93
+ create.set_defaults(run=run_create, family="client")
94
+
95
+ setup = actions.add_parser("setup", parents=[common], help="Print what to create in your cloud; save the setup inputs given first.")
96
+ _add_id(setup)
97
+ setup.add_argument(
98
+ "--access-method", default=None, choices=("auto", "manual"), help="s3: auto for the CloudFormation stack, manual to write the role yourself."
99
+ )
100
+ setup.add_argument("--aws-account-id", default=None, metavar="ACCOUNT_ID", help="s3: the 12-digit AWS account the role is created in.")
101
+ setup.add_argument("--role-name", default=None, metavar="NAME", help="s3: the name of the role the stack creates.")
102
+ setup.add_argument("--kms-key-arn", default=None, metavar="ARN", help="s3: a KMS key the role may use.")
103
+ setup.add_argument("--subscription-id", default=None, metavar="ID", help="azure_blob: the subscription the storage account is in.")
104
+ setup.add_argument("--resource-group", default=None, metavar="NAME", help="azure_blob: the resource group the storage account is in.")
105
+ setup.add_argument("--tenant-id", default=None, metavar="ID", help="azure_blob: your Microsoft Entra tenant.")
106
+ setup.add_argument("--client-id", default=None, metavar="ID", help="azure_blob: the application (client) id to give access to.")
107
+ setup.set_defaults(run=run_setup, family="client")
108
+
109
+ credentials = actions.add_parser("credentials", parents=[common], help="Save the IAM role, or the Azure service principal, Nace uses.")
110
+ _add_id(credentials)
111
+ credentials.add_argument(
112
+ "--role-arn", default=None, metavar="ARN", help="s3: your IAM role that trusts Nace's roles with the connection's external id."
113
+ )
114
+ credentials.add_argument("--tenant-id", default=None, metavar="ID", help="azure_blob: your Microsoft Entra tenant.")
115
+ credentials.add_argument("--client-id", default=None, metavar="ID", help="azure_blob: the application (client) id.")
116
+ credentials.add_argument(
117
+ "--client-secret-file",
118
+ default=None,
119
+ metavar="FILE",
120
+ help=f"azure_blob: a file holding the application's secret, or - for stdin (hidden at a terminal). Default: ${AZURE_SECRET_ENV}.",
121
+ )
122
+ credentials.set_defaults(run=run_credentials, family="client")
123
+
124
+ validate = actions.add_parser("validate", parents=[common], help="Check that Nace can write, read and delete in the destination.")
125
+ _add_id(validate)
126
+ validate.add_argument("--reactivate", action="store_true", help="Restore a connection you revoked once the validation passes.")
127
+ validate.add_argument("--wait", action="store_true", help="Wait until the validation finishes; exit 1 if it failed.")
128
+ validate.add_argument(
129
+ "--wait-timeout",
130
+ type=float,
131
+ default=DEFAULT_STORAGE_VALIDATION_WAIT_SECONDS,
132
+ metavar="SECONDS",
133
+ help=f"With --wait, how long to wait (default {DEFAULT_STORAGE_VALIDATION_WAIT_SECONDS:.0f}s). It keeps going server-side after.",
134
+ )
135
+ validate.set_defaults(run=run_validate, family="client")
136
+
137
+ revoke = actions.add_parser("revoke", parents=[common], help="Stop Nace using a connection until it is validated again with --reactivate.")
138
+ _add_id(revoke)
139
+ revoke.set_defaults(run=run_revoke, family="client")
140
+
141
+ delete = actions.add_parser("delete", parents=[common], help="Delete a connection no workspace uses. Nothing in the bucket is touched.")
142
+ _add_id(delete)
143
+ delete.set_defaults(run=run_delete, family="client")
144
+
145
+
146
+ # --- Rendering ----------------------------------------------------------------------------------
147
+
148
+
149
+ def _where(item: StorageConnection) -> str:
150
+ destination = item.destination
151
+ s3 = isinstance(destination, S3Destination)
152
+ place = f"s3://{destination.bucket}" if s3 else f"azure://{destination.account}/{destination.container}"
153
+ return f"{place}/{destination.prefix}" if destination.prefix else place
154
+
155
+
156
+ def _connection_line(item: StorageConnection) -> str:
157
+ return f"{item.id} {item.status} {_where(item)} {item.name}"
158
+
159
+
160
+ def _connection_detail(item: StorageConnection) -> str:
161
+ """The connection's line, then what a person setting it up or binding it needs, one fact per line."""
162
+ lines = [_connection_line(item), f"external id: {item.external_id}"]
163
+ lines += [f"trusted role: {arn}" for arn in item.aws_trusted_principal_arns]
164
+ flags = {
165
+ "credentials pending": item.credentials_pending,
166
+ "validation running": item.validation_running,
167
+ "reactivation requires approval": item.reactivation_requires_approval,
168
+ }
169
+ lines += [flag for flag, on in flags.items() if on]
170
+ if item.validated_at:
171
+ lines.append(f"validated at: {item.validated_at}")
172
+ if item.revoked_at:
173
+ lines.append(f"revoked at: {item.revoked_at}" + (f" ({item.revoked_reason})" if item.revoked_reason else ""))
174
+ lines.append(f"ready for a workspace: {'yes' if item.is_ready else 'no'}")
175
+ return "\n".join(lines)
176
+
177
+
178
+ def _readable(text: str) -> str:
179
+ """A policy or template as indented JSON; anything else as it came."""
180
+ try:
181
+ return json.dumps(json.loads(text), indent=2)
182
+ except ValueError:
183
+ return text.rstrip("\n")
184
+
185
+
186
+ def _setup_text(setup: StorageConnectionSetup) -> str:
187
+ options = setup.options.model_dump(mode="json", exclude_none=True)
188
+ shown = " ".join(f"{key}={value}" for key, value in options.items() if key != "type")
189
+ lines = [f"options: {options['type']}" + (f" {shown}" if shown else "")]
190
+ lines += [f"{label}: {value}" for field, label in SETUP_LINES if (value := getattr(setup, field))]
191
+ for field, label in SETUP_BLOCKS:
192
+ if value := getattr(setup, field):
193
+ lines += ["", f"{label}:", _readable(value)]
194
+ return "\n".join(lines)
195
+
196
+
197
+ def _emit_connection(item: StorageConnection, args: argparse.Namespace) -> int:
198
+ print(item.model_dump_json(indent=2, exclude_none=True) if args.json else _connection_line(item))
199
+ return 0
200
+
201
+
202
+ def _connection_id(args: argparse.Namespace) -> str:
203
+ value = args.connection_id.strip()
204
+ if not value:
205
+ raise ValueError("--id must name a storage connection id")
206
+ return value
207
+
208
+
209
+ # --- Commands -----------------------------------------------------------------------------------
210
+
211
+
212
+ def run_list(client: NaceClient, args: argparse.Namespace) -> int:
213
+ connections = client.storage_connections
214
+ return _emit_listing(
215
+ args,
216
+ "storage connections",
217
+ lambda: connections.list(limit=args.limit, cursor=args.cursor),
218
+ lambda: connections.iter(),
219
+ line=_connection_line,
220
+ extra=lambda page, items: {},
221
+ )
222
+
223
+
224
+ def run_get(client: NaceClient, args: argparse.Namespace) -> int:
225
+ item = client.storage_connections.get(_connection_id(args))
226
+ print(item.model_dump_json(indent=2, exclude_none=True) if args.json else _connection_detail(item))
227
+ return 0
228
+
229
+
230
+ def _destination(args: argparse.Namespace) -> S3Destination | AzureBlobDestination:
231
+ """The destination the flags name: each of the provider's own flags, and none of the other's."""
232
+ wanted = DESTINATION_FLAGS[args.provider]
233
+ other = next(flags for provider, flags in DESTINATION_FLAGS.items() if provider != args.provider)
234
+ missing = [flag for dest, flag in wanted.items() if not (getattr(args, dest) or "").strip()]
235
+ if missing:
236
+ raise ValueError(f"--provider {args.provider} needs {' and '.join(missing)}")
237
+ stray = [flag for dest, flag in other.items() if getattr(args, dest) is not None]
238
+ if stray:
239
+ raise ValueError(f"{' and '.join(stray)} {'is' if len(stray) == 1 else 'are'} not for --provider {args.provider}")
240
+ fields: dict[str, Any] = {dest: getattr(args, dest).strip() for dest in wanted}
241
+ if args.prefix is not None:
242
+ fields["prefix"] = args.prefix.strip().strip("/")
243
+ return S3Destination(**fields) if args.provider == "s3" else AzureBlobDestination(**fields)
244
+
245
+
246
+ def run_create(client: NaceClient, args: argparse.Namespace) -> int:
247
+ name = args.name.strip()
248
+ if not name or len(name) > 200:
249
+ raise ValueError("--name must be 1 to 200 characters, not blank")
250
+ item = client.storage_connections.create(name=name, destination=_destination(args))
251
+ print(f"next: nace storage setup --id {item.id}", file=sys.stderr)
252
+ return _emit_connection(item, args)
253
+
254
+
255
+ def run_setup(client: NaceClient, args: argparse.Namespace) -> int:
256
+ """Read the setup; with option flags, save them over the saved options first (the rest keep their values)."""
257
+ connections = client.storage_connections
258
+ connection_id = _connection_id(args)
259
+ given = {option: value for flags in SETUP_FLAGS.values() for option, flag in flags.items() if (value := getattr(args, option)) is not None}
260
+ setup = connections.setup(connection_id)
261
+ if given:
262
+ kind = setup.options.type
263
+ stray = [flag for provider, flags in SETUP_FLAGS.items() if provider != kind for option, flag in flags.items() if option in given]
264
+ if stray:
265
+ # `--tenant-id` and `--client-id` are Azure's; the others name one provider each.
266
+ raise ValueError(f"{' and '.join(stray)} {'is' if len(stray) == 1 else 'are'} not for a {kind} connection")
267
+ saved = {option: value for option in SETUP_FLAGS[kind] if (value := getattr(setup.options, option, None)) is not None}
268
+ merged = {**saved, **given}
269
+ options = S3SetupOptions(**merged) if kind == "s3" else AzureSetupOptions(**merged)
270
+ setup = connections.set_setup(connection_id, options)
271
+ print(setup.model_dump_json(indent=2, exclude_none=True) if args.json else _setup_text(setup))
272
+ return 0
273
+
274
+
275
+ def _azure_secret(args: argparse.Namespace) -> str:
276
+ if args.client_secret_file is not None:
277
+ source = args.client_secret_file
278
+ secret = read_secret(source if source == "-" else f"@{source}", prompt="Azure client secret: ")
279
+ where = "--client-secret-file"
280
+ else:
281
+ secret = os.environ.get(AZURE_SECRET_ENV, "")
282
+ where = f"${AZURE_SECRET_ENV}"
283
+ if not secret.strip():
284
+ raise ValueError(f"the Azure client secret comes from --client-secret-file FILE (- for stdin) or ${AZURE_SECRET_ENV}")
285
+ if not secret.strip():
286
+ raise ValueError(f"{where} is empty")
287
+ return secret
288
+
289
+
290
+ def run_credentials(client: NaceClient, args: argparse.Namespace) -> int:
291
+ azure = {"--tenant-id": args.tenant_id, "--client-id": args.client_id, "--client-secret-file": args.client_secret_file}
292
+ if args.role_arn is not None:
293
+ stray = [flag for flag, value in azure.items() if value is not None]
294
+ if stray:
295
+ raise ValueError(f"--role-arn is for an S3 connection, and {' and '.join(stray)} for an Azure one: pass one kind")
296
+ if not args.role_arn.strip():
297
+ raise ValueError("--role-arn must not be blank")
298
+ credential: AwsRoleCredential | AzureServicePrincipalCredential = AwsRoleCredential(role_arn=args.role_arn.strip())
299
+ elif args.tenant_id is not None or args.client_id is not None:
300
+ missing = [flag for flag in ("--tenant-id", "--client-id") if not (azure[flag] or "").strip()]
301
+ if missing:
302
+ raise ValueError(f"an Azure service principal needs {' and '.join(missing)}")
303
+ credential = AzureServicePrincipalCredential.model_validate(
304
+ {"tenant_id": args.tenant_id.strip(), "client_id": args.client_id.strip(), "client_secret": _azure_secret(args)}
305
+ )
306
+ else:
307
+ raise ValueError("pass --role-arn for an S3 connection, or --tenant-id and --client-id for an Azure one")
308
+ item = client.storage_connections.set_credentials(_connection_id(args), credential)
309
+ print(f"saved; next: nace storage validate --id {item.id} --wait", file=sys.stderr)
310
+ return _emit_connection(item, args)
311
+
312
+
313
+ def _failure(job: DocumentJob) -> str:
314
+ detail = job.error_message or job.error_code or "no error detail"
315
+ return f"validation {job.status}: {detail}"
316
+
317
+
318
+ def run_validate(client: NaceClient, args: argparse.Namespace) -> int:
319
+ """Start a validation; with ``--wait``, wait for it and exit ``1`` unless it succeeded."""
320
+ if not args.wait_timeout >= 0:
321
+ raise ValueError("--wait-timeout must be 0 or more seconds")
322
+ connections = client.storage_connections
323
+ connection_id = _connection_id(args)
324
+ job = connections.validate(connection_id, reactivate=True if args.reactivate else None)
325
+ if args.wait:
326
+ print(job_line(job), file=sys.stderr)
327
+ job = connections.wait_for_validation(connection_id, timeout=args.wait_timeout)
328
+ print(job_json(job) if args.json else job_line(job))
329
+ if args.wait and job.status != "succeeded":
330
+ raise CommandFailed(_failure(job))
331
+ return 0
332
+
333
+
334
+ def run_revoke(client: NaceClient, args: argparse.Namespace) -> int:
335
+ return _emit_connection(client.storage_connections.revoke(_connection_id(args)), args)
336
+
337
+
338
+ def run_delete(client: NaceClient, args: argparse.Namespace) -> int:
339
+ connection_id = _connection_id(args)
340
+ client.storage_connections.delete(connection_id)
341
+ print(f"deleted {connection_id}", file=sys.stderr)
342
+ return 0
@@ -106,6 +106,12 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
106
106
  create.add_argument(
107
107
  "--access", default=None, metavar="JSON", help='Access labels, {"labels": [...], "default_label": ...}: inline JSON, @FILE, a path, or -.'
108
108
  )
109
+ create.add_argument(
110
+ "--storage",
111
+ default=None,
112
+ metavar="CONNECTION_ID",
113
+ help="Keep the files in this ready storage connection of your account's (`nace storage list`). It can't be changed later.",
114
+ )
109
115
  create.set_defaults(run=run_create, family="client")
110
116
 
111
117
  delete = sub.add_parser("workspace-delete", parents=[common], help="Delete a workspace and everything in it. It can't be undone.")
@@ -233,6 +239,7 @@ def _emit_listing(
233
239
  """One page (or with ``--all`` every item), as the ndi CLI listed it: ``<what> (<total>):``, a line per item, ``next: <cursor>``.
234
240
 
235
241
  ``--json`` prints ``{"items", "next_cursor", ...extra}``; ``extra`` gets the page read (``None`` with ``--all``) and the items.
242
+ Without a ``total_count`` from ``extra``, the heading counts the items listed.
236
243
  """
237
244
  listed = None
238
245
  if args.all:
@@ -245,7 +252,7 @@ def _emit_listing(
245
252
  payload = [item.model_dump(mode="json", by_alias=True, exclude_none=True) for item in items]
246
253
  print(to_json({"items": payload, "next_cursor": next_cursor, **fields}))
247
254
  return 0
248
- print(f"{what} ({fields['total_count']}):")
255
+ print(f"{what} ({fields.get('total_count', len(items))}):")
249
256
  for item in items:
250
257
  print(f" {line(item)}")
251
258
  if next_cursor:
@@ -323,7 +330,10 @@ def run_create(client: NaceClient, args: argparse.Namespace) -> int:
323
330
  if not name.strip() or len(name) > 120:
324
331
  raise ValueError("NAME must be 1 to 120 characters, not blank")
325
332
  access = _json_object(args.access, "--access")
326
- item = client.workspaces.create(name=name, domain_slug=args.domain, access=access)
333
+ storage = args.storage.strip() if args.storage is not None else None
334
+ if storage == "":
335
+ raise ValueError("--storage must name a storage connection id")
336
+ item = client.workspaces.create(name=name, domain_slug=args.domain, access=access, storage_connection_id=storage)
327
337
  return _emit_workspace(item, args, _name_line(item))
328
338
 
329
339
 
File without changes
File without changes
File without changes