acumatica-cli 0.15.2__tar.gz → 0.15.4__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 (68) hide show
  1. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/PKG-INFO +32 -108
  2. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/README.md +31 -107
  3. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/pyproject.toml +1 -1
  4. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/cli.py +85 -22
  5. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/config.py +66 -25
  6. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/seed.py +3 -3
  7. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/target.py +12 -18
  8. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/README.md +2 -1
  9. acumatica_cli-0.15.4/src/acumatica_cli/templates/env +16 -0
  10. acumatica_cli-0.15.4/src/acumatica_cli/templates/target +7 -0
  11. acumatica_cli-0.15.2/src/acumatica_cli/templates/env +0 -17
  12. acumatica_cli-0.15.2/src/acumatica_cli/templates/target +0 -6
  13. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/__init__.py +0 -0
  14. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/bootstrap.py +0 -0
  15. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  16. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/bootstrap_project.xml +0 -0
  17. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/client.py +0 -0
  18. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/extract.py +0 -0
  19. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/firstlogin.py +0 -0
  20. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/models.py +0 -0
  21. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/output.py +0 -0
  22. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/run.py +0 -0
  23. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/seed_catalog.yaml +0 -0
  24. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/state.py +0 -0
  25. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
  26. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
  27. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
  28. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
  29. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
  30. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
  31. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
  32. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
  33. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
  34. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
  35. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
  36. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
  37. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
  38. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
  39. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
  40. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
  41. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
  42. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
  43. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
  44. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
  45. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
  46. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
  47. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
  48. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
  49. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
  50. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
  51. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
  52. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
  53. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
  54. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
  55. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
  56. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
  57. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
  58. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
  59. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
  60. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
  61. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
  62. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
  63. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/gitignore +0 -0
  64. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
  65. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
  66. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
  67. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
  68. {acumatica_cli-0.15.2 → acumatica_cli-0.15.4}/src/acumatica_cli/tenant.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acumatica-cli
3
- Version: 0.15.2
3
+ Version: 0.15.4
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>
@@ -39,7 +39,7 @@ uv tool install acumatica-cli
39
39
 
40
40
  acu config init --host erp.example.com my-erp
41
41
  cd my-erp # edit .env: set ACU_PASSWORD, ACU_TENANT
42
- # keep ACU_API_VERSION in sync with target.yaml
42
+ # Default API pin = target.yaml default_api
43
43
  # start from a brand-new empty tenant
44
44
 
45
45
  acu config check # read-only preflight (incl. target.yaml)
@@ -49,18 +49,17 @@ acu --tenant DEV apply config/ # seed config/{bootstrap,baseline,setup
49
49
  acu --tenant DEV run scenario/ # once capital → buy → build → sell
50
50
  acu --tenant DEV diff config/ # prove zero drift (exit 2 on drift)
51
51
  acu --tenant DEV state # capture state/ trial-balance
52
- # warm: capital once-skips; Owner Capital stays 50000 (not 100000)
53
- acu --tenant DEV run scenario/
52
+ acu --tenant DEV run scenario/ # replay transaction scenarios
54
53
  ```
55
54
 
56
55
  Bare `apply` / `diff` (no path args) also prefer `config/` when those trees exist.
57
56
  See [docs/demo-seed.md](docs/demo-seed.md) for the entity map, once-guard, and apply-order notes.
58
57
 
59
- **Hosted Acumatica (no SSH):** the tenant already exists; leave `ACU_SSH` blank.
58
+ **Hosted Acumatica (no SSH):** the tenant already exists; set a blank `ACU_SSH=` in `.env` (scaffold omits the key — without it, acu defaults to `Administrator@<ACU_BASE_URL host>` for SSH boxes).
60
59
 
61
60
  ```sh
62
61
  acu config init --host customer.acumatica.com my-erp
63
- cd my-erp # edit .env: ACU_TENANT, ACU_PASSWORD; ACU_SSH=
62
+ cd my-erp # edit .env: ACU_TENANT, ACU_PASSWORD; add ACU_SSH=
64
63
  acu config check # REST preflight; ssh probe is skipped
65
64
  acu --tenant DEV bootstrap # publish AcuBootstrap via REST only
66
65
  acu --tenant DEV apply config/
@@ -126,7 +125,6 @@ There is no `--flavor`.
126
125
  Legacy data repos may still keep root `bootstrap/`…`master/`; bare `apply`/`diff` prefer `config/` when present and never merge both trees.
127
126
 
128
127
  Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order.
129
- The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
130
128
  Commit `target.yaml` with the seeds so every clone knows the verified ERP line and Default API generation.
131
129
 
132
130
  Seed YAML is state: `apply` upserts it, `diff` proves it.
@@ -141,38 +139,19 @@ git diff config/ # review extract delta before commit
141
139
  acu --tenant DEV apply config/ # replay extracted seed
142
140
  acu --tenant DEV diff config/ # expect exit 0
143
141
  ```
144
-
145
- **Migration (T115–T120 extract hard-cut):** extract no longer writes root `bootstrap/` / `baseline/` / `setup/` / `master/`.
146
- Paths are always `config/…`.
147
- There is no `--layout`.
148
- Move any root-layout extract output under `config/`, or re-extract into a modern data repo.
149
- Catalog rename: `extract_manifest.yaml` becomes `seed_catalog.yaml` (package data only; operators do not author it).
150
-
151
- Scenario YAML is different — it describes transactions that flow forward (not extractable seed).
152
- `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 additive scenarios re-run safely on a warm tenant.
153
- `once: true` scenarios declare a `present` inquire-absolute gate; when the probe already holds, the CLI prints `skip <path> (once: already present)` and runs neither steps nor expects (Owner Capital does not restack).
154
-
155
- `acu state` is the third observation path: it captures live derived state (balances) into `state/` for git review.
156
- It is not `extract` (config seed) and not `diff` (desired vs actual config).
157
- Packaged golden is trial-balance via contract `inquire:` (`EndingBalance` fixed-point); inventory-summary is not golden this pass. `gi:` stays optional when a GI is V12-verified and **Expose via OData** is on (`params` fail-closed vs `$metadata`).
158
- After a cold `acu run scenario/ && acu state`, warm `acu run scenario/10-seed-capital.yaml && acu state --assert-unchanged` is the once-class gate (full scenario re-run is additive and moves cash observations on the TB).
159
-
160
- **Migration (T112–T114 hard-cut):** CLI verb is `state` only (no `snapshot` alias).
161
- Bare defaults are `config/views/` (views) and `state/` (observations).
162
- Prior `config/snapshot/` and root `snapshot/` / `snapshots/` are not fallbacks — move views to `config/views/` or pass explicit path args.
163
-
164
142
  ### Seed `endpoint:` symbols
165
143
 
