acumatica-cli 0.8.0__tar.gz → 0.8.2__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 (32) hide show
  1. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/PKG-INFO +24 -29
  2. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/README.md +22 -27
  3. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/pyproject.toml +4 -4
  4. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/bootstrap.py +1 -1
  5. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/cli.py +27 -11
  6. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/client.py +1 -1
  7. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/config.py +16 -13
  8. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/extract.py +2 -2
  9. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/output.py +25 -2
  10. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/run.py +3 -3
  11. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/seed.py +1 -1
  12. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/env +3 -2
  13. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/tenant.py +8 -0
  14. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/__init__.py +0 -0
  15. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  16. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/bootstrap_project.xml +0 -0
  17. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/extract_manifest.yaml +0 -0
  18. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/firstlogin.py +0 -0
  19. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/models.py +0 -0
  20. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
  21. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
  22. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
  23. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +0 -0
  24. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +0 -0
  25. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
  26. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/bootstrap/company.yaml +0 -0
  27. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +0 -0
  28. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
  29. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/gitignore +0 -0
  30. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/setup/10-financial-year.yaml +0 -0
  31. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +0 -0
  32. {acumatica_cli-0.8.0 → acumatica_cli-0.8.2}/src/acumatica_cli/templates/setup/30-open-periods.yaml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: acumatica-cli
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data
5
5
  Author: Konstantin Borovik
6
6
  Author-email: Konstantin Borovik <kb@lab5.ca>
@@ -10,7 +10,7 @@ Requires-Dist: pydantic>=2.7
10
10
  Requires-Dist: pydantic-settings>=2.5
11
11
  Requires-Dist: pyyaml>=6.0
12
12
  Requires-Dist: rich>=13
13
- Requires-Python: >=3.12
13
+ Requires-Python: >=3.14
14
14
  Project-URL: Homepage, https://lab5.ca
15
15
  Project-URL: Repository, https://github.com/kborovik/acumatica-cli
16
16
  Description-Content-Type: text/markdown
@@ -21,22 +21,6 @@ Description-Content-Type: text/markdown
21
21
 
22
22
  **No UI clicks, no Configuration Wizard.**
23
23
 
24
- ## How it works
25
-
26
- 1. **Extract** the target Acumatica deployment with `acu extract` — its configuration becomes YAML, config as code.
27
- 2. **Develop** changes in the YAML, in git: edit, review, version.
28
- 3. **Re-deploy** with `acu apply`, then prove the live tenant matches the code with `acu diff`.
29
-
30
- Four commands do the work, and every one is safe to re-run:
31
-
32
- - **`acu tenant create`** — creates a tenant and bootstraps it in one step, ready for `apply`.
33
- Re-running it against an existing tenant republishes the bootstrap package instead of failing.
34
- - **`acu apply`** — pushes your YAML into the tenant as keyed upserts.
35
- Running it twice changes nothing.
36
- - **`acu diff`** — compares your YAML against the live tenant and exits with code 2 on drift.
37
- - **`acu extract`** — reads a configured tenant and writes the YAML file set back out, the inverse of `apply`.
38
- Existing files are skipped unless `--force`.
39
-
40
24
  > **Tested against** Acumatica ERP **26.101.0225** on Windows Server 2025,
41
25
  > contract REST endpoint **25.200.001**. Other versions will likely work,
42
26
  > but only this combination is verified.
@@ -75,6 +59,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
75
59
 
76
60
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
77
61
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
62
+ ├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
78
63
  ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
79
64
  │ dump live tenant state as seed YAML (inverse of apply)
80
65
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
@@ -86,25 +71,31 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
86
71
  ```
87
72
 
88
73
  `apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`.
74
+ `run` called without FILES defaults to `scenario/`.
89
75
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
90
76
  Run `acu <command> --help` for details on any command.
91
77
 
92
78
  ## The data repo
93
79
 
94
- Your configuration lives in its own git repo, which `acu config init` scaffolds:
80
+ Your configuration lives in its own git repo. `acu config init` scaffolds everything except `scenario/`, which you author by hand:
95
81
 
96
82
  | Path | What it holds |
97
83
  | ------------ | -------------------------------------------------------------------------- |
98
84
  | `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
