acumatica-cli 0.9.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/PKG-INFO +50 -15
  2. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/README.md +49 -14
  3. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/pyproject.toml +1 -1
  4. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/cli.py +104 -17
  5. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/client.py +107 -23
  6. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/config.py +15 -2
  7. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/extract.py +4 -2
  8. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/seed.py +61 -11
  9. acumatica_cli-0.10.0/src/acumatica_cli/target.py +100 -0
  10. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/env +3 -0
  11. acumatica_cli-0.10.0/src/acumatica_cli/templates/target +6 -0
  12. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/__init__.py +0 -0
  13. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/bootstrap.py +0 -0
  14. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  15. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
  16. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/extract_manifest.yaml +0 -0
  17. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/firstlogin.py +0 -0
  18. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/models.py +0 -0
  19. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/output.py +0 -0
  20. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/run.py +0 -0
  21. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
  22. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
  23. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
  24. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +0 -0
  25. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +0 -0
  26. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
  27. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/bootstrap/company.yaml +0 -0
  28. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +0 -0
  29. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
  30. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/gitignore +0 -0
  31. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/setup/10-financial-year.yaml +0 -0
  32. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +0 -0
  33. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/setup/30-open-periods.yaml +0 -0
  34. {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/tenant.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: acumatica-cli
3
- Version: 0.9.0
3
+ Version: 0.10.0
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>
@@ -38,8 +38,9 @@ uv tool install acumatica-cli
38
38
 
39
39
  acu config init --host erp.example.com my-erp
40
40
  cd my-erp # edit .env: set ACU_PASSWORD
41
+ # keep ACU_API_VERSION in sync with target.yaml
41
42
 
42
- acu config check # read-only preflight
43
+ acu config check # read-only preflight (incl. target.yaml)
43
44
  acu tenant create --id 3 --login DEV # create the tenant + bootstrap it (needs SSH)
44
45
  acu --tenant DEV apply # seed bootstrap/, baseline/, setup/
45
46
  acu --tenant DEV diff # prove zero drift (exit 2 on drift)
@@ -79,9 +80,9 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
79
80
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
80
81
  │
81
82
  └── config configuration ops
82
- ├── init [--host HOST] [DIR] scaffold a data repo (.env plus example YAML)
83
+ ├── init [--host HOST] [DIR] scaffold a data repo (.env, target.yaml, example YAML)
83
84
  ├── show print the resolved config as a complete .env
84
- └── check read-only preflight: discovery, secrets, REST, SSH
85
+ └── check [--strict] preflight: discovery, secrets, target, REST, endpoints, SSH
85
86
  ```
86
87
 
87
88
  `apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`.
@@ -93,20 +94,36 @@ Run `acu <command> --help` for details on any command.
93
94
 
94
95
  Your configuration lives in its own git repo. `acu config init` scaffolds everything except `scenario/`, which you author by hand:
95
96
 
96
- | Path | What it holds |
97
- | ------------ | -------------------------------------------------------------------------- |
98
- | `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
99
- | `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
100
- | `setup/` | one-time actions: financial year, master calendar, open periods |
101
- | `scenario/` | transaction scenarios for `acu run`: purchase, build, sell flows |
102
- | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
97
+ | Path | What it holds |
98
+ | ------------- | -------------------------------------------------------------------------- |
99
+ | `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
100
+ | `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
101
+ | `setup/` | one-time actions: financial year, master calendar, open periods |
102
+ | `scenario/` | transaction scenarios for `acu run`: purchase, build, sell flows |
103
+ | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
104
+ | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
103
105
 
104
- 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.
106
+ Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order.
107
+ The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
108
+ Commit `target.yaml` with the seeds so every clone knows the verified ERP line and Default API generation.
105
109
 
106
110
  Seed YAML in `bootstrap/`, `baseline/`, and `setup/` is state: `apply` upserts it, `diff` proves it.
107
111
  Scenario YAML is different — it describes transactions that flow forward.
108
112
  `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.
109
113
 
114
+ ### Seed `endpoint:` symbols
115
+
116
+ Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
117
+
118
+ | Value | Resolves to |
119
+ | ----- | ----------- |
120
+ | omitted | `Default/<ACU_API_VERSION>` for Default-only entities |
121
+ | `bootstrap` | active `Bootstrap/<ver>` from `bootstrap/project.xml` or the packaged contract |
122
+ | `default` | `Default/<ACU_API_VERSION>` — tracks the operator API version |
123
+ | `Bootstrap/1.9.0` or `Default/25.200.001` | literal pin (ignores `ACU_API_VERSION` for Default) |
124
+
125
+ Prefer symbolic `default` over a pinned `Default/25.200.001` so the seed tree travels with the configured API generation.
126
+
110
127
  ## Installation
111
128
 
112
129
  Requires Python 3.14 or newer.
@@ -125,16 +142,32 @@ Verify with `acu --version`.
125
142
 
126
143
  ## Configuration
127
144
 
128
- Everything lives in one `.env` file: *where* to apply and *who* signs in. Three values are required; everything else has a code default matching a stock Acumatica install:
145
+ Everything lives in one `.env` file: *where* to apply and *who* signs in.
146
+ Three values are required; everything else has a code default matching a stock Acumatica install:
129
147
 
130
148
  ```sh
131
149
  ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # required: REST root
132
150
  ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use
133
151
  ACU_SSH=Administrator@acu-dev1.vm.internal # optional: control-plane user@host (tenant CRUD)
152
+ ACU_API_VERSION=25.200.001 # Default contract version half only
134
153
  ACU_USER=admin # optional, defaults to admin
135
154
  ACU_PASSWORD=... # required for live commands
136
155
  ```
137
156
 
157
+ `ACU_API_VERSION` is the version half only (`25.200.001`), never `Default/25.200.001`.
158
+ A full path would nest as `/entity/Default/Default/...`.
159
+
160
+ The committed `target.yaml` next to `.env` declares the verified matrix (what, not where):
161
+
162
+ ```yaml
163
+ erp: "26.101.0225" # claimed product line/build (README-level detail)
164
+ default_api: "25.200.001" # must match ACU_API_VERSION
165
+ ```
166
+
167
+ When `target.yaml` is present, `apply` / `diff` / `run` / `extract` / `schema` / `bootstrap` hard-fail if `default_api` does not match the configured API version.
168
+ `acu config check` reports the same match as a probe line.
169
+ Missing `target.yaml` only warns on check unless you pass `--strict`.
170
+
138
171
  Worth knowing:
139
172
 
140
173
  - The file is found by walking up from the current directory, so any subdirectory of the data repo works.
@@ -143,13 +176,15 @@ Worth knowing:
143
176
  - Leave it blank on hosted instances; only `acu tenant` needs it.
144
177
  - 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.
145
178
  - `acu config show` prints the fully resolved configuration as a complete, valid `.env` — every knob visible, the password excluded.
179
+ - When `target.yaml` is present, `config show` also comments `erp` / `default_api`.
146
180
  - Redirect it to turn resolved state into a working config: `acu config show > .env`.
147
181
 
148
182
  Verify before touching anything live:
149
183
 
150
184
  ```sh
151
- acu config check # read-only preflight: discovery, secrets, REST, SSH
152
- acu apply --dry-run # show what would be written, write nothing
185
+ acu config check # discovery, secrets, target, REST, endpoints, SSH
186
+ acu config check --strict # missing target.yaml becomes fail
187
+ acu apply --dry-run # show what would be written, write nothing
153
188
  ```
154
189
 
155
190
  ## Control and Data Planes
@@ -21,8 +21,9 @@ 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
24
+ # keep ACU_API_VERSION in sync with target.yaml
24
25
 
25
- acu config check # read-only preflight
26
+ acu config check # read-only preflight (incl. target.yaml)
26
27
  acu tenant create --id 3 --login DEV # create the tenant + bootstrap it (needs SSH)
27
28
  acu --tenant DEV apply # seed bootstrap/, baseline/, setup/
28
29
  acu --tenant DEV diff # prove zero drift (exit 2 on drift)
@@ -62,9 +63,9 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
62
63
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
63
64
  │
64
65
  └── config configuration ops
65
- ├── init [--host HOST] [DIR] scaffold a data repo (.env plus example YAML)
66
+ ├── init [--host HOST] [DIR] scaffold a data repo (.env, target.yaml, example YAML)
66
67
  ├── show print the resolved config as a complete .env
67
- └── check read-only preflight: discovery, secrets, REST, SSH
68
+ └── check [--strict] preflight: discovery, secrets, target, REST, endpoints, SSH
68
69
  ```
69
70
 
70
71
  `apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`.
@@ -76,20 +77,36 @@ Run `acu <command> --help` for details on any command.
76
77
 
77
78
  Your configuration lives in its own git repo. `acu config init` scaffolds everything except `scenario/`, which you author by hand:
78
79
 
79
- | Path | What it holds |
80
- | ------------ | -------------------------------------------------------------------------- |
81
- | `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
82
- | `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
83
- | `setup/` | one-time actions: financial year, master calendar, open periods |
84
- | `scenario/` | transaction scenarios for `acu run`: purchase, build, sell flows |
85
- | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
80
+ | Path | What it holds |
81
+ | ------------- | -------------------------------------------------------------------------- |
82
+ | `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
83
+ | `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
84
+ | `setup/` | one-time actions: financial year, master calendar, open periods |
85
+ | `scenario/` | transaction scenarios for `acu run`: purchase, build, sell flows |
86
+ | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
87
+ | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
86
88
 
87
- 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.
89
+ Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order.
90
+ The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
91
+ Commit `target.yaml` with the seeds so every clone knows the verified ERP line and Default API generation.
88
92
 
89
93
  Seed YAML in `bootstrap/`, `baseline/`, and `setup/` is state: `apply` upserts it, `diff` proves it.
90
94
  Scenario YAML is different — it describes transactions that flow forward.
91
95
  `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.
92
96
 
97
+ ### Seed `endpoint:` symbols
98
+
99
+ Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
100
+
101
+ | Value | Resolves to |
102
+ | ----- | ----------- |
103
+ | omitted | `Default/<ACU_API_VERSION>` for Default-only entities |
104
+ | `bootstrap` | active `Bootstrap/<ver>` from `bootstrap/project.xml` or the packaged contract |
105
+ | `default` | `Default/<ACU_API_VERSION>` — tracks the operator API version |
106
+ | `Bootstrap/1.9.0` or `Default/25.200.001` | literal pin (ignores `ACU_API_VERSION` for Default) |
107
+
108
+ Prefer symbolic `default` over a pinned `Default/25.200.001` so the seed tree travels with the configured API generation.
109
+
93
110
  ## Installation
94
111
 
95
112
  Requires Python 3.14 or newer.
@@ -108,16 +125,32 @@ Verify with `acu --version`.
108
125
 
109
126
  ## Configuration
110
127
 
111
- Everything lives in one `.env` file: *where* to apply and *who* signs in. Three values are required; everything else has a code default matching a stock Acumatica install:
128
+ Everything lives in one `.env` file: *where* to apply and *who* signs in.
129
+ Three values are required; everything else has a code default matching a stock Acumatica install:
112
130
 
113
131
  ```sh
114
132
  ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # required: REST root
115
133
  ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use
116
134
  ACU_SSH=Administrator@acu-dev1.vm.internal # optional: control-plane user@host (tenant CRUD)
135
+ ACU_API_VERSION=25.200.001 # Default contract version half only
117
136
  ACU_USER=admin # optional, defaults to admin
118
137
  ACU_PASSWORD=... # required for live commands
119
138
  ```
120
139
 
140
+ `ACU_API_VERSION` is the version half only (`25.200.001`), never `Default/25.200.001`.
141
+ A full path would nest as `/entity/Default/Default/...`.
142
+
143
+ The committed `target.yaml` next to `.env` declares the verified matrix (what, not where):
144
+
145
+ ```yaml
146
+ erp: "26.101.0225" # claimed product line/build (README-level detail)
147
+ default_api: "25.200.001" # must match ACU_API_VERSION
148
+ ```
149
+
150
+ When `target.yaml` is present, `apply` / `diff` / `run` / `extract` / `schema` / `bootstrap` hard-fail if `default_api` does not match the configured API version.
151
+ `acu config check` reports the same match as a probe line.
152
+ Missing `target.yaml` only warns on check unless you pass `--strict`.
153
+
121
154
  Worth knowing:
122
155
 
123
156
  - The file is found by walking up from the current directory, so any subdirectory of the data repo works.
@@ -126,13 +159,15 @@ Worth knowing:
126
159
  - Leave it blank on hosted instances; only `acu tenant` needs it.
127
160
  - 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.
128
161
  - `acu config show` prints the fully resolved configuration as a complete, valid `.env` — every knob visible, the password excluded.
162
+ - When `target.yaml` is present, `config show` also comments `erp` / `default_api`.
129
163
  - Redirect it to turn resolved state into a working config: `acu config show > .env`.
130
164
 
131
165
  Verify before touching anything live:
132
166
 
133
167
  ```sh
134
- acu config check # read-only preflight: discovery, secrets, REST, SSH
135
- acu apply --dry-run # show what would be written, write nothing
168
+ acu config check # discovery, secrets, target, REST, endpoints, SSH
169
+ acu config check --strict # missing target.yaml becomes fail
170
+ acu apply --dry-run # show what would be written, write nothing
136
171
  ```
137
172
 
138
173
  ## Control and Data Planes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.9.0"
3
+ version = "0.10.0"
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"
@@ -22,6 +22,7 @@ from .config import (
22
22
  read_env_values,
23
23
  scaffold,
24
24
  )
25
+ from .target import assert_target_compatible, load_target
25
26
  from .tenant import TenantManager
26
27
 
27
28
 
@@ -370,6 +371,7 @@ def bootstrap_cmd(ctx: click.Context, export_path: Path | None) -> None:
370
371
  output.success(f"wrote {export_path}")
371
372
  return
372
373
  inst = _resolve_instance(ctx)
374
+ assert_target_compatible(inst)
373
375
  if not inst.tenant:
374
376
  raise SystemExit(
375
377
  "tenant not set (pass --tenant, "
@@ -433,29 +435,45 @@ def config_show(inst: Instance) -> None:
433
435
  so the printed values are exactly what a live command would trust -
434
436
  global flag overrides (--url, --ssh, ...) included. The password is
435
437
  never emitted in any form (V2): no ACU_PASSWORD key, no value.
436
- Redirect to a file and edit: the output loads back through
437
- load_instance unchanged, the password supplied out of band.
438
+ When target.yaml is present, surfaces erp/default_api as comments
439
+ (mismatch noted, still exit 0 — no hard gate). Redirect to a file and
440
+ edit: the output loads back through load_instance unchanged, the
441
+ password supplied out of band.
438
442
  """
439
443
  output.data("# resolved by `acu config show` - a complete .env")
440
444
  output.data("# ACU_PASSWORD comes from .env or the environment, never from here")
441
445
  for field, value in inst.model_dump(exclude={"password"}).items():
442
446
  output.data(f"ACU_{field.upper()}={value}")
447
+ target = load_target()
448
+ if target is not None:
449
+ output.data(f"# target.yaml: erp={target.erp} default_api={target.default_api}")
450
+ if target.default_api != inst.api_version:
451
+ output.data(
452
+ f"# warn: default_api={target.default_api} != "
453
+ f"ACU_API_VERSION={inst.api_version}"
454
+ )
443
455
 
444
456
 
445
457
  @config_group.command("check")
458
+ @click.option(
459
+ "--strict",
460
+ is_flag=True,
461
+ help="Promote warn classes (missing target.yaml) to fail",
462
+ )
446
463
  @click.pass_context
447
- def config_check(ctx: click.Context) -> None:
448
- """Read-only preflight of the resolved target, one ok/fail line per probe.
464
+ def config_check(ctx: click.Context, strict: bool) -> None:
465
+ """Read-only preflight of the resolved target, one ok/fail/warn/skip line.
449
466
 
450
467
  Dependency order: discovery (.env walk-up + parse + ACU_BASE_URL), then
451
- secrets (ACU_PASSWORD resolved), then REST (login, landed-tenant verify,
452
- logout) and ssh probed independently - ssh set → trivial remote; ssh
453
- unset → skip (ACU_SSH optional, V3 hosted path), never fail. A discovery
454
- or secrets failure stops; a REST failure still probes ssh when set and
455
- vice versa. Discovery is lax (V3): no .env passes when --url covers
456
- base_url, and flags-only runs (no .env anywhere) are valid. Writes
457
- nothing: no PUTs, no tenant CRUD. Exit 0 when every non-skipped probe
458
- passes, 1 on any failure.
468
+ secrets (ACU_PASSWORD resolved), then local target.yaml probe, then REST
469
+ (login, landed-tenant verify, logout) and ssh probed independently -
470
+ ssh set → trivial remote; ssh unset → skip (ACU_SSH optional, V3 hosted
471
+ path), never fail. A discovery or secrets failure stops; a REST failure
472
+ still probes ssh when set and vice versa. Discovery is lax (V3): no
473
+ .env passes when --url covers base_url, and flags-only runs (no .env
474
+ anywhere) are valid. Writes nothing: no PUTs, no tenant CRUD. Exit 0
475
+ when no fail line (warns allowed); --strict promotes missing target to
476
+ fail; exit 1 on any failure.
459
477
  """
460
478
  overrides: dict[str, str] = ctx.obj or {}
461
479
  # discovery (V3): lax walk-up + parse; base_url (the primary identity
@@ -493,13 +511,15 @@ def config_check(ctx: click.Context) -> None:
493
511
  # both live probes run through the exact objects live commands use, so
494
512
  # a pass here proves the real code path, not a parallel one
495
513
  inst = _resolve_instance(ctx)
496
- failed = False
514
+ failed = _probe_target(root, inst, strict=strict)
497
515
  try:
498
516
  # entering the client is the whole probe: login + landed-tenant
499
- # verify (V5), and the context manager guarantees logout (V6)
500
- with AcumaticaClient(inst):
501
- pass
502
- output.data(f"ok rest ({inst.base_url}, tenant {inst.tenant})")
517
+ # verify (V5), and the context manager guarantees logout (V6);
518
+ # endpoints probe (T74) reuses the same session when login succeeds
519
+ with AcumaticaClient(inst) as client:
520
+ output.data(f"ok rest ({inst.base_url}, tenant {inst.tenant})")
521
+ if not _probe_endpoints(client, inst):
522
+ failed = True
503
523
  except (RuntimeError, httpx.HTTPError) as exc:
504
524
  output.data(f"fail rest: {exc}")
505
525
  failed = True
@@ -517,6 +537,68 @@ def config_check(ctx: click.Context) -> None:
517
537
  raise SystemExit(1)
518
538
 
519
539
 
540
+ def _probe_target(root: Path | None, inst: Instance, *, strict: bool) -> bool:
541
+ """Emit the local target probe line; return True when it failed (V27).
542
+
543
+ Invalid target hard-exits (any loader). Missing under data root warns
544
+ unless --strict. No data root → skip. Match → ok (erp claimed-only).
545
+ """
546
+ try:
547
+ target = load_target(root)
548
+ except SystemExit as exc:
549
+ output.data(f"fail target: {exc}")
550
+ raise SystemExit(1) from exc
551
+ if root is None:
552
+ output.data("skip target (no data root)")
553
+ return False
554
+ if target is None:
555
+ msg = (
556
+ f"target: no target.yaml under {root} - dataset verified matrix "
557
+ "unknown; add target.yaml (see acu config init) or pass --strict "
558
+ "to require it"
559
+ )
560
+ output.data(f"{'fail' if strict else 'warn'} {msg}")
561
+ return strict
562
+ if target.default_api != inst.api_version:
563
+ output.data(
564
+ f"fail target: dataset default_api={target.default_api} vs "
565
+ f"configured {inst.api_version}"
566
+ )
567
+ return True
568
+ output.data(
569
+ f"ok target (default_api={target.default_api} matches configured; "
570
+ f"erp={target.erp} claimed)"
571
+ )
572
+ # T76: no stable HTTP ERP-build discovery (V12) — keep erp claimed-only;
573
+ # skip rather than invent SSH/sqlcmd (control-plane exclusion, V1)
574
+ output.data(f"skip erp (live probe not available; claimed {target.erp})")
575
+ return False
576
+
577
+
578
+ def _probe_endpoints(client: AcumaticaClient, inst: Instance) -> bool:
579
+ """Emit endpoints probe; return True on pass, False on fail (V12/V27).
580
+
581
+ Exact match: a Default entry whose version half equals
582
+ ``Instance.api_version``. Fail-closed when GET /entity is unparseable.
583
+ """
584
+ want = f"Default/{inst.api_version}"
585
+ try:
586
+ endpoints = client.list_endpoints()
587
+ except (RuntimeError, httpx.HTTPError) as exc:
588
+ output.data(f"fail endpoints: {exc}")
589
+ return False
590
+ defaults = [v for name, v in endpoints if name == "Default"]
591
+ if inst.api_version in defaults:
592
+ output.data(f"ok endpoints ({want} present)")
593
+ return True
594
+ present = ", ".join(f"Default/{v}" for v in defaults) or "(none)"
595
+ output.data(
596
+ f"fail endpoints: configured {want} not listed; "
597
+ f"present Default versions: {present}"
598
+ )
599
+ return False
600
+
601
+
520
602
  SEED_DIRS = ("bootstrap", "baseline", "setup")
521
603
 
522
604
 
@@ -571,6 +653,7 @@ def apply_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
571
653
  they default to the data repo's existing init-scaffolded directories in
572
654
  fixed order: bootstrap/, baseline/, setup/.
573
655
  """
656
+ assert_target_compatible(inst)
574
657
  with AcumaticaClient(inst) as client:
575
658
  for path in expand_files(files or default_seed_dirs()):
576
659
  baseline = seed.load_baseline(path)
@@ -596,6 +679,7 @@ def schema_cmd(inst: Instance, out_dir: Path | None) -> None:
596
679
  The schema is the authoritative field-level reference for the exact
597
680
  build - regenerate rather than version (the file is ~3 MB).
598
681
  """
682
+ assert_target_compatible(inst)
599
683
  if out_dir is None:
600
684
  out_dir = data_root() / "schemas"
601
685
  out_file = out_dir / f"swagger-Default-{inst.api_version}.json"
@@ -623,6 +707,7 @@ def diff_cmd(inst: Instance, files: tuple[Path, ...]) -> None:
623
707
  they default to the data repo's existing init-scaffolded directories in
624
708
  fixed order: bootstrap/, baseline/, setup/.
625
709
  """
710
+ assert_target_compatible(inst)
626
711
  paths = expand_files(files or default_seed_dirs())
627
712
  drifts: list[str] = []
628
713
  with AcumaticaClient(inst) as client:
@@ -659,6 +744,7 @@ def run_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
659
744
  last, so a scenario re-runs safely on a warm tenant. Exit 0 when every
660
745
  expectation holds, 1 on any step error or expectation miss.
661
746
  """
747
+ assert_target_compatible(inst)
662
748
  if not files:
663
749
  default = data_root() / "scenario"
664
750
  if not default.is_dir():
@@ -731,6 +817,7 @@ def extract_cmd(
731
817
  when every row wrote or skipped clean, 1 when any row failed - drift
732
818
  detection stays with diff.
733
819
  """
820
+ assert_target_compatible(inst)
734
821
  with AcumaticaClient(inst) as client:
735
822
  failed = extract.run(
736
823
  client,
@@ -62,6 +62,49 @@ def unwrap(entity: dict[str, Any]) -> dict[str, Any]:
62
62
  return out
63
63
 
64
64
 
65
+ def parse_entity_list(response: httpx.Response) -> list[tuple[str, str]]:
66
+ """Parse ``GET /entity`` into ``[(name, version), ...]`` (fail-closed).
67
+
68
+ Vendor contract shape (Acumatica help + docs/rest-api.md): a JSON array
69
+ of objects with ``name`` and ``version`` strings. Unparseable body →
70
+ RuntimeError with status, content-type, and a short raw hint so a
71
+ shape change is re-verified (V12) rather than silently skipped.
72
+ """
73
+ try:
74
+ body = response.json()
75
+ except Exception as exc:
76
+ hint = response.text[:200].replace("\n", " ")
77
+ raise RuntimeError(
78
+ "GET /entity response not parseable as endpoint list "
79
+ f"(status {response.status_code}; "
80
+ f"content-type {response.headers.get('content-type', '?')}; "
81
+ f"first 200 chars: {hint})"
82
+ ) from exc
83
+ if not isinstance(body, list) or not body:
84
+ hint = str(body)[:200]
85
+ raise RuntimeError(
86
+ "GET /entity response not parseable as endpoint list "
87
+ f"(status {response.status_code}; expected non-empty JSON array; "
88
+ f"first 200 chars: {hint})"
89
+ )
90
+ out: list[tuple[str, str]] = []
91
+ for item in body:
92
+ if not isinstance(item, dict):
93
+ raise RuntimeError(
94
+ "GET /entity response not parseable as endpoint list "
95
+ f"(row is not an object: {item!r})"
96
+ )
97
+ name = item.get("name")
98
+ version = item.get("version")
99
+ if not isinstance(name, str) or not isinstance(version, str):
100
+ raise RuntimeError(
101
+ "GET /entity response not parseable as endpoint list "
102
+ f"(row missing string name/version: {item!r})"
103
+ )
104
+ out.append((name, version))
105
+ return out
106
+
107
+
65
108
  # The list GET's optimized-export failure (B9): the contract API's list GET
66
109
  # 500s with this marker when any field in scope maps to a BQL-delegate view.
67
110
  # The one error read paths retry around (seed.diff via the key-URL GET,
@@ -93,6 +136,9 @@ class AcumaticaClient:
93
136
  self._http = httpx.Client(
94
137
  base_url=instance.base_url, timeout=timeout, transport=transport
95
138
  )
139
+ # V5/B24: first successful Company PUT this session → one re-login
140
+ # so later branch selectors resolve; warm re-PUT is re-login-safe
141
+ self._refreshed_after_company = False
96
142
 
97
143
  def __enter__(self) -> AcumaticaClient:
98
144
  # tenant guard (V5, docs/rest-api.md): an omitted or empty tenant is
@@ -105,27 +151,10 @@ class AcumaticaClient:
105
151
  "an explicit tenant silently lands on the default tenant; "
106
152
  "set ACU_TENANT in .env or pass --tenant"
107
153
  )
108
- creds: dict[str, str] = {
109
- "name": self.instance.user,
110
- "password": self.instance.password,
111
- "tenant": self.instance.tenant,
112
- }
113
- self._checked(self._http.post("/entity/auth/login", json=creds))
114
- # tenant guard, landed side (V5, B5): login accepting the name proves
115
- # nothing - a stale tenant map reroutes named logins to the default
116
- # tenant, and a single-tenant instance accepts ANY name (both verified
117
- # live, docs/rest-api.md). Verify where the session actually landed
118
- # and refuse on mismatch; logout first, since __exit__ never runs
119
- # when __enter__ raises (V6 - sessions count against the license).
154
+ # logout first on failure: __exit__ never runs when __enter__ raises
155
+ # (V6 - sessions count against the license)
120
156
  try:
121
- landed = self._landed_tenant()
122
- if landed.casefold() != self.instance.tenant.casefold():
123
- raise RuntimeError(
124
- f"tenant guard: asked for tenant {self.instance.tenant!r} "
125
- f"but the session landed on {landed!r} - the instance "
126
- "tenant map is stale or the tenant does not exist; check "
127
- "acu tenant list and recycle the app pool"
128
- )
157
+ self._login()
129
158
  except BaseException:
130
159
  self.__exit__()
131
160
  raise
@@ -138,6 +167,50 @@ class AcumaticaClient:
138
167
  finally:
139
168
  self._http.close()
140
169
 
170
+ def _login(self) -> None:
171
+ """POST login + refuse on landed-tenant mismatch (V5, B5).
172
+
173
+ Login accepting the name proves nothing - a stale tenant map
174
+ reroutes named logins to the default tenant, and a single-tenant
175
+ instance accepts ANY name (both verified live, docs/rest-api.md).
176
+ """
177
+ creds: dict[str, str] = {
178
+ "name": self.instance.user,
179
+ "password": self.instance.password,
180
+ "tenant": self.instance.tenant,
181
+ }
182
+ self._checked(self._http.post("/entity/auth/login", json=creds))
183
+ landed = self._landed_tenant()
184
+ if landed.casefold() != self.instance.tenant.casefold():
185
+ raise RuntimeError(
186
+ f"tenant guard: asked for tenant {self.instance.tenant!r} "
187
+ f"but the session landed on {landed!r} - the instance "
188
+ "tenant map is stale or the tenant does not exist; check "
189
+ "acu tenant list and recycle the app pool"
190
+ )
191
+
192
+ def relogin(self) -> None:
193
+ """Drop the cookie session and open a new one without closing transport.
194
+
195
+ Mid-session Company create leaves branch selectors empty until a
196
+ fresh login (V5, B24). Unlike ``__exit__``, the httpx client stays
197
+ open so the outer context manager still owns the session lifecycle.
198
+ """
199
+ # empty body → Content-Length: 0 (V6 / IIS 411)
200
+ self._http.post("/entity/auth/logout", content=b"")
201
+ self._login()
202
+
203
+ def refresh_after_company(self) -> None:
204
+ """Re-login once after the first successful Company PUT this session.
205
+
206
+ Warm-tenant Company re-PUT is re-login-safe (V5); further Company
207
+ records in the same run skip the second bounce.
208
+ """
209
+ if self._refreshed_after_company:
210
+ return
211
+ self.relogin()
212
+ self._refreshed_after_company = True
213
+
141
214
  def _landed_tenant(self) -> str:
142
215
  """The tenant this session actually landed on (login name).
143
216
 
@@ -160,9 +233,20 @@ class AcumaticaClient:
160
233
  return html.unescape(m.group(1))
161
234
 
162
235
  def _url(self, entity: str, endpoint: str | None = None) -> str:
163
- # V11: versioned path only; the endpoint-name half is hardcoded
164
- # Default - custom endpoints arrive per call (seed `endpoint:`)
165
- return f"/entity/{endpoint or f'Default/{self.instance.api_version}'}/{entity}"
236
+ # V11: versioned path only. Omitted or symbolic "default" (V20) →
237
+ # Default/<Instance.api_version> so dual-serve seeds track the
238
+ # configured API generation; literals pass through unchanged.
239
+ if endpoint is None or endpoint == "default":
240
+ endpoint = f"Default/{self.instance.api_version}"
241
+ return f"/entity/{endpoint}/{entity}"
242
+
243
+ def list_endpoints(self) -> list[tuple[str, str]]:
244
+ """Authenticated ``GET /entity`` → ``[(name, version), ...]`` (V12).
245
+
246
+ Fail-closed on unparseable body — see ``parse_entity_list``.
247
+ """
248
+ r = self._checked(self._http.get("/entity"))
249
+ return parse_entity_list(r)
166
250
 
167
251
  @staticmethod
168
252
  def _checked(r: httpx.Response) -> httpx.Response:
@@ -34,6 +34,7 @@ DB_NAME = "AcumaticaDB"
34
34
  INIT_TEMPLATES = (
35
35
  ("env", ".env"),
36
36
  ("gitignore", ".gitignore"),
37
+ ("target", "target.yaml"),
37
38
  ("baseline/10-subaccounts.yaml", "baseline/10-subaccounts.yaml"),
38
39
  ("baseline/20-accounts.yaml", "baseline/20-accounts.yaml"),
39
40
  ("baseline/40-ledger.yaml", "baseline/40-ledger.yaml"),
@@ -93,8 +94,20 @@ class Instance(BaseSettings):
93
94
 
94
95
  @field_validator("api_version")
95
96
  @classmethod
96
- def _no_surrounding_slashes(cls, v: str) -> str:
97
- return v.strip("/")
97
+ def _api_version_half_only(cls, v: str) -> str:
98
+ # V11: version half only (e.g. 25.200.001). A full path like
99
+ # Default/25.200.001 would nest as /entity/Default/Default/...
100
+ v = v.strip().strip("/")
101
+ if not v:
102
+ raise ValueError(
103
+ "api_version must be the version half only (e.g. 25.200.001)"
104
+ )
105
+ if "/" in v or v.lower().startswith("default"):
106
+ raise ValueError(
107
+ "api_version must be the version half only "
108
+ f"(e.g. 25.200.001), not a path like Default/{v}"
109
+ )
110
+ return v
98
111
 
99
112
 
100
113
  def scaffold(directory: Path, host: str | None = None) -> Iterator[tuple[str, Path]]:
@@ -141,7 +141,7 @@ def _fetch(client: AcumaticaClient, spec: EntitySpec) -> list[dict[str, Any]]:
141
141
  A manifest filter rides both list reads, so the two paths serve the
142
142
  same record set and the per-key walk only visits filtered keys.
143
143
  """
144
- endpoint = resolve_endpoint(spec.endpoint)
144
+ endpoint = resolve_endpoint(spec.endpoint, api_version=client.instance.api_version)
145
145
  narrowed = {"$filter": spec.filter} if spec.filter else {}
146
146
  try:
147
147
  return client.get_list(spec.entity, params=narrowed or None, endpoint=endpoint)
@@ -418,7 +418,9 @@ class _Extraction:
418
418
  # the active package does not serve cannot be read - skip clean
419
419
  # rather than 404 (full company surface lives in the data-repo
420
420
  # contract; minimal packaged fallback has config-init only).
421
- resolved = resolve_endpoint(spec.endpoint)
421
+ resolved = resolve_endpoint(
422
+ spec.endpoint, api_version=self.client.instance.api_version
423
+ )
422
424
  if resolved == active_name and spec.entity not in active_entities:
423
425
  self._skip(target, "entity not in active Bootstrap contract")
424
426
  continue
@@ -60,7 +60,7 @@ from pydantic import Field, ValidationError, field_validator, model_validator
60
60
 
61
61
  from . import bootstrap, output
62
62
  from .client import OPTIMIZATION_500, AcumaticaClient, unwrap
63
- from .config import Instance, find_data_root
63
+ from .config import find_data_root
64
64
  from .models import Model, validation_summary
65
65
 
66
66
  # Packaged-fallback defaults (no data-repo contract). Prefer active_bootstrap()
@@ -68,10 +68,15 @@ from .models import Model, validation_summary
68
68
  BOOTSTRAP_ENDPOINT, BOOTSTRAP_ENTITIES = bootstrap.parse_endpoint(
69
69
  bootstrap.packaged_contract_xml()
70
70
  )
71
- # The code-default instance endpoint, for the V20 error message - read off
72
- # the Instance field default rather than hand-synced (V11: one spelling).
73
- _DEFAULT_ENDPOINT: str = f"Default/{Instance.model_fields['api_version'].default}"
71
+ # Dual-serve error text prefers the symbolic Default form (V20) so operators
72
+ # do not read the code-default version as their configured ACU_API_VERSION.
73
+ _DEFAULT_ENDPOINT_NAME = "Default"
74
74
  _SYMBOLIC_BOOTSTRAP = "bootstrap"
75
+ _SYMBOLIC_DEFAULT = "default"
76
+ # Mid-session branch-selector failure (V5/B24): virgin-tenant apply opens
77
+ # the REST session pre-Company; INPreferences TransitBranchID (and kin)
78
+ # 500 with this message until a fresh login sees the new branch.
79
+ _BRANCH_EMPTY = "'Branch' cannot be empty"
75
80
 
76
81
 
77
82
  def active_bootstrap(root: Path | None = None) -> tuple[str, frozenset[str]]:
@@ -86,10 +91,30 @@ def active_bootstrap(root: Path | None = None) -> tuple[str, frozenset[str]]:
86
91
  return bootstrap.parse_endpoint(bootstrap.load_contract_xml(root))
87
92
 
88
93
 
89
- def resolve_endpoint(endpoint: str | None, root: Path | None = None) -> str | None:
90
- """Map symbolic ``bootstrap`` to the active ``Bootstrap/<ver>`` (V20)."""
94
+ def resolve_endpoint(
95
+ endpoint: str | None,
96
+ root: Path | None = None,
97
+ *,
98
+ api_version: str | None = None,
99
+ ) -> str | None:
100
+ """Map symbolic endpoint names to versioned paths (V20).
101
+
102
+ ``bootstrap`` → active ``Bootstrap/<ver>`` from contract XML (load-time
103
+ what). ``default`` → ``Default/<api_version>``; ``api_version`` required
104
+ when endpoint is default. Other values pass through (literals or None).
105
+
106
+ Apply/diff primarily resolve symbolic default in ``client._url``; this
107
+ helper serves extract pre-HTTP path building and offline unit tests.
108
+ """
91
109
  if endpoint == _SYMBOLIC_BOOTSTRAP:
92
110
  return active_bootstrap(root)[0]
111
+ if endpoint == _SYMBOLIC_DEFAULT:
112
+ if not api_version:
113
+ raise SystemExit(
114
+ "endpoint: default requires a configured api_version "
115
+ "(ACU_API_VERSION or --api-version)"
116
+ )
117
+ return f"{_DEFAULT_ENDPOINT_NAME}/{api_version}"
93
118
  return endpoint
94
119
 
95
120
 
@@ -186,6 +211,8 @@ def load_baseline(path: Path) -> BaselineFile | ActionFile:
186
211
 
187
212
  Symbolic ``endpoint: bootstrap`` resolves to the active package version
188
213
  at load (data-repo contract when present, else packaged minimal — V20).
214
+ Symbolic ``endpoint: default`` stays on the model and resolves at HTTP
215
+ time via ``client._url`` (never load-rewritten — version is where).
189
216
  An entity the active Bootstrap contract serves still needs an explicit
190
217
  endpoint (literal or symbolic); silent Default-endpoint PUTs are the
191
218
  B8 class.
@@ -204,10 +231,10 @@ def load_baseline(path: Path) -> BaselineFile | ActionFile:
204
231
  parsed = parsed.model_copy(update={"endpoint": name})
205
232
  elif parsed.endpoint is None and parsed.entity in entities:
206
233
  raise SystemExit(
207
- f"{path}: entity '{parsed.entity}' is served by both the instance "
208
- f"default endpoint ({_DEFAULT_ENDPOINT}) and the active "
209
- f"{name} - add an explicit 'endpoint:' line to pick one "
210
- f"(literal or symbolic 'bootstrap')"
234
+ f"{path}: entity '{parsed.entity}' is served by both "
235
+ f"Default (use endpoint: default -> Default/<ACU_API_VERSION>) "
236
+ f"and the active {name} - add an explicit 'endpoint:' line to "
237
+ f"pick one (literal or symbolic 'bootstrap' | 'default')"
211
238
  )
212
239
  return parsed
213
240
 
@@ -303,6 +330,10 @@ def apply(
303
330
  ) -> int:
304
331
  """PUT every record (upsert by key); an action file invokes its action.
305
332
 
333
+ After the first successful Company PUT this session, re-login once so
334
+ branch selectors resolve for later files (V5/B24). A PUT that still
335
+ 500s with ``'Branch' cannot be empty`` gets one re-login + retry.
336
+
306
337
  Returns the record count.
307
338
  """
308
339
  if isinstance(baseline, ActionFile):
@@ -315,11 +346,30 @@ def apply(
315
346
  body = record
316
347
  if any(isinstance(v, list) for v in record.values()):
317
348
  body = _with_detail_ids(client, baseline, record)
318
- client.put(baseline.entity, body, endpoint=baseline.endpoint)
349
+ _put(client, baseline.entity, body, baseline.endpoint)
350
+ if baseline.entity == "Company":
351
+ client.refresh_after_company()
319
352
  output.data(f" PUT {baseline.entity} [{label}]")
320
353
  return len(baseline.records)
321
354
 
322
355
 
356
+ def _put(
357
+ client: AcumaticaClient,
358
+ entity: str,
359
+ body: dict[str, Any],
360
+ endpoint: str | None,
361
+ ) -> None:
362
+ """PUT one record; one re-login + retry on branch-empty 500 (V5/B24)."""
363
+ try:
364
+ client.put(entity, body, endpoint=endpoint)
365
+ except RuntimeError as err:
366
+ if _BRANCH_EMPTY not in str(err):
367
+ raise
368
+ output.info(f"re-login and retry {entity} (branch empty)")
369
+ client.relogin()
370
+ client.put(entity, body, endpoint=endpoint)
371
+
372
+
323
373
  def _with_detail_ids(
324
374
  client: AcumaticaClient, baseline: BaselineFile, record: dict[str, Any]
325
375
  ) -> dict[str, Any]:
@@ -0,0 +1,100 @@
1
+ """Dataset target matrix: committed verified ERP + Default API versions (V27).
2
+
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.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+ from pydantic import ValidationError, field_validator
13
+
14
+ from .config import Instance, find_data_root
15
+ from .models import Model, validation_summary
16
+
17
+ TARGET_FILENAME = "target.yaml"
18
+
19
+
20
+ class DatasetTarget(Model):
21
+ """Committed verified target for a data repo (V2 what — never secrets)."""
22
+
23
+ erp: str # claimed product line/build; live compare only when a probe exists
24
+ default_api: str # Default contract version half only (e.g. 25.200.001)
25
+
26
+ @field_validator("default_api")
27
+ @classmethod
28
+ def _api_version_half_only(cls, v: str) -> str:
29
+ v = v.strip().strip("/")
30
+ if not v:
31
+ raise ValueError(
32
+ "default_api must be the version half only (e.g. 25.200.001)"
33
+ )
34
+ if "/" in v or v.lower().startswith("default"):
35
+ raise ValueError(
36
+ "default_api must be the version half only "
37
+ f"(e.g. 25.200.001), not a path like Default/{v}"
38
+ )
39
+ return v
40
+
41
+ @field_validator("erp")
42
+ @classmethod
43
+ def _erp_nonempty(cls, v: str) -> str:
44
+ v = v.strip()
45
+ if not v:
46
+ raise ValueError("erp must be a non-empty build id (e.g. 26.101.0225)")
47
+ return v
48
+
49
+
50
+ def find_target_path(root: Path | None = None) -> Path | None:
51
+ """``{data-root}/target.yaml`` when the file exists; else None."""
52
+ root = root if root is not None else find_data_root()
53
+ if root is None:
54
+ return None
55
+ path = root / TARGET_FILENAME
56
+ return path if path.is_file() else None
57
+
58
+
59
+ def load_target(root: Path | None = None) -> DatasetTarget | None:
60
+ """Return DatasetTarget, None if absent, or SystemExit on unreadable/invalid.
61
+
62
+ Invalid file is always a hard error for any caller that loads it — never
63
+ silently ignored on apply while only failing on config check (V27).
64
+ """
65
+ path = find_target_path(root)
66
+ if path is None:
67
+ return None
68
+ try:
69
+ with open(path) as f:
70
+ data = yaml.safe_load(f)
71
+ except OSError as exc:
72
+ raise SystemExit(f"{path}: cannot read target.yaml: {exc}") from exc
73
+ if data is None:
74
+ raise SystemExit(f"{path}: target.yaml is empty")
75
+ if not isinstance(data, dict):
76
+ raise SystemExit(f"{path}: expected a mapping at the top level")
77
+ try:
78
+ return DatasetTarget.model_validate(data)
79
+ except ValidationError as exc:
80
+ raise SystemExit(f"{path}: {validation_summary(exc)}") from exc
81
+
82
+
83
+ 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
+
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.
89
+ """
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
+ )
@@ -4,6 +4,9 @@
4
4
  # ACU_BASE_URL (REST root) is required; ACU_SSH (control-plane user@host)
5
5
  # is optional - leave blank for hosted / data-plane-only (tenant CRUD still
6
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).
7
10
  # Keep this file out of git (the scaffolded .gitignore already lists it;
8
11
  # encrypt at rest, e.g. .env.gpg).
9
12
  ACU_BASE_URL=http://erp.example.com/AcumaticaERP
@@ -0,0 +1,6 @@
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"