166
144
  Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
167
145
 
168
146
  | Value | Resolves to |
169
147
  | ----- | ----------- |
170
- | omitted | `Default/<ACU_API_VERSION>` for Default-only entities |
148
+ | omitted | `Default/<api_version>` for Default-only entities |
171
149
  | `bootstrap` | active `Bootstrap/<ver>` from `bootstrap/project.xml` or the packaged contract |
172
- | `default` | `Default/<ACU_API_VERSION>` — tracks the operator API version |
173
- | `Bootstrap/1.0.0` or `Default/25.200.001` | literal pin (ignores `ACU_API_VERSION` for Default) |
150
+ | `default` | `Default/<api_version>` — tracks the resolved API version |
151
+ | `Bootstrap/1.0.0` or `Default/25.200.001` | literal pin (ignores the resolved Default version) |
174
152
 
175
- Prefer symbolic `default` over a pinned `Default/25.200.001` so the seed tree travels with the configured API generation.
153
+ `api_version` resolves as `--api-version` flag, else `target.yaml` `default_api` when present, else code default `25.200.001` (never `ACU_API_VERSION` in `.env`).
154
+ Prefer symbolic `default` over a pinned `Default/25.200.001` so the seed tree travels with the dataset pin.
176
155
 
177
156
  ## Installation
178
157
 
@@ -193,41 +172,45 @@ Verify with `acu --version`.
193
172
 
194
173
  ## Configuration
195
174
 
196
- Everything lives in one `.env` file: *where* to apply and *who* signs in.
197
- Three values are required; everything else has a code default matching a stock Acumatica install:
175
+ Everything lives in one `.env` file: *where* to apply and *who* signs in
176
+ (`ACU_*` vars only).
177
+ The Default contract API pin is **not** in `.env` — it
178
+ lives in committed `target.yaml` (`default_api`).
198
179
 
199
180
  ```sh
200
181
  ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # required: REST root
201
182
  ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use
202
- ACU_SSH=Administrator@acu-dev1.vm.internal # optional: control-plane user@host (tenant CRUD)
203
- ACU_API_VERSION=25.200.001 # Default contract version half only
183
+ # ACU_SSH omitted → defaults to Administrator@acu-dev1.vm.internal
184
+ # ACU_SSH= # hosted opt-out (blank key)
204
185
  ACU_USER=admin # optional, defaults to admin
205
186
  ACU_PASSWORD=... # required for live commands
206
187
  ```
207
188
 
208
- `ACU_API_VERSION` is the version half only (`25.200.001`), never `Default/25.200.001`.
209
- A full path would nest as `/entity/Default/Default/...`.
189
+ There is no `ACU_API_VERSION` env key (unknown `ACU_*` vars are ignored).
190
+ Ad-hoc override: `acu --api-version 24.200.001 …` (version half only, never
191
+ `Default/25.200.001` — a full path would nest as `/entity/Default/Default/...`).
210
192
 
211
- The committed `target.yaml` next to `.env` declares the verified matrix (what, not where):
193
+ The committed `target.yaml` next to `.env` declares the verified matrix (what, not where) and is the **sole data-repo Default pin**:
212
194
 
213
195
  ```yaml
214
- erp: "26.101.0225" # claimed product line/build (README-level detail)
215
- default_api: "25.200.001" # must match ACU_API_VERSION
196
+ erp: "26.101.0225" # claimed product line/build
197
+ default_api: "25.200.001" # sources Instance.api_version when --api-version absent
216
198
  ```
217
199
 
218
- When `target.yaml` is present, `apply` / `diff` / `run` / `extract` / `schema` / `bootstrap` hard-fail if `default_api` does not match the configured API version.
219
- `acu config check` reports the same match as a probe line.
220
- Missing `target.yaml` only warns on check unless you pass `--strict`.
200
+ When `target.yaml` is present, live commands resolve `api_version` from
201
+ `default_api` (source-merge). `acu config check` reports
202
+ `ok target (api_version from default_api=…; erp=… claimed)`.
203
+ Missing `target.yaml` only warns on check unless you pass `--strict` (then
204
+ the code default `25.200.001` is used).
221
205
 
222
206
  Worth knowing:
223
207
 
224
208
  - The file is found by walking up from the current directory, so any subdirectory of the data repo works.
225
209
  - Without a `.env`, global flags plus the process environment supply the full configuration.
226
- - `ACU_SSH` is optional.
227
- - Leave it blank on hosted instances; only `acu tenant` needs it.
228
- - Nothing is derived: split-horizon DNS, port forwards, and jump hosts are all handled by writing the address you actually want into the address keys.
229
- - `acu config show` prints the fully resolved configuration as a complete, valid `.env` — every knob visible, the password excluded.
230
- - When `target.yaml` is present, `config show` also comments `erp` / `default_api`.
210
+ - When `ACU_SSH` is **absent**, acu defaults to `Administrator@` + the `ACU_BASE_URL` hostname.
211
+ A **present blank** `ACU_SSH=` is the hosted opt-out.
212
+ Only `acu tenant` requires a non-empty value post-default.
213
+ - `acu config show` prints the resolved `.env` (password excluded; never `ACU_API_VERSION`) and comments `erp` / `default_api` plus the `api_version` source when `target.yaml` is present.
231
214
  - Redirect it to turn resolved state into a working config: `acu config show > .env`.
232
215
 
233
216
  Verify before touching anything live:
@@ -238,65 +221,6 @@ acu config check --strict # missing target.yaml becomes fail
238
221
  acu apply --dry-run # show what would be written, write nothing