99
85
  | `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
100
86
  | `setup/` | one-time actions: financial year, master calendar, open periods |
87
+ | `scenario/` | transaction scenarios for `acu run`: purchase, build, sell flows |
101
88
  | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
102
89
 
103
90
  Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order. The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
104
91
 
92
+ Seed YAML in `bootstrap/`, `baseline/`, and `setup/` is state: `apply` upserts it, `diff` proves it.
93
+ Scenario YAML is different — it describes transactions that flow forward.
94
+ `acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so a scenario re-runs safely on a warm tenant.
95
+
105
96
  ## Installation
106
97
 
107
- Requires Python 3.12 or newer.
98
+ Requires Python 3.14 or newer.
108
99
 
109
100
  ```sh
110
101
  uv tool install acumatica-cli
@@ -148,9 +139,9 @@ acu apply --dry-run # show what would be written, write nothing
148
139
  `acu` talks to an instance over two independent channels:
149
140
 
150
141
  - **Control plane (SSH):** `acu tenant` runs `ac.exe -cm:CompanyConfig` and `sqlcmd` on the Windows guest — see [`docs/ac-exe.md`](docs/ac-exe.md).
151
- - **Data plane (REST):** `acu apply`, `diff`, and `schema` use the contract-based API (`/entity/Default/25.200.001/`), where `PUT` is a keyed upsert — see [`docs/rest-api.md`](docs/rest-api.md).
142
+ - **Data plane (REST):** `acu apply`, `diff`, `run`, `extract`, and `schema` use the contract-based API (`/entity/Default/25.200.001/`), where `PUT` is a keyed upsert — see [`docs/rest-api.md`](docs/rest-api.md).
152
143
 
153
- If you only apply and diff YAML, you never need SSH. SSH setup is required only for `acu tenant`.
144
+ If you never touch `acu tenant`, you never need SSH every other command is pure REST.
154
145
 
155
146
  ## SSH setup (control plane)
156
147
 
@@ -197,29 +188,33 @@ ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersi
197
188
 
198
189
  ## Development
199
190
 
191
+ Requires **GNU Make at least 3.82** — the Makefile uses `.ONESHELL`.
192
+ On macOS use Homebrew's `gmake` (`brew install make`); `/usr/bin/make` is 3.81 and fails the guard.
193
+ Elsewhere plain `make` is fine when it is GNU Make.
194
+
200
195
  ```sh
201
196
  git clone https://github.com/kborovik/acumatica-cli.git
202
197
  cd acumatica-cli
203
- make install # editable install as a global uv tool
204
- make check # offline gate: ruff, basedpyright strict, pytest
198
+ gmake install # editable install as a global uv tool
199
+ gmake check # offline gate: ruff, basedpyright strict, pytest
205
200
  ```
206
201
 
207
202
  The default test suite is fully offline.
208
203
  REST is faked with `httpx.MockTransport`, SSH with a monkeypatched `subprocess.run` — no live instance is needed.
209
- `make check` must pass before every commit.
204
+ `gmake check` must pass before every commit.
210
205
 
211
206
  ### Live end-to-end tier
212
207
 
213
- `make e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
208
+ `gmake e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
214
209
 
215
210
  Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_SSH`, `ACU_TENANT`, `ACU_PASSWORD`.
216
- `make e2e` refuses to start without it.
211
+ `gmake e2e` refuses to start without it.
217
212
 
218
213
  The tier is self-contained.
219
214
  Each run scaffolds a synthetic single-org company from the packaged `acu config init` templates into a temporary directory, copies the real `.env` into it, and runs the installed `acu` binary from there — no data repo, no pre-existing fixtures on the instance.
220
215
  Scratch tenants (`E2E`, `E2EA`, `E2EB`) are created on the way in and always deleted on the way out, so nothing persists.
221
216
 
222
217
  ```sh
223
- make e2e # whole tier, about 20 minutes
224
- make e2e FILE=test_provision_lifecycle # one file, by stem or path
218
+ gmake e2e # whole tier, about 20 minutes
219
+ gmake e2e FILE=test_provision_lifecycle # one file, by stem or path
225
220
  ```
@@ -4,22 +4,6 @@
4
4
 
5
5
  **No UI clicks, no Configuration Wizard.**
6
6
 