239
222
  ```
240
223
 
241
- ## Control and Data Planes
242
-
243
- `acu` talks to an instance over two independent channels:
244
-
245
- - **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).
246
- - **Data plane (REST):** `acu bootstrap`, `apply`, `diff`, `run`, `extract`, and `schema` use the contract-based API and CustomizationApi — see [`docs/rest-api.md`](docs/rest-api.md).
247
-
248
- If you never touch `acu tenant`, you never need SSH.
249
- On a hosted instance, publish AcuBootstrap with `acu bootstrap`, then seed with `apply`.
250
- `acu bootstrap --export PATH` writes the package zip for manual import on the Customization Projects screen (SM204505) when you cannot call the API.
251
-
252
- ## SSH setup (control plane)
253
-
254
- `acu tenant` runs commands on the Windows guest through plain `ssh`.
255
- Two things about this setup are not obvious, and both are hard requirements.
256
-
257
- **1.
258
- The default SSH shell on the Windows guest must be PowerShell.**
259
- `acu` sends PowerShell syntax over the wire, and every one of those commands fails under `cmd.exe`, the Windows OpenSSH default.
260
- Switch it once, in an elevated PowerShell on the guest:
261
-
262
- ```powershell
263
- New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell `
264
- -Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" `
265
- -PropertyType String -Force
266
- ```
267
-
268
- **2.
269
- Authentication must be key-based and non-interactive.**
270
- `acu` connects with `BatchMode=yes`, so it will never answer a password prompt.
271
- Because the default user is `Administrator` (an administrators-group member), Windows OpenSSH reads the key from the *machine-wide* file `C:\ProgramData\ssh\administrators_authorized_keys` — **not** from `~\.ssh\authorized_keys` like on Linux.
272
- On the guest:
273
-
274
- Install + start the server (once):
275
-
276
- ```powershell
277
- Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
278
- Set-Service sshd -StartupType Automatic
279
- Start-Service sshd
280
- ```
281
-
282
- Authorize your public key for administrators:
283
-
284
- ```powershell
285
- Add-Content -Path C:\ProgramData\ssh\administrators_authorized_keys -Value "ssh-ed25519 AAAA... you@laptop"
286
- ```
287
-
288
- The file must be readable by SYSTEM/Administrators only, or sshd ignores it:
289
-
290
- ```powershell
291
- icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
292
- ```
293
-
294
- Then verify from your workstation — this one test proves both requirements at once (key auth works, and the shell is PowerShell):
295
-
296
- ```sh
297
- ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersion'
298
- ```
299
-
300
224
  ## Development
301
225
 
302
226
  Requires **GNU Make at least 3.82** — the Makefile uses `.ONESHELL`.
@@ -328,7 +252,7 @@ GitHub Actions re-runs the check on the tag, then publishes the GitHub release a
328
252
 
329
253
  `gmake e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
330
254
 
331
- Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_SSH`, `ACU_TENANT`, `ACU_PASSWORD`.
255
+ Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_TENANT`, `ACU_PASSWORD` (and optional `ACU_SSH`; omitted defaults to `Administrator@` + base-url host).
332
256
  `gmake e2e` refuses to start without it.
333
257
 
334
258
  The tier is self-contained.
@@ -21,7 +21,7 @@ uv tool install acumatica-cli
21
21
 
22
22
  acu config init --host erp.example.com my-erp
23
23
  cd my-erp # edit .env: set ACU_PASSWORD, ACU_TENANT
24
- # keep ACU_API_VERSION in sync with target.yaml
24
+ # Default API pin = target.yaml default_api
25
25
  # start from a brand-new empty tenant
26
26
 
27
27
  acu config check # read-only preflight (incl. target.yaml)
@@ -31,18 +31,17 @@ acu --tenant DEV apply config/ # seed config/{bootstrap,baseline,setup
31
31
  acu --tenant DEV run scenario/ # once capital → buy → build → sell
32
32
  acu --tenant DEV diff config/ # prove zero drift (exit 2 on drift)
33
33
  acu --tenant DEV state # capture state/ trial-balance
34
- # warm: capital once-skips; Owner Capital stays 50000 (not 100000)
35
- acu --tenant DEV run scenario/
34
+ acu --tenant DEV run scenario/ # replay transaction scenarios
36
35
  ```
37
36
 
38
37
  Bare `apply` / `diff` (no path args) also prefer `config/` when those trees exist.
39
38
  See [docs/demo-seed.md](docs/demo-seed.md) for the entity map, once-guard, and apply-order notes.
40
39
 
41
- **Hosted Acumatica (no SSH):** the tenant already exists; leave `ACU_SSH` blank.
40
+ **Hosted Acumatica (no SSH):** the tenant already exists; set a blank `ACU_SSH=` in `.env` (scaffold omits the key — without it, acu defaults to `Administrator@<ACU_BASE_URL host>` for SSH boxes).
42
41
 
43
42
  ```sh
44
43
  acu config init --host customer.acumatica.com my-erp
45
- cd my-erp # edit .env: ACU_TENANT, ACU_PASSWORD; ACU_SSH=
44
+ cd my-erp # edit .env: ACU_TENANT, ACU_PASSWORD; add ACU_SSH=
46
45
  acu config check # REST preflight; ssh probe is skipped
47
46
  acu --tenant DEV bootstrap # publish AcuBootstrap via REST only
48
47
  acu --tenant DEV apply config/
@@ -108,7 +107,6 @@ There is no `--flavor`.
108
107
  Legacy data repos may still keep root `bootstrap/`…`master/`; bare `apply`/`diff` prefer `config/` when present and never merge both trees.
109
108
 
110
109
  Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order.
111
- The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
112
110
  Commit `target.yaml` with the seeds so every clone knows the verified ERP line and Default API generation.
113
111
 
114
112
  Seed YAML is state: `apply` upserts it, `diff` proves it.
@@ -123,38 +121,19 @@ git diff config/ # review extract delta before commit
123
121
  acu --tenant DEV apply config/ # replay extracted seed
124
122
  acu --tenant DEV diff config/ # expect exit 0
125
123
  ```
126
-
127
- **Migration (T115–T120 extract hard-cut):** extract no longer writes root `bootstrap/` / `baseline/` / `setup/` / `master/`.
128
- Paths are always `config/…`.
129
- There is no `--layout`.
130
- Move any root-layout extract output under `config/`, or re-extract into a modern data repo.
131
- Catalog rename: `extract_manifest.yaml` becomes `seed_catalog.yaml` (package data only; operators do not author it).
132
-
133
- Scenario YAML is different — it describes transactions that flow forward (not extractable seed).
134
- `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 additive scenarios re-run safely on a warm tenant.
135
- `once: true` scenarios declare a `present` inquire-absolute gate; when the probe already holds, the CLI prints `skip <path> (once: already present)` and runs neither steps nor expects (Owner Capital does not restack).
136
-
137
- `acu state` is the third observation path: it captures live derived state (balances) into `state/` for git review.
138
- It is not `extract` (config seed) and not `diff` (desired vs actual config).
139
- Packaged golden is trial-balance via contract `inquire:` (`EndingBalance` fixed-point); inventory-summary is not golden this pass. `gi:` stays optional when a GI is V12-verified and **Expose via OData** is on (`params` fail-closed vs `$metadata`).
140
- After a cold `acu run scenario/ && acu state`, warm `acu run scenario/10-seed-capital.yaml && acu state --assert-unchanged` is the once-class gate (full scenario re-run is additive and moves cash observations on the TB).
141
-
142
- **Migration (T112–T114 hard-cut):** CLI verb is `state` only (no `snapshot` alias).
143
- Bare defaults are `config/views/` (views) and `state/` (observations).
144
- Prior `config/snapshot/` and root `snapshot/` / `snapshots/` are not fallbacks — move views to `config/views/` or pass explicit path args.
145
-
146
124
  ### Seed `endpoint:` symbols
147
125
 
148
126
  Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
149
127
 
150
128
  | Value | Resolves to |
151
129
  | ----- | ----------- |
152
- | omitted | `Default/<ACU_API_VERSION>` for Default-only entities |
130
+ | omitted | `Default/<api_version>` for Default-only entities |
153
131
  | `bootstrap` | active `Bootstrap/<ver>` from `bootstrap/project.xml` or the packaged contract |
154
- | `default` | `Default/<ACU_API_VERSION>` — tracks the operator API version |
155
- | `Bootstrap/1.0.0` or `Default/25.200.001` | literal pin (ignores `ACU_API_VERSION` for Default) |
132
+ | `default` | `Default/<api_version>` — tracks the resolved API version |
133
+ | `Bootstrap/1.0.0` or `Default/25.200.001` | literal pin (ignores the resolved Default version) |
156
134
 
157
- Prefer symbolic `default` over a pinned `Default/25.200.001` so the seed tree travels with the configured API generation.
135
+ `api_version` resolves as `--api-version` flag, else `target.yaml` `default_api` when present, else code default `25.200.001` (never `ACU_API_VERSION` in `.env`).
136
+ Prefer symbolic `default` over a pinned `Default/25.200.001` so the seed tree travels with the dataset pin.
158
137
 
159
138
  ## Installation
160
139
 
@@ -175,41 +154,45 @@ Verify with `acu --version`.
175
154
 
176
155
  ## Configuration
177
156
 
178
- Everything lives in one `.env` file: *where* to apply and *who* signs in.
179
- Three values are required; everything else has a code default matching a stock Acumatica install:
157
+ Everything lives in one `.env` file: *where* to apply and *who* signs in
158
+ (`ACU_*` vars only).
159
+ The Default contract API pin is **not** in `.env` — it
160
+ lives in committed `target.yaml` (`default_api`).
180
161
 
181
162
  ```sh
182
163
  ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # required: REST root
183
164
  ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use
184
- ACU_SSH=Administrator@acu-dev1.vm.internal # optional: control-plane user@host (tenant CRUD)
185
- ACU_API_VERSION=25.200.001 # Default contract version half only
165
+ # ACU_SSH omitted → defaults to Administrator@acu-dev1.vm.internal
166
+ # ACU_SSH= # hosted opt-out (blank key)
186
167
  ACU_USER=admin # optional, defaults to admin
187
168
  ACU_PASSWORD=... # required for live commands
188
169
  ```
189
170
 
190
- `ACU_API_VERSION` is the version half only (`25.200.001`), never `Default/25.200.001`.
191
- A full path would nest as `/entity/Default/Default/...`.
171
+ There is no `ACU_API_VERSION` env key (unknown `ACU_*` vars are ignored).
172
+ Ad-hoc override: `acu --api-version 24.200.001 …` (version half only, never
173
+ `Default/25.200.001` — a full path would nest as `/entity/Default/Default/...`).
192
174
 
193
- The committed `target.yaml` next to `.env` declares the verified matrix (what, not where):
175
+ The committed `target.yaml` next to `.env` declares the verified matrix (what, not where) and is the **sole data-repo Default pin**:
194
176
 
195
177
  ```yaml
196
- erp: "26.101.0225" # claimed product line/build (README-level detail)
197
- default_api: "25.200.001" # must match ACU_API_VERSION
178
+ erp: "26.101.0225" # claimed product line/build
179
+ default_api: "25.200.001" # sources Instance.api_version when --api-version absent
198
180
  ```
199
181
 
200
- When `target.yaml` is present, `apply` / `diff` / `run` / `extract` / `schema` / `bootstrap` hard-fail if `default_api` does not match the configured API version.
201
- `acu config check` reports the same match as a probe line.
202
- Missing `target.yaml` only warns on check unless you pass `--strict`.
182
+ When `target.yaml` is present, live commands resolve `api_version` from
183
+ `default_api` (source-merge). `acu config check` reports
184
+ `ok target (api_version from default_api=…; erp=… claimed)`.
185
+ Missing `target.yaml` only warns on check unless you pass `--strict` (then
186
+ the code default `25.200.001` is used).
203
187
 
204
188
  Worth knowing:
205
189
 
206
190
  - The file is found by walking up from the current directory, so any subdirectory of the data repo works.
207
191
  - Without a `.env`, global flags plus the process environment supply the full configuration.
208
- - `ACU_SSH` is optional.
209
- - Leave it blank on hosted instances; only `acu tenant` needs it.
210
- - Nothing is derived: split-horizon DNS, port forwards, and jump hosts are all handled by writing the address you actually want into the address keys.
211
- - `acu config show` prints the fully resolved configuration as a complete, valid `.env` — every knob visible, the password excluded.
212
- - When `target.yaml` is present, `config show` also comments `erp` / `default_api`.
192
+ - When `ACU_SSH` is **absent**, acu defaults to `Administrator@` + the `ACU_BASE_URL` hostname.
193
+ A **present blank** `ACU_SSH=` is the hosted opt-out.
194
+ Only `acu tenant` requires a non-empty value post-default.
195
+ - `acu config show` prints the resolved `.env` (password excluded; never `ACU_API_VERSION`) and comments `erp` / `default_api` plus the `api_version` source when `target.yaml` is present.
213
196
  - Redirect it to turn resolved state into a working config: `acu config show > .env`.
214
197
 
215
198
  Verify before touching anything live:
@@ -220,65 +203,6 @@ acu config check --strict # missing target.yaml becomes fail
220
203
  acu apply --dry-run # show what would be written, write nothing