7
- ## How it works
8
-
9
- 1. **Extract** the target Acumatica deployment with `acu extract` — its configuration becomes YAML, config as code.
10
- 2. **Develop** changes in the YAML, in git: edit, review, version.
11
- 3. **Re-deploy** with `acu apply`, then prove the live tenant matches the code with `acu diff`.
12
-
13
- Four commands do the work, and every one is safe to re-run:
14
-
15
- - **`acu tenant create`** — creates a tenant and bootstraps it in one step, ready for `apply`.
16
- Re-running it against an existing tenant republishes the bootstrap package instead of failing.
17
- - **`acu apply`** — pushes your YAML into the tenant as keyed upserts.
18
- Running it twice changes nothing.
19
- - **`acu diff`** — compares your YAML against the live tenant and exits with code 2 on drift.
20
- - **`acu extract`** — reads a configured tenant and writes the YAML file set back out, the inverse of `apply`.
21
- Existing files are skipped unless `--force`.
22
-
23
7
  > **Tested against** Acumatica ERP **26.101.0225** on Windows Server 2025,
24
8
  > contract REST endpoint **25.200.001**. Other versions will likely work,
25
9
  > but only this combination is verified.
@@ -58,6 +42,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
58
42
 
59
43
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
60
44
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
45
+ ├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
61
46
  ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
62
47
  │ dump live tenant state as seed YAML (inverse of apply)
63
48
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
@@ -69,25 +54,31 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
69
54
  ```
70
55
 
71
56
  `apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`.
57
+ `run` called without FILES defaults to `scenario/`.
72
58
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
73
59
  Run `acu <command> --help` for details on any command.
74
60
 
75
61
  ## The data repo
76
62
 
77
- Your configuration lives in its own git repo, which `acu config init` scaffolds:
63
+ Your configuration lives in its own git repo. `acu config init` scaffolds everything except `scenario/`, which you author by hand:
78
64
 
79
65
  | Path | What it holds |
80
66
  | ------------ | -------------------------------------------------------------------------- |
81
67
  | `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
82
68
  | `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
83
69
  | `setup/` | one-time actions: financial year, master calendar, open periods |
70
+ | `scenario/` | transaction scenarios for `acu run`: purchase, build, sell flows |
84
71
  | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
85
72
 
86
73
  Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order. The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
87
74
 
75
+ Seed YAML in `bootstrap/`, `baseline/`, and `setup/` is state: `apply` upserts it, `diff` proves it.
76
+ Scenario YAML is different — it describes transactions that flow forward.
77
+ `acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so a scenario re-runs safely on a warm tenant.
78
+
88
79
  ## Installation
89
80
 
90
- Requires Python 3.12 or newer.
81
+ Requires Python 3.14 or newer.
91
82
 
92
83
  ```sh
93
84
  uv tool install acumatica-cli
@@ -131,9 +122,9 @@ acu apply --dry-run # show what would be written, write nothing
131
122
  `acu` talks to an instance over two independent channels:
132
123
 
133
124
  - **Control plane (SSH):** `acu tenant` runs `ac.exe -cm:CompanyConfig` and `sqlcmd` on the Windows guest — see [`docs/ac-exe.md`](docs/ac-exe.md).
134
- - **Data plane (REST):** `acu apply`, `diff`, and `schema` use the contract-based API (`/entity/Default/25.200.001/`), where `PUT` is a keyed upsert — see [`docs/rest-api.md`](docs/rest-api.md).
125
+ - **Data plane (REST):** `acu apply`, `diff`, `run`, `extract`, and `schema` use the contract-based API (`/entity/Default/25.200.001/`), where `PUT` is a keyed upsert — see [`docs/rest-api.md`](docs/rest-api.md).
135
126
 
136
- If you only apply and diff YAML, you never need SSH. SSH setup is required only for `acu tenant`.
127
+ If you never touch `acu tenant`, you never need SSH every other command is pure REST.
137
128
 
138
129
  ## SSH setup (control plane)
139
130
 
@@ -180,29 +171,33 @@ ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersi
180
171
 
181
172
  ## Development
182
173
 
174
+ Requires **GNU Make at least 3.82** — the Makefile uses `.ONESHELL`.
175
+ On macOS use Homebrew's `gmake` (`brew install make`); `/usr/bin/make` is 3.81 and fails the guard.
176
+ Elsewhere plain `make` is fine when it is GNU Make.
177
+
183
178
  ```sh
184
179
  git clone https://github.com/kborovik/acumatica-cli.git
185
180
  cd acumatica-cli