221
204
  ```
222
205
 
223
- ## Control and Data Planes
224
-
225
- `acu` talks to an instance over two independent channels:
226
-
227
- - **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).
228
- - **Data plane (REST):** `acu bootstrap`, `apply`, `diff`, `run`, `extract`, and `schema` use the contract-based API and CustomizationApi — see [`docs/rest-api.md`](docs/rest-api.md).
229
-
230
- If you never touch `acu tenant`, you never need SSH.
231
- On a hosted instance, publish AcuBootstrap with `acu bootstrap`, then seed with `apply`.
232
- `acu bootstrap --export PATH` writes the package zip for manual import on the Customization Projects screen (SM204505) when you cannot call the API.
233
-
234
- ## SSH setup (control plane)
235
-
236
- `acu tenant` runs commands on the Windows guest through plain `ssh`.
237
- Two things about this setup are not obvious, and both are hard requirements.
238
-
239
- **1.
240
- The default SSH shell on the Windows guest must be PowerShell.**
241
- `acu` sends PowerShell syntax over the wire, and every one of those commands fails under `cmd.exe`, the Windows OpenSSH default.
242
- Switch it once, in an elevated PowerShell on the guest:
243
-
244
- ```powershell
245
- New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell `
246
- -Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" `
247
- -PropertyType String -Force
248
- ```
249
-
250
- **2.
251
- Authentication must be key-based and non-interactive.**
252
- `acu` connects with `BatchMode=yes`, so it will never answer a password prompt.
253
- Because the default user is `Administrator` (an administrators-group member), Windows OpenSSH reads the key from the *machine-wide* file `C:\ProgramData\ssh\administrators_authorized_keys` — **not** from `~\.ssh\authorized_keys` like on Linux.
254
- On the guest:
255
-
256
- Install + start the server (once):
257
-
258
- ```powershell
259
- Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
260
- Set-Service sshd -StartupType Automatic
261
- Start-Service sshd
262
- ```
263
-
264
- Authorize your public key for administrators:
265
-
266
- ```powershell
267
- Add-Content -Path C:\ProgramData\ssh\administrators_authorized_keys -Value "ssh-ed25519 AAAA... you@laptop"
268
- ```
269
-
270
- The file must be readable by SYSTEM/Administrators only, or sshd ignores it:
271
-
272
- ```powershell
273
- icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
274
- ```
275
-
276
- Then verify from your workstation — this one test proves both requirements at once (key auth works, and the shell is PowerShell):
277
-
278
- ```sh
279
- ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersion'
280
- ```
281
-
282
206
  ## Development
283
207
 
284
208
  Requires **GNU Make at least 3.82** — the Makefile uses `.ONESHELL`.
@@ -310,7 +234,7 @@ GitHub Actions re-runs the check on the tag, then publishes the GitHub release a
310
234
 
311
235
  `gmake e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
312
236
 
313
- Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_SSH`, `ACU_TENANT`, `ACU_PASSWORD`.
237
+ Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_TENANT`, `ACU_PASSWORD` (and optional `ACU_SSH`; omitted defaults to `Administrator@` + base-url host).
314
238
  `gmake e2e` refuses to start without it.
315
239
 
316
240
  The tier is self-contained.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.15.2"
3
+ version = "0.15.4"
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
  license = "PolyForm-Noncommercial-1.0.0"
@@ -155,18 +155,67 @@ def pass_instance[**P, R](f: Callable[Concatenate[Instance, P], R]) -> Callable[
155
155
  return new_func
156
156
 
157
157
 
158
+ def _transport_target(
159
+ exc: httpx.TransportError, *, target: str | None = None
160
+ ) -> str | None:
161
+ """Prefer explicit target (e.g. Instance.base_url); else request URL."""
162
+ if target:
163
+ return target
164
+ request = getattr(exc, "request", None)
165
+ if request is not None:
166
+ return str(request.url)
167
+ return None
168
+
169
+
170
+ def _format_transport_error(
171
+ exc: httpx.TransportError, *, target: str | None = None
172
+ ) -> str:
173
+ """Friendly one-line body for connect/timeout/TLS (V9 / T123).
174
+
175
+ Never surface raw httpx/OS dumps (e.g. ``[Errno 61] Connection refused``).
176
+ """
177
+ raw = str(exc).lower()
178
+ if "ssl" in raw or "certificate" in raw or "tls" in raw:
179
+ kind = "TLS error"
180
+ elif isinstance(exc, httpx.TimeoutException):
181
+ kind = "request timed out"
182
+ elif isinstance(exc, httpx.ConnectError):
183
+ kind = "cannot connect"
184
+ elif isinstance(exc, httpx.NetworkError):
185
+ kind = "network error"
186
+ else:
187
+ kind = "transport error"
188
+ where = _transport_target(exc, target=target)
189
+ hint = "check ACU_BASE_URL / network / instance up"
190
+ if where:
191
+ return f"{kind} to {where} ({hint})"
192
+ return f"{kind} ({hint})"
193
+
194
+
195
+ def _format_failure(exc: BaseException, *, target: str | None = None) -> str:
196
+ """Map expected failures to one greppable error line body (V9).
197
+
198
+ Transport/network class → friendly rewrite; other RuntimeError / HTTPError
199
+ keep ``str(exc)`` (server ``exceptionMessage`` path unchanged).
200
+ """
201
+ if isinstance(exc, httpx.TransportError):
202
+ return _format_transport_error(exc, target=target)
203
+ return str(exc)
204
+
205
+
158
206
  def main() -> None:
159
207
  """Entry point: run the CLI, mapping expected failures to one-line errors.
160
208
 
161
209
  RuntimeError (SSH/ac.exe, REST, first-login) and httpx transport errors
162
210
  print `x message` and exit 1; ACU_DEBUG=1 re-raises for the traceback.
211
+ Transport/network failures use a friendly rewrite (V9), not raw dumps.
163
212
  """
164
213
  try:
165
214
  cli()
166
215
  except (RuntimeError, httpx.HTTPError) as exc:
167
216
  if os.environ.get("ACU_DEBUG"):
168
217
  raise
169
- output.error(str(exc))
218
+ output.error(_format_failure(exc))
170
219
  raise SystemExit(1) from exc
171
220
 
172
221
 
@@ -407,8 +456,8 @@ def config_group() -> None:
407
456
  @click.option(
408
457
  "--host",
409
458
  default=None,
410
- help="Hostname substituted into the scaffolded .env ACU_BASE_URL/ACU_SSH "
411
- "values (default: a placeholder)",
459
+ help="Hostname substituted into the scaffolded .env ACU_BASE_URL "
460
+ "(default: a placeholder); ACU_SSH is omitted and defaults from that host",
412
461
  )
413
462
  @click.argument(
414
463
  "directory", required=False, type=click.Path(file_okay=False, path_type=Path)
@@ -431,7 +480,7 @@ def config_init(host: str | None, directory: Path | None) -> None:
431
480
  # next-step cmds: operator rebuild order after scaffold (V28)
432
481
  output.data("")
433
482
  output.data("next:")
434
- output.data(" 1. edit .env (set ACU_PASSWORD, ACU_TENANT; keep ACU_API_VERSION)")
483
+ output.data(" 1. edit .env (set ACU_PASSWORD, ACU_TENANT)")
435
484
  output.data(" 2. acu config check")
436
485
  output.data(" 3. acu bootstrap # or: acu tenant create ... (SSH)")
437
486
  output.data(" 4. acu apply config/")
@@ -449,23 +498,35 @@ def config_show(inst: Instance) -> None:
449
498
  so the printed values are exactly what a live command would trust -
450
499
  global flag overrides (--url, --ssh, ...) included. The password is
451
500
  never emitted in any form (V2): no ACU_PASSWORD key, no value.
452
- When target.yaml is present, surfaces erp/default_api as comments
453
- (mismatch noted, still exit 0 — no hard gate). Redirect to a file and
454
- edit: the output loads back through load_instance unchanged, the
455
- password supplied out of band.
501
+ ``ACU_API_VERSION`` is never emitted (V27/T125 — api pin is not env;
502
+ source is target.yaml ``default_api`` or ``--api-version``). When
503
+ target.yaml is present, surfaces erp/default_api as comments and notes
504
+ the api_version source (still exit 0 — no hard gate). Redirect to a
505
+ file and edit: the output loads back through load_instance unchanged,
506
+ the password supplied out of band.
456
507
  """
457
508
  output.data("# resolved by `acu config show` - a complete .env")
458
509
  output.data("# ACU_PASSWORD comes from .env or the environment, never from here")
459
- for field, value in inst.model_dump(exclude={"password"}).items():
510
+ # V27: api pin is not an env key — never emit ACU_API_VERSION
511
+ for field, value in inst.model_dump(exclude={"password", "api_version"}).items():
460
512
  output.data(f"ACU_{field.upper()}={value}")
461
513
  target = load_target()
462
514
  if target is not None:
463
515
  output.data(f"# target.yaml: erp={target.erp} default_api={target.default_api}")
464
- if target.default_api != inst.api_version:
516
+ if inst.api_version == target.default_api:
465
517
  output.data(
466
- f"# warn: default_api={target.default_api} != "
467
- f"ACU_API_VERSION={inst.api_version}"
518
+ f"# api_version={inst.api_version} (from target.yaml default_api)"
468
519
  )
520
+ else:
521
+ output.data(
522
+ f"# api_version={inst.api_version} (from --api-version; "
523
+ f"target default_api={target.default_api})"
524
+ )
525
+ else:
526
+ output.data(
527
+ f"# api_version={inst.api_version} "
528
+ f"(code default or --api-version; no target.yaml)"
529
+ )
469
530
 
470
531
 
471
532
  @config_group.command("check")
@@ -546,7 +607,7 @@ def _probe_rest(inst: Instance, claimed_erp: str | None) -> bool:
546
607
  output.data(f"ok rest ({inst.base_url}, tenant {inst.tenant})")
547
608
  return _probe_entity_root(client, inst, claimed_erp)
548
609
  except (RuntimeError, httpx.HTTPError) as exc:
549
- output.data(f"fail rest: {exc}")
610
+ output.data(f"fail rest: {_format_failure(exc, target=inst.base_url)}")
550
611
  return False
551
612
 
552
613
 
@@ -558,7 +619,7 @@ def _probe_entity_root(
558
619
  try:
559
620
  endpoints, live_build = client.entity_root()
560
621
  except (RuntimeError, httpx.HTTPError) as exc:
561
- output.data(f"fail endpoints: {exc}")
622
+ output.data(f"fail endpoints: {_format_failure(exc, target=inst.base_url)}")
562
623
  endpoints, live_build = [], None
563
624
  ok = False
564
625
  else:
@@ -609,16 +670,18 @@ def _probe_target(
609
670
  )
610
671
  output.data(f"{'fail' if strict else 'warn'} {msg}")
611
672
  return strict, None
612
- if target.default_api != inst.api_version:
673
+ # V27/T125: source-merge — api_version already from default_api unless
674
+ # --api-version; surface source, never dual-source mismatch fail
675
+ if inst.api_version == target.default_api:
613
676
  output.data(
614
- f"fail target: dataset default_api={target.default_api} vs "
615
- f"configured {inst.api_version}"
677
+ f"ok target (api_version from default_api={target.default_api}; "
678
+ f"erp={target.erp} claimed)"
679
+ )
680
+ else:
681
+ output.data(
682
+ f"ok target (api_version={inst.api_version} from --api-version; "
683
+ f"default_api={target.default_api}; erp={target.erp} claimed)"
616
684
  )
617
- return True, None
618
- output.data(
619
- f"ok target (default_api={target.default_api} matches configured; "
620
- f"erp={target.erp} claimed)"
621
- )
622
685
  return False, target.erp
623
686
 
624
687
 
@@ -5,16 +5,22 @@ 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`` 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``.
8
+ over a found ``.env``), code default — exclusion ``api_version`` (V27/T125):
9
+ ``--api-version`` flag ? → else data-repo ``target.yaml`` ``default_api``
10
+ when present → else code default ``25.200.001``; never ``ACU_API_VERSION``
11
+ (unknown ``ACU_*`` ignored). ``base_url`` is the only required address
12
+ (REST data plane). ``ssh`` defaults to ``Administrator@`` + the
13
+ ``base_url`` hostname when the key is absent (V3/T124); a present blank
14
+ key is the hosted opt-out (empty = data-plane only; tenant cmds hard-error
15
+ when empty post-default — V1/V3). The password must resolve via
16
+ ``--password`` or ``ACU_PASSWORD``.
12
17
  """
13
18
 
14
19
  from collections.abc import Iterator, Mapping
15
20
  from importlib import resources
16
21
  from pathlib import Path
17
22
  from typing import Any
23
+ from urllib.parse import urlparse
18
24
 
19
25
  from pydantic import ValidationError, field_validator, model_validator
20
26
  from pydantic_settings import BaseSettings, DotEnvSettingsSource, SettingsConfigDict
@@ -22,6 +28,8 @@ from pydantic_settings import BaseSettings, DotEnvSettingsSource, SettingsConfig
22
28
  from .models import validation_summary
23
29
 
24
30
  PLACEHOLDER_HOST = "erp.example.com"
31
+ DEFAULT_SSH_USER = "Administrator"
32
+ DEFAULT_API_VERSION = "25.200.001"
25
33
 
26
34
  ACU_INSTANCE_NAME = "AcumaticaERP" # ac.exe -iname; IIS app-pool name
27
35
  ACU_INSTANCE_PATH = "C:\\Acumatica\\AcumaticaERP" # ac.exe -h
@@ -124,13 +132,26 @@ INIT_TEMPLATES = (
124
132
  )
125
133
 
126
134
 
135
+ def default_ssh_for_base_url(base_url: str | None) -> str:
136
+ """``Administrator@<hostname>`` from a REST root, or empty when unparseable.
137
+
138
+ T124/V3 code default when ``ACU_SSH`` is absent. Blank key stays empty
139
+ (hosted opt-out) and never calls this.
140
+ """
141
+ if not base_url:
142
+ return ""
143
+ host = urlparse(base_url).hostname
144
+ return f"{DEFAULT_SSH_USER}@{host}" if host else ""
145
+
146
+
127
147
  class Instance(BaseSettings):
128
148
  """The resolved target: flags over ACU_* vars (.env or process) over defaults.
129
149
 
130
- Explicit addresses, no derivation (V1): ``base_url`` is the REST root
131
- (scheme + host + site path); ``ssh`` is the optional control-plane
132
- ``user@host`` (empty = data-plane only). Install-layout values are
133
- module constants, not fields. Unknown ``ACU_*`` vars are ignored,
150
+ ``base_url`` is the REST root (scheme + host + site path). ``ssh`` is the
151
+ control-plane ``user@host``: flag/env when the key is present win
152
+ (including blank = hosted/data-plane only); key absent → code default
153
+ ``Administrator@`` + base_url hostname (V3/T124). Install-layout values
154
+ are module constants, not fields. Unknown ``ACU_*`` vars are ignored,
134
155
  never errors - the environment and ``.env`` legitimately carry
135
156
  non-config vars (``ACU_DEBUG``).
136
157
  """
@@ -142,9 +163,12 @@ class Instance(BaseSettings):
142
163
  )
143
164
 
144
165
  base_url: str # REST root: scheme + host + site path
145
- ssh: str = "" # control plane: full user@host; empty = data-plane only
166
+ ssh: str = "" # control plane: full user@host; empty post-default = data-plane only
146
167
  tenant: str = ""
147
- api_version: str = "25.200.001" # V11: /entity/Default/<api_version>/
168
+ # V11/V27: version half only; resolved in load_instance (flag → target →
169
+ # code default), never ACU_API_VERSION env. Default here is the code pin
170
+ # only; env values for this field are discarded post-build (T125).
171
+ api_version: str = DEFAULT_API_VERSION # V11: /entity/Default/<api_version>/
148
172
  user: str = "admin" # ACU_USER; the --username flag maps here
149
173
  # required, but enforced in load_instance so a blank scaffolded
150
174
  # ACU_PASSWORD= placeholder and a missing var raise the same named error
@@ -152,13 +176,15 @@ class Instance(BaseSettings):
152
176
 
153
177
  @model_validator(mode="before")
154
178
  @classmethod
155
- def _blank_required_is_unset(cls, data: Any) -> Any:
156
- # blank ACU_BASE_URL= / ACU_SSH= reads as unset (V3): base_url then
157
- # fails required; ssh falls through to the empty default (optional)
179
+ def _blank_required_and_ssh_default(cls, data: Any) -> Any:
180
+ # blank ACU_BASE_URL= reads as unset (V3): base_url then fails required.
181
+ # ACU_SSH: key absent → Administrator@<base_url host>; key present blank
182
+ # stays empty (hosted opt-out) — do not strip blank ssh (T124).
158
183
  if isinstance(data, dict):
159
- for key in ("base_url", "ssh"):
160
- if data.get(key) == "":
161
- del data[key]
184
+ if data.get("base_url") == "":
185
+ del data["base_url"]
186
+ if "ssh" not in data:
187
+ data["ssh"] = default_ssh_for_base_url(data.get("base_url"))
162
188
  return data
163
189
 
164
190
  @field_validator("base_url")
@@ -188,11 +214,12 @@ def scaffold(directory: Path, host: str | None = None) -> Iterator[tuple[str, Pa
188
214
  """Write the data-repo template set into ``directory``, never overwriting.
189
215
 
190
216
  Yields ("write" | "skip", path) per template file. ``host`` replaces the
191
- placeholder host inside the scaffolded .env ``ACU_BASE_URL``/``ACU_SSH``
192
- values; secrets stay placeholders (V2). Single full seed under
193
- ``config/`` + lifecycle ``scenario/`` (V28/T108; no flavor). The
194
- directory is created if absent. No git init, no gpg - version control
195
- and secret encryption stay the operator's call.
217
+ placeholder host inside the scaffolded .env ``ACU_BASE_URL`` only
218
+ (``ACU_SSH`` is omitted — defaults from the base_url host at resolve;
219
+ hosted opt-out = present blank ``ACU_SSH=``). Secrets stay placeholders
220
+ (V2). Single full seed under ``config/`` + lifecycle ``scenario/``
221
+ (V28/T108; no flavor). The directory is created if absent. No git init,
222
+ no gpg - version control and secret encryption stay the operator's call.
196
223
  """
197
224
  pkg = resources.files("acumatica_cli") / "templates"
198
225
  directory.mkdir(parents=True, exist_ok=True)
@@ -257,13 +284,27 @@ def load_instance(overrides: Mapping[str, str | None] | None = None) -> Instance
257
284
 
258
285
  ``overrides`` carries the global flags keyed by Instance field name;
259
286
  per key the first set value wins (flag, ACU_* var - process environment
260
- over a found .env - code default). No .env is fine (V3): the hard error
261
- comes only when a required value (base_url, password) is still
262
- unresolved after the merge, naming the missing key. ``ssh`` is optional
263
- (hosted / data-plane-only path); tenant cmds hard-error when it is empty.
287
+ over a found .env - code default). Exclusion ``api_version`` (V27/T125):
288
+ ``--api-version`` flag ? → else ``target.yaml`` ``default_api`` when
289
+ present → else ``DEFAULT_API_VERSION``; ``ACU_API_VERSION`` is ignored.
290
+ No .env is fine (V3): the hard error comes only when a required value
291
+ (base_url, password) is still unresolved after the merge, naming the
292
+ missing key. ``ssh`` defaults from the base_url host when the key is
293
+ absent; blank key = hosted path; tenant cmds hard-error when empty
294
+ post-default.
264
295
  """
265
296
  flags = {k: v for k, v in dict(overrides or {}).items() if v is not None}
266
297
  root = find_data_root()
298
+ # V27/T125: resolve api_version outside env. Init kwargs beat dotenv/env,
299
+ # so always inject — ACU_API_VERSION (valid or invalid) is ignored.
300
+ if "api_version" not in flags:
301
+ # Local import avoids config↔target cycle (target imports Instance).
302
+ from .target import load_target
303
+
304
+ target = load_target(root)
305
+ flags["api_version"] = (
306
+ target.default_api if target is not None else DEFAULT_API_VERSION
307
+ )
267
308
  env_file = root / ".env" if root is not None else None
268
309
  try:
269
310
  # _env_file is a real BaseSettings init override; the synthesized
@@ -70,7 +70,7 @@ BOOTSTRAP_ENDPOINT, BOOTSTRAP_ENTITIES = bootstrap.parse_endpoint(
70
70
  bootstrap.packaged_contract_xml()
71
71
  )
72
72
  # Dual-serve error text prefers the symbolic Default form (V20) so operators
73
- # do not read the code-default version as their configured ACU_API_VERSION.
73
+ # do not read the code-default version as their configured api_version.
74
74
  _DEFAULT_ENDPOINT_NAME = "Default"
75
75
  _SYMBOLIC_BOOTSTRAP = "bootstrap"
76
76
  _SYMBOLIC_DEFAULT = "default"
@@ -113,7 +113,7 @@ def resolve_endpoint(
113
113
  if not api_version:
114
114
  raise SystemExit(
115
115
  "endpoint: default requires a configured api_version "
116
- "(ACU_API_VERSION or --api-version)"
116
+ "(target.yaml default_api or --api-version)"
117
117
  )
118
118
  return f"{_DEFAULT_ENDPOINT_NAME}/{api_version}"
119
119
  return endpoint
@@ -233,7 +233,7 @@ def load_baseline(path: Path) -> BaselineFile | ActionFile:
233
233
  elif parsed.endpoint is None and parsed.entity in entities:
234
234
  raise SystemExit(
235
235
  f"{path}: entity '{parsed.entity}' is served by both "
236
- f"Default (use endpoint: default -> Default/<ACU_API_VERSION>) "
236
+ f"Default (use endpoint: default -> Default/<api_version>) "
237
237
  f"and the active {name} - add an explicit 'endpoint:' line to "
238
238
  f"pick one (literal or symbolic 'bootstrap' | 'default')"
239
239
  )
@@ -1,9 +1,10 @@
1
1
  """Dataset target matrix: committed verified ERP + Default API versions (V27).
2
2
 
3
3
  ``target.yaml`` is *what* (V2) — co-located with the data-repo root found by
4
- ``.env`` discovery. Never secrets. Present → hard-match ``default_api`` to
5
- ``Instance.api_version`` on allowlisted data-plane cmds and ``config check``;
6
- missing → warn on check unless ``--strict``; invalid → hard-fail any loader.
4
+ ``.env`` discovery. Never secrets. Present → ``load_instance`` sources
5
+ ``Instance.api_version`` from ``default_api`` when ``--api-version`` is
6
+ absent (source-merge; dual-source match gate retired — T125); missing →
7
+ warn on check unless ``--strict``; invalid → hard-fail any loader.
7
8
  """
8
9
 
9
10
  from pathlib import Path
@@ -81,20 +82,13 @@ def load_target(root: Path | None = None) -> DatasetTarget | None:
81
82
 
82
83
 
83
84
  def assert_target_compatible(inst: Instance, root: Path | None = None) -> None:
84
- """Hard-fail when target.yaml present and default_api mismatches api_version.
85
+ """Load target when present; invalid → hard-fail (V27).
85
86
 
86
- Missing target is not an error here (config check --strict owns that).
87
- Invalid target always SystemExit. Call only from the allowlisted
88
- data-plane commands (V27) — never from bare pass_instance.
87
+ Version mismatch gate retired (T125): ``load_instance`` source-merges
88
+ ``default_api`` into ``api_version`` when the flag is absent; flag
89
+ override is intentional for ad-hoc probes. Missing target is not an
90
+ error here (config check --strict owns that). Call only from the
91
+ allowlisted data-plane commands — never from bare pass_instance.
89
92
  """
90
- target = load_target(root)
91
- if target is None:
92
- return
93
- if target.default_api != inst.api_version:
94
- raise SystemExit(
95
- "Default API version mismatch:\n"
96
- f" dataset target (target.yaml): default_api={target.default_api}\n"
97
- f" configured (ACU_API_VERSION/--api-version): {inst.api_version}\n"
98
- f"Fix: set ACU_API_VERSION={target.default_api} to match this "
99
- f"dataset, or use a dataset verified for {inst.api_version}."
100
- )
93
+ del inst # kept for call-site stability; version match no longer used
94
+ load_target(root)
@@ -8,7 +8,8 @@ Tenant Factory class). Single full seed — no `--flavor`.
8
8
  ## Rebuild order
9
9
 
10
10
  ```sh
11
- # 1. Credentials in .env (ACU_PASSWORD, ACU_TENANT, …); keep ACU_API_VERSION in sync with target.yaml
11
+ # 1. Credentials in .env (ACU_PASSWORD, ACU_TENANT, …)
12
+ # Default API pin = committed target.yaml default_api (not .env)
12
13
  acu config check
13
14
 
14
15
  # 2. Publish Bootstrap (features + contract from config/bootstrap/)
@@ -0,0 +1,16 @@
1
+ # scaffolded by `acu config init` - the sole config file: where + secrets,
2
+ # every key an ACU_* var; print the fully resolved document with:
3
+ # acu config show
4
+ # ACU_BASE_URL (REST root) is required. Control-plane SSH is omitted on
5
+ # purpose: when the key is absent, acu defaults to Administrator@ plus the
6
+ # ACU_BASE_URL hostname (SSH boxes). Hosted / data-plane-only opt-out: add
7
+ # the SSH key with an empty value. Tenant CRUD still needs a non-empty
8
+ # value post-default.
9
+ # Default contract API pin lives in committed target.yaml (default_api),
10
+ # not here — never ACU_API_VERSION (see acu config check / --api-version).
11
+ # Keep this file out of git (the scaffolded .gitignore already lists it;
12
+ # encrypt at rest, e.g. .env.gpg).
13
+ ACU_BASE_URL=http://erp.example.com/AcumaticaERP
14
+ ACU_TENANT=
15
+ ACU_USER=admin
16
+ ACU_PASSWORD=
@@ -0,0 +1,7 @@
1
+ # scaffolded by `acu config init` — verified target matrix for this dataset.
2
+ # Edit when you re-verify against a new ERP build or Default API generation.
3
+ # default_api is the sole data-repo Default contract pin (version half only,
4
+ # never Default/<ver>). It sources Instance.api_version when --api-version is
5
+ # absent. There is no ACU_API_VERSION env pin.
6
+ erp: "26.101.0225"
7
+ default_api: "25.200.001"
@@ -1,17 +0,0 @@
1
- # scaffolded by `acu config init` - the sole config file: where + secrets,
2
- # every key an ACU_* var; print the fully resolved document with:
3
- # acu config show
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.
7
- # ACU_API_VERSION is the Default contract version half only (e.g. 25.200.001),
8
- # never "Default/25.200.001". Must match target.yaml default_api when that
9
- # file exists (see acu config check).
10
- # Keep this file out of git (the scaffolded .gitignore already lists it;
11
- # encrypt at rest, e.g. .env.gpg).
12
- ACU_BASE_URL=http://erp.example.com/AcumaticaERP
13
- ACU_SSH=Administrator@erp.example.com
14
- ACU_TENANT=
15
- ACU_API_VERSION=25.200.001
16
- ACU_USER=admin
17
- ACU_PASSWORD=
@@ -1,6 +0,0 @@
1
- # scaffolded by `acu config init` — verified target matrix for this dataset.
2
- # Edit when you re-verify against a new ERP build or Default API generation.
3
- # default_api is the version half only (ACU_API_VERSION spelling), not Default/<ver>.
4
- # Must match ACU_API_VERSION / --api-version (default 25.200.001) or live cmds fail.
5
- erp: "26.101.0225"
6
- default_api: "25.200.001"