186
- make install # editable install as a global uv tool
187
- make check # offline gate: ruff, basedpyright strict, pytest
181
+ gmake install # editable install as a global uv tool
182
+ gmake check # offline gate: ruff, basedpyright strict, pytest
188
183
  ```
189
184
 
190
185
  The default test suite is fully offline.
191
186
  REST is faked with `httpx.MockTransport`, SSH with a monkeypatched `subprocess.run` — no live instance is needed.
192
- `make check` must pass before every commit.
187
+ `gmake check` must pass before every commit.
193
188
 
194
189
  ### Live end-to-end tier
195
190
 
196
- `make e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
191
+ `gmake e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
197
192
 
198
193
  Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_SSH`, `ACU_TENANT`, `ACU_PASSWORD`.
199
- `make e2e` refuses to start without it.
194
+ `gmake e2e` refuses to start without it.
200
195
 
201
196
  The tier is self-contained.
202
197
  Each run scaffolds a synthetic single-org company from the packaged `acu config init` templates into a temporary directory, copies the real `.env` into it, and runs the installed `acu` binary from there — no data repo, no pre-existing fixtures on the instance.
203
198
  Scratch tenants (`E2E`, `E2EA`, `E2EB`) are created on the way in and always deleted on the way out, so nothing persists.
204
199
 
205
200
  ```sh
206
- make e2e # whole tier, about 20 minutes
207
- make e2e FILE=test_provision_lifecycle # one file, by stem or path
201
+ gmake e2e # whole tier, about 20 minutes
202
+ gmake e2e FILE=test_provision_lifecycle # one file, by stem or path
208
203
  ```
@@ -1,10 +1,10 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.8.0"
3
+ version = "0.8.2"
4
4
  description = "Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data"
5
5
  authors = [{ name = "Konstantin Borovik", email = "kb@lab5.ca" }]
6
6
  readme = "README.md"
7
- requires-python = ">=3.12"
7
+ requires-python = ">=3.14"
8
8
  dependencies = [
9
9
  "click>=8.1",
10
10
  "httpx>=0.27",
@@ -41,7 +41,7 @@ markers = [
41
41
 
42
42
  [tool.basedpyright]
43
43
  typeCheckingMode = "strict"
44
- pythonVersion = "3.12"
44
+ pythonVersion = "3.14"
45
45
  include = ["src/acumatica_cli/", "tests/"]
46
46
  reportMissingTypeStubs = false
47
47
  reportUnknownVariableType = false
@@ -52,7 +52,7 @@ reportUnknownLambdaType = false
52
52
  reportUnusedFunction = "warning"
53
53
 
54
54
  [tool.ruff]
55
- target-version = "py312"
55
+ target-version = "py314"
56
56
  src = ["src"]
57
57
 
58
58
  [tool.ruff.format]
@@ -150,7 +150,7 @@ def _published_description(client: AcumaticaClient) -> str | None:
150
150
  try:
151
151
  with zipfile.ZipFile(io.BytesIO(content)) as zf:
152
152
  root = ET.fromstring(zf.read("project.xml"))
153
- except (zipfile.BadZipFile, KeyError, ET.ParseError):
153
+ except zipfile.BadZipFile, KeyError, ET.ParseError:
154
154
  return None
155
155
  return root.get("description")
156
156
 
@@ -379,12 +379,13 @@ def config_check(ctx: click.Context) -> None:
379
379
 
380
380
  Dependency order: discovery (.env walk-up + parse + ACU_BASE_URL), then
381
381
  secrets (ACU_PASSWORD resolved), then REST (login, landed-tenant verify,
382
- logout) and ssh (trivial remote command) probed independently - a
383
- discovery or secrets failure stops, a REST failure still probes ssh and
382
+ logout) and ssh probed independently - ssh set → trivial remote; ssh
383
+ unset skip (ACU_SSH optional, V3 hosted path), never fail. A discovery
384
+ or secrets failure stops; a REST failure still probes ssh when set and
384
385
  vice versa. Discovery is lax (V3): no .env passes when --url covers
385
386
  base_url, and flags-only runs (no .env anywhere) are valid. Writes
386
- nothing: no PUTs, no tenant CRUD. Exit 0 when every probe passes, 1 on
387
- any failure.
387
+ nothing: no PUTs, no tenant CRUD. Exit 0 when every non-skipped probe
388
+ passes, 1 on any failure.
388
389
  """
389
390
  overrides: dict[str, str] = ctx.obj or {}
390
391
  # discovery (V3): lax walk-up + parse; base_url (the primary identity
@@ -432,12 +433,16 @@ def config_check(ctx: click.Context) -> None:
432
433
  except (RuntimeError, httpx.HTTPError) as exc:
433
434
  output.data(f"fail rest: {exc}")
434
435
  failed = True
435
- try:
436
- TenantManager(inst).ping()
437
- output.data(f"ok ssh ({inst.ssh})")
438
- except RuntimeError as exc:
439
- output.data(f"fail ssh: {exc}")
440
- failed = True
436
+ if not inst.ssh:
437
+ # V3/I.cmd: ACU_SSH optional — hosted data-plane path skips, never fails
438
+ output.data("skip ssh (ACU_SSH not set)")
439
+ else:
440
+ try:
441
+ TenantManager(inst).ping()
442
+ output.data(f"ok ssh ({inst.ssh})")
443
+ except RuntimeError as exc:
444
+ output.data(f"fail ssh: {exc}")
445
+ failed = True
441
446
  if failed:
442
447
  raise SystemExit(1)
443
448
 
@@ -553,7 +558,18 @@ def diff_cmd(inst: Instance, files: tuple[Path, ...]) -> None:
553
558
  with AcumaticaClient(inst) as client:
554
559
  for path in paths:
555
560
  baseline = seed.load_baseline(path)
556
- drifts += seed.diff(client, baseline)
561
+ # same file banner as apply so a long multi-file diff shows
562
+ # progress instead of silence until the final status line
563
+ output.data(
564
+ f"{path} -> {inst.tenant} on {inst.base_url} ({baseline.entity})"
565
+ )
566
+ file_drifts = seed.diff(client, baseline)
567
+ drifts += file_drifts
568
+ n = 1 if isinstance(baseline, seed.ActionFile) else len(baseline.records)
569
+ if file_drifts:
570
+ output.data(f" {n} record(s), {len(file_drifts)} drift(s)")
571
+ else:
572
+ output.data(f" {n} record(s) ok")
557
573
  _exit_on_drift(inst, drifts, len(paths))
558
574
 
559
575
 
@@ -94,7 +94,7 @@ class AcumaticaClient:
94
94
  base_url=instance.base_url, timeout=timeout, transport=transport
95
95
  )
96
96
 
97
- def __enter__(self) -> "AcumaticaClient":
97
+ def __enter__(self) -> AcumaticaClient:
98
98
  # tenant guard (V5, docs/rest-api.md): an omitted or empty tenant is
99
99
  # the one login the server still routes silently — to the default
100
100
  # tenant. Defense-in-depth vs wrong-tenant writes: every data-plane
@@ -5,9 +5,10 @@ env prefix ``ACU_``, and the sole config file is ``.env`` (found by walking
5
5
  up from cwd) carrying where + secrets as ``ACU_*`` vars. The file is
6
6
  optional - flags plus the process environment can supply the full config.
7
7
  Per key the first set value wins: flag, ``ACU_*`` var (process environment
8
- over a found ``.env``), code default. ``base_url`` and ``ssh`` are the only
9
- required address values - one explicit address per plane (V1), never
10
- derived; the password must resolve via ``--password`` or ``ACU_PASSWORD``.
8
+ over a found ``.env``), code default. ``base_url`` is the only required
9
+ address (REST data plane); ``ssh`` is optional control-plane address
10
+ (empty = data-plane only; tenant cmds hard-error when unresolved V1/V3).
11
+ The password must resolve via ``--password`` or ``ACU_PASSWORD``.
11
12
  """
12
13
 
13
14
  from collections.abc import Iterator, Mapping
@@ -51,11 +52,12 @@ INIT_TEMPLATES = (
51
52
  class Instance(BaseSettings):
52
53
  """The resolved target: flags over ACU_* vars (.env or process) over defaults.
53
54
 
54
- One explicit address per plane (V1), no derivation: ``base_url`` is the
55
- REST root (scheme + host + site path), ``ssh`` the control-plane
56
- ``user@host``. Install-layout values are module constants, not fields.
57
- Unknown ``ACU_*`` vars are ignored, never errors - the environment and
58
- ``.env`` legitimately carry non-config vars (``ACU_DEBUG``).
55
+ Explicit addresses, no derivation (V1): ``base_url`` is the REST root
56
+ (scheme + host + site path); ``ssh`` is the optional control-plane
57
+ ``user@host`` (empty = data-plane only). Install-layout values are
58
+ module constants, not fields. Unknown ``ACU_*`` vars are ignored,
59
+ never errors - the environment and ``.env`` legitimately carry
60
+ non-config vars (``ACU_DEBUG``).
59
61
  """
60
62
 
61
63
  model_config = SettingsConfigDict(
@@ -65,7 +67,7 @@ class Instance(BaseSettings):
65
67
  )
66
68
 
67
69
  base_url: str # REST root: scheme + host + site path
68
- ssh: str # control plane: full user@host
70
+ ssh: str = "" # control plane: full user@host; empty = data-plane only
69
71
  tenant: str = ""
70
72
  api_version: str = "25.200.001" # V11: /entity/Default/<api_version>/
71
73
  user: str = "admin" # ACU_USER; the --username flag maps here
@@ -76,8 +78,8 @@ class Instance(BaseSettings):
76
78
  @model_validator(mode="before")
77
79
  @classmethod
78
80
  def _blank_required_is_unset(cls, data: Any) -> Any:
79
- # a blank ACU_BASE_URL= / ACU_SSH= line reads as missing, not as an
80
- # empty address - the hard error names the unresolved key (V3)
81
+ # blank ACU_BASE_URL= / ACU_SSH= reads as unset (V3): base_url then
82
+ # fails required; ssh falls through to the empty default (optional)
81
83
  if isinstance(data, dict):
82
84
  for key in ("base_url", "ssh"):
83
85
  if data.get(key) == "":
@@ -161,8 +163,9 @@ def load_instance(overrides: Mapping[str, str | None] | None = None) -> Instance
161
163
  ``overrides`` carries the global flags keyed by Instance field name;
162
164
  per key the first set value wins (flag, ACU_* var - process environment
163
165
  over a found .env - code default). No .env is fine (V3): the hard error
164
- comes only when a required value (base_url, ssh, password) is still
165
- unresolved after the merge, naming the missing key.
166
+ comes only when a required value (base_url, password) is still
167
+ unresolved after the merge, naming the missing key. ``ssh`` is optional
168
+ (hosted / data-plane-only path); tenant cmds hard-error when it is empty.
166
169
  """
167
170
  flags = {k: v for k, v in dict(overrides or {}).items() if v is not None}
168
171
  root = find_data_root()
@@ -66,7 +66,7 @@ class EntitySpec(Model):
66
66
  return BOOTSTRAP_ENDPOINT if v == "bootstrap" else v
67
67
 
68
68
  @model_validator(mode="after")
69
- def _strip_include_exclusive(self) -> "EntitySpec":
69
+ def _strip_include_exclusive(self) -> EntitySpec:
70
70
  if self.strip and self.include:
71
71
  raise ValueError("strip and include are mutually exclusive")
72
72
  return self
@@ -96,7 +96,7 @@ class Manifest(Model):
96
96
  setup: list[SetupSynth] = Field(default_factory=list)
97
97
 
98
98
  @model_validator(mode="after")
99
- def _self_consistent(self) -> "Manifest":
99
+ def _self_consistent(self) -> Manifest:
100
100
  # V20 by construction: an emitted file for an entity both endpoints
101
101
  # serve must carry an endpoint, or load_baseline rejects it
102
102
  for spec in self.entities:
@@ -6,6 +6,7 @@ and spinners on a TTY and degrades to plain deterministic text when piped
6
6
  stderr carries status, warnings, and errors.
7
7
  """
8
8
 
9
+ import os
9
10
  from collections.abc import Generator, Iterable
10
11
  from contextlib import contextmanager
11
12
 
@@ -13,8 +14,30 @@ from rich import box
13
14
  from rich.console import Console
14
15
  from rich.table import Table
15
16
 
16
- out = Console(markup=False, emoji=False, highlight=False)
17
- err = Console(stderr=True, markup=False, emoji=False, highlight=False)
17
+
18
+ def _force_terminal() -> bool | None:
19
+ """Map NO_COLOR / FORCE_COLOR to rich's force_terminal flag.
20
+
21
+ None leaves auto-detect (isatty). False forces plain/piped rendering.
22
+ True forces TTY chrome even when piped.
23
+
24
+ Rich treats any non-empty FORCE_COLOR as "on", so FORCE_COLOR=0 would
25
+ otherwise enable color. We treat empty and "0" as off (force-color.org).
26
+ NO_COLOR (any value) always wins and forces plain output (no-color.org).
27
+ """
28
+ if "NO_COLOR" in os.environ:
29
+ return False
30
+ force = os.environ.get("FORCE_COLOR")
31
+ if force is not None:
32
+ return force not in ("", "0")
33
+ return None
34
+
35
+
36
+ _force = _force_terminal()
37
+ out = Console(markup=False, emoji=False, highlight=False, force_terminal=_force)
38
+ err = Console(
39
+ stderr=True, markup=False, emoji=False, highlight=False, force_terminal=_force
40
+ )
18
41
 
19
42
 
20
43
  def data(msg: str) -> None:
@@ -99,7 +99,7 @@ class Step(Model):
99
99
  endpoint: str | None = None
100
100
 
101
101
  @model_validator(mode="after")
102
- def _one_op(self) -> "Step":
102
+ def _one_op(self) -> Step:
103
103
  ops = [op for op in (self.put, self.action, self.get) if op is not None]
104
104
  if len(ops) > 1:
105
105
  raise ValueError(f"step '{self.id}': put, action, get are exclusive")
@@ -134,7 +134,7 @@ class Expect(Model):
134
134
  endpoint: str | None = None
135
135
 
136
136
  @model_validator(mode="after")
137
- def _one_kind(self) -> "Expect":
137
+ def _one_kind(self) -> Expect:
138
138
  if (self.get is None) == (self.inquire is None):
139
139
  raise ValueError("expect: exactly one of get, inquire")
140
140
  if self.get is not None and self.fields is None:
@@ -164,7 +164,7 @@ class Scenario(Model):
164
164
  expect: list[Expect] = Field(default_factory=list)
165
165
 
166
166
  @model_validator(mode="after")
167
- def _unique_step_ids(self) -> "Scenario":
167
+ def _unique_step_ids(self) -> Scenario:
168
168
  seen: set[str] = set()
169
169
  for step in self.steps:
170
170
  if step.id in seen:
@@ -109,7 +109,7 @@ class BaselineFile(Model):
109
109
  return v if isinstance(v, list) else [v]
110
110
 
111
111
  @model_validator(mode="after")
112
- def _keys_identify_records(self) -> "BaselineFile":
112
+ def _keys_identify_records(self) -> BaselineFile:
113
113
  # V25: the declared key tuple must uniquely identify records - a
114
114
  # dup-keyed file diffs as permanent false drift and apply collapses
115
115
  # the dup records into one PUT target (B21)
@@ -1,8 +1,9 @@
1
1
  # scaffolded by `acu config init` - the sole config file: where + secrets,
2
2
  # every key an ACU_* var; print the fully resolved document with:
3
3
  # acu config show
4
- # ACU_BASE_URL (REST root) and ACU_SSH (control-plane user@host) are the
5
- # required addresses; the rest are code defaults you can override here.
4
+ # ACU_BASE_URL (REST root) is required; ACU_SSH (control-plane user@host)
5
+ # is optional - leave blank for hosted / data-plane-only (tenant CRUD still
6
+ # needs it). The rest are code defaults you can override here.
6
7
  # Keep this file out of git (the scaffolded .gitignore already lists it;
7
8
  # encrypt at rest, e.g. .env.gpg).
8
9
  ACU_BASE_URL=http://erp.example.com/AcumaticaERP
@@ -28,6 +28,14 @@ class TenantManager:
28
28
  """Tenant CRUD via SSH to the Windows guest."""
29
29
 
30
30
  def __init__(self, instance: Instance):
31
+ # control-plane address is optional on Instance (hosted path, V3);
32
+ # tenant CRUD still needs it - hard-error naming the key before any
33
+ # remote (I.cmd), never an empty ssh argv
34
+ if not instance.ssh:
35
+ raise SystemExit(
36
+ "ACU_SSH not set (pass --ssh, "
37
+ "or put ACU_SSH in .env or the environment)"
38
+ )
31
39
  self.instance = instance
32
40
 
33
41
  def _ssh(self, command: str) -> str: