acumatica-cli 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- acumatica_cli/__init__.py +0 -0
- acumatica_cli/bootstrap.py +105 -0
- acumatica_cli/bootstrap_plugin.cs +62 -0
- acumatica_cli/bootstrap_project.xml +117 -0
- acumatica_cli/cli.py +351 -0
- acumatica_cli/client.py +251 -0
- acumatica_cli/config.py +113 -0
- acumatica_cli/firstlogin.py +171 -0
- acumatica_cli/models.py +21 -0
- acumatica_cli/output.py +70 -0
- acumatica_cli/seed.py +120 -0
- acumatica_cli/tenant.py +149 -0
- acumatica_cli-0.2.2.dist-info/METADATA +187 -0
- acumatica_cli-0.2.2.dist-info/RECORD +16 -0
- acumatica_cli-0.2.2.dist-info/WHEEL +4 -0
- acumatica_cli-0.2.2.dist-info/entry_points.txt +3 -0
acumatica_cli/seed.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Reference data as code: baseline/*.yaml applied to the live tenant.
|
|
2
|
+
|
|
3
|
+
apply = entity PUTs (upsert by key); diff = live-vs-source comparison
|
|
4
|
+
(the drift proof).
|
|
5
|
+
|
|
6
|
+
Baseline file format:
|
|
7
|
+
|
|
8
|
+
entity: Currency # entity name in the contract endpoint
|
|
9
|
+
key: CurrencyID # key field(s), string or list
|
|
10
|
+
endpoint: Bootstrap/1.0.0 # optional: override the instance endpoint
|
|
11
|
+
records:
|
|
12
|
+
- CurrencyID: "CAD"
|
|
13
|
+
Description: Canadian Dollar
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
from pydantic import Field, ValidationError, field_validator, model_validator
|
|
21
|
+
|
|
22
|
+
from . import output
|
|
23
|
+
from .client import AcumaticaClient, unwrap
|
|
24
|
+
from .models import Model, validation_summary
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BaselineFile(Model):
|
|
28
|
+
"""A parsed baseline YAML: one entity, its key fields, its records."""
|
|
29
|
+
|
|
30
|
+
path: Path
|
|
31
|
+
entity: str
|
|
32
|
+
keys: list[str] = Field(alias="key")
|
|
33
|
+
records: list[dict[str, Any]]
|
|
34
|
+
endpoint: str | None = None # bootstrap YAML targets the custom endpoint
|
|
35
|
+
|
|
36
|
+
@field_validator("keys", mode="before")
|
|
37
|
+
@classmethod
|
|
38
|
+
def _key_as_list(cls, v: object) -> object:
|
|
39
|
+
return v if isinstance(v, list) else [v]
|
|
40
|
+
|
|
41
|
+
@model_validator(mode="after")
|
|
42
|
+
def _records_carry_keys(self) -> "BaselineFile":
|
|
43
|
+
for i, record in enumerate(self.records):
|
|
44
|
+
for k in self.keys:
|
|
45
|
+
if k not in record:
|
|
46
|
+
raise ValueError(f"records[{i}] missing key field '{k}'")
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_baseline(path: Path) -> BaselineFile:
|
|
51
|
+
"""Parse and validate one baseline YAML file."""
|
|
52
|
+
with open(path) as f:
|
|
53
|
+
data = yaml.safe_load(f)
|
|
54
|
+
if not isinstance(data, dict):
|
|
55
|
+
raise SystemExit(f"{path}: expected a mapping at the top level")
|
|
56
|
+
try:
|
|
57
|
+
return BaselineFile.model_validate({"path": path, **data})
|
|
58
|
+
except ValidationError as exc:
|
|
59
|
+
raise SystemExit(f"{path}: {validation_summary(exc)}") from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _norm(value: Any) -> str:
|
|
63
|
+
"""Comparable form: bools case-folded, numbers by value, rest stringified.
|
|
64
|
+
|
|
65
|
+
Numbers compare by value, not spelling - a YAML `0` against the
|
|
66
|
+
endpoint's `0.0` (DecimalValue fields come back as floats) is not
|
|
67
|
+
drift (T13).
|
|
68
|
+
"""
|
|
69
|
+
if isinstance(value, bool):
|
|
70
|
+
return str(value).lower()
|
|
71
|
+
if isinstance(value, int | float):
|
|
72
|
+
return repr(float(value))
|
|
73
|
+
return str(value).strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _filter_for(record: dict[str, Any], keys: list[str]) -> str:
|
|
77
|
+
return " and ".join(f"{k} eq '{record[k]}'" for k in keys)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def apply(
|
|
81
|
+
client: AcumaticaClient, baseline: BaselineFile, dry_run: bool = False
|
|
82
|
+
) -> int:
|
|
83
|
+
"""PUT every record (upsert by key). Returns the record count."""
|
|
84
|
+
for record in baseline.records:
|
|
85
|
+
label = ", ".join(str(record[k]) for k in baseline.keys)
|
|
86
|
+
if dry_run:
|
|
87
|
+
output.data(f" would PUT {baseline.entity} [{label}]")
|
|
88
|
+
else:
|
|
89
|
+
client.put(baseline.entity, record, endpoint=baseline.endpoint)
|
|
90
|
+
output.data(f" PUT {baseline.entity} [{label}]")
|
|
91
|
+
return len(baseline.records)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def diff(client: AcumaticaClient, baseline: BaselineFile) -> list[str]:
|
|
95
|
+
"""Compare each source record against the live tenant.
|
|
96
|
+
|
|
97
|
+
Returns human-readable drift lines (empty = no drift).
|
|
98
|
+
"""
|
|
99
|
+
drifts: list[str] = []
|
|
100
|
+
for record in baseline.records:
|
|
101
|
+
label = (
|
|
102
|
+
f"{baseline.entity} [{', '.join(str(record[k]) for k in baseline.keys)}]"
|
|
103
|
+
)
|
|
104
|
+
live = client.get_list(
|
|
105
|
+
baseline.entity,
|
|
106
|
+
params={"$filter": _filter_for(record, baseline.keys)},
|
|
107
|
+
endpoint=baseline.endpoint,
|
|
108
|
+
)
|
|
109
|
+
if not live:
|
|
110
|
+
drifts.append(f"{label}: missing on tenant")
|
|
111
|
+
continue
|
|
112
|
+
actual = unwrap(live[0])
|
|
113
|
+
for field, expected in record.items():
|
|
114
|
+
if field not in actual:
|
|
115
|
+
drifts.append(f"{label}.{field}: not returned by endpoint")
|
|
116
|
+
elif _norm(actual[field]) != _norm(expected):
|
|
117
|
+
drifts.append(
|
|
118
|
+
f"{label}.{field}: source={expected!r} live={actual[field]!r}"
|
|
119
|
+
)
|
|
120
|
+
return drifts
|
acumatica_cli/tenant.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Tenant CRUD on the instance.
|
|
2
|
+
|
|
3
|
+
Thin SSH wrapper around ac.exe CompanyConfig (the only supported CLI route,
|
|
4
|
+
see docs/ac-exe.md) plus a sqlcmd read side.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from .config import Instance
|
|
10
|
+
from .models import Model
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Tenant(Model):
|
|
14
|
+
"""One row of the instance's Company table.
|
|
15
|
+
|
|
16
|
+
login_name is dbo.Company.CompanyKey — the name on the sign-in page and
|
|
17
|
+
the REST login's tenant value. company_cd is the internal auto-generated
|
|
18
|
+
code (ac.exe ignores LoginName for it; see docs/ac-exe.md).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
company_id: int
|
|
22
|
+
company_cd: str
|
|
23
|
+
login_name: str
|
|
24
|
+
company_type: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TenantManager:
|
|
28
|
+
"""Tenant CRUD via SSH to the Windows guest."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, instance: Instance):
|
|
31
|
+
self.instance = instance
|
|
32
|
+
|
|
33
|
+
def _ssh(self, command: str) -> str:
|
|
34
|
+
# PowerShell-over-ssh returns 0 even when the native command fails;
|
|
35
|
+
# appending the exit here (single choke point, never at call sites)
|
|
36
|
+
# makes every remote failure propagate (SPEC V18, B4).
|
|
37
|
+
r = subprocess.run(
|
|
38
|
+
[
|
|
39
|
+
"ssh",
|
|
40
|
+
"-o",
|
|
41
|
+
"BatchMode=yes",
|
|
42
|
+
self.instance.ssh,
|
|
43
|
+
command + "\nexit $LASTEXITCODE",
|
|
44
|
+
],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
check=False,
|
|
48
|
+
)
|
|
49
|
+
if r.returncode != 0:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
f"remote command failed ({r.returncode}):\n{r.stdout}\n{r.stderr}"
|
|
52
|
+
)
|
|
53
|
+
return r.stdout
|
|
54
|
+
|
|
55
|
+
def list(self) -> list[Tenant]:
|
|
56
|
+
"""Read tenants from the Company table (sqlcmd over SSH)."""
|
|
57
|
+
query = (
|
|
58
|
+
"SET NOCOUNT ON; "
|
|
59
|
+
"SELECT CompanyID, CompanyCD, ISNULL(CompanyKey, ''), CompanyType "
|
|
60
|
+
f"FROM {self.instance.db_name}.dbo.Company ORDER BY CompanyID"
|
|
61
|
+
)
|
|
62
|
+
out = self._ssh(f'sqlcmd -S "(local)" -E -C -W -h -1 -s "|" -Q "{query}"')
|
|
63
|
+
tenants = []
|
|
64
|
+
for line in out.splitlines():
|
|
65
|
+
parts = [p.strip() for p in line.split("|")]
|
|
66
|
+
if len(parts) == 4 and parts[0].isdigit():
|
|
67
|
+
tenants.append(
|
|
68
|
+
Tenant(
|
|
69
|
+
company_id=int(parts[0]),
|
|
70
|
+
company_cd=parts[1],
|
|
71
|
+
login_name=parts[2],
|
|
72
|
+
company_type=parts[3],
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return tenants
|
|
76
|
+
|
|
77
|
+
def recycle_app_pool(self) -> None:
|
|
78
|
+
"""Recycle the IIS app pool so the running app reloads its tenant map.
|
|
79
|
+
|
|
80
|
+
The tenant map loads at app start; without this, tenants created or
|
|
81
|
+
deleted by CompanyConfig stay invisible to the sign-in page and REST
|
|
82
|
+
routing silently falls back to the default tenant (docs/rest-api.md).
|
|
83
|
+
The pool is named after the instance (how acumatica-infra builds it).
|
|
84
|
+
"""
|
|
85
|
+
self._ssh(
|
|
86
|
+
"Import-Module WebAdministration; "
|
|
87
|
+
f"Restart-WebAppPool -Name '{self.instance.instance_name}'"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _company_config(self, company: str, extra: str = "") -> str:
|
|
91
|
+
# -iname AND -h are both required: without -iname CompanyConfig can't
|
|
92
|
+
# find the site; without -h its web.config step dies on a null path
|
|
93
|
+
# (verified 2026-07-08, docs/ac-exe.md).
|
|
94
|
+
return self._ssh(
|
|
95
|
+
f"& '{self.instance.ac_exe}' "
|
|
96
|
+
f'-configmode:"CompanyConfig" -output:"Forced" '
|
|
97
|
+
f'-iname:"{self.instance.instance_name}" '
|
|
98
|
+
f'-h:"{self.instance.instance_path}" '
|
|
99
|
+
f'-dbsrvname:"(local)" -dbsrvwinauth:"True" '
|
|
100
|
+
f'-dbname:"{self.instance.db_name}" -dbnew:"False" '
|
|
101
|
+
f'-company:"{company}"{extra}'
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def create(
|
|
105
|
+
self,
|
|
106
|
+
company_id: int,
|
|
107
|
+
login_name: str,
|
|
108
|
+
parent_id: int = 1,
|
|
109
|
+
visible: bool = True,
|
|
110
|
+
company_type: str = "",
|
|
111
|
+
) -> str:
|
|
112
|
+
"""Create (or, for an existing CompanyID, overwrite!) a tenant.
|
|
113
|
+
|
|
114
|
+
company_type: '' = clean tenant, 'SalesDemo' = demo data. The admin
|
|
115
|
+
flags preset the tenant's admin to the instance credentials with no
|
|
116
|
+
must-change flag, so the tenant is REST-loginable right after the
|
|
117
|
+
app-pool recycle — no first-login dance (verified, docs/ac-exe.md).
|
|
118
|
+
"""
|
|
119
|
+
company = (
|
|
120
|
+
f"CompanyID={company_id};ParentID={parent_id};"
|
|
121
|
+
f"Visible={'Yes' if visible else 'No'};"
|
|
122
|
+
f"CompanyType={company_type};LoginName={login_name};"
|
|
123
|
+
)
|
|
124
|
+
admin = (
|
|
125
|
+
f' -aun:"{self.instance.username}" '
|
|
126
|
+
f'-aup:"{self.instance.password}" -auc:"False"'
|
|
127
|
+
)
|
|
128
|
+
return self._company_config(company, extra=admin)
|
|
129
|
+
|
|
130
|
+
def delete(self, company_id: int) -> str:
|
|
131
|
+
"""Delete the tenant and all its data.
|
|
132
|
+
|
|
133
|
+
Two verified gotchas (docs/ac-exe.md):
|
|
134
|
+
|
|
135
|
+
- The sub-key is ``Deleted``, not the officially documented
|
|
136
|
+
``Delete`` — an unknown key is silently ignored and the run degrades
|
|
137
|
+
into a *destructive data re-insert* into the tenant.
|
|
138
|
+
- The full spec (``ParentID`` + ``CompanyType``) must be passed. With
|
|
139
|
+
only ``CompanyID;Deleted=Yes`` the preflight reads an empty
|
|
140
|
+
CompanyType as the system tenant and aborts with "The system company
|
|
141
|
+
cannot be removed."
|
|
142
|
+
"""
|
|
143
|
+
target = next((t for t in self.list() if t.company_id == company_id), None)
|
|
144
|
+
if target is None:
|
|
145
|
+
raise RuntimeError(f"no tenant with CompanyID={company_id}")
|
|
146
|
+
return self._company_config(
|
|
147
|
+
f"CompanyID={company_id};ParentID=1;"
|
|
148
|
+
f"CompanyType={target.company_type};Deleted=Yes;"
|
|
149
|
+
)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: acumatica-cli
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: The acu CLI - Acumatica ERP configuration as code: tenant provisioning, baseline config, and reference data
|
|
5
|
+
Author: Konstantin Borovik
|
|
6
|
+
Author-email: Konstantin Borovik <kb@lab5.ca>
|
|
7
|
+
Requires-Dist: click>=8.1
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: pydantic>=2.7
|
|
10
|
+
Requires-Dist: python-dotenv>=1.0
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
Requires-Dist: rich>=13
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Acumatica CLI
|
|
17
|
+
|
|
18
|
+
**`acu`** — configure Acumatica ERP purely from source code: no UI clicks,
|
|
19
|
+
no Configuration Wizard.
|
|
20
|
+
|
|
21
|
+
Every operation is idempotent:
|
|
22
|
+
|
|
23
|
+
- `acu apply` upserts records by key — running it twice is safe
|
|
24
|
+
- `acu diff` detects drift between your YAML and the live tenant
|
|
25
|
+
- `acu provision` takes an empty instance to a fully configured tenant in
|
|
26
|
+
one command, and skips every step that is already done
|
|
27
|
+
|
|
28
|
+
## Why?
|
|
29
|
+
|
|
30
|
+
Acumatica configuration normally lives in the web UI: wizards, screens, and
|
|
31
|
+
manual data entry that nobody can review, version, or reproduce. `acu` moves
|
|
32
|
+
that configuration into YAML files in a git repo, so a tenant can be rebuilt
|
|
33
|
+
from scratch, audited in a pull request, and checked for drift like any other
|
|
34
|
+
infrastructure.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
From the data repo:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
acu config show # sanity-check the resolved target
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
acu provision --id 3 --login DEV # create → bootstrap → apply → diff
|
|
46
|
+
acu diff baseline/ # later: prove zero drift (exit 2 on drift)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## CLI Map
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
acu [-t TENANT]
|
|
53
|
+
│
|
|
54
|
+
├── provision --id N --login NAME one command: create → bootstrap → apply → diff
|
|
55
|
+
│ [--type SalesDemo] [--parent N]
|
|
56
|
+
│
|
|
57
|
+
├── tenant tenant CRUD (ac.exe over SSH — control plane)
|
|
58
|
+
│ ├── list CompanyID, sign-in name, internal CD, type
|
|
59
|
+
│ ├── create create a tenant and make it automation-ready
|
|
60
|
+
│ └── delete delete a tenant and its data, recycle app pool
|
|
61
|
+
│
|
|
62
|
+
├── apply [--dry-run] FILES... seed baseline YAML via REST (idempotent PUT upserts)
|
|
63
|
+
├── diff FILES... drift check: YAML vs live tenant (exit 2 on drift)
|
|
64
|
+
├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
|
|
65
|
+
│
|
|
66
|
+
└── config local, read-only — never talks to a live instance
|
|
67
|
+
└── show print the resolved target as a complete acu.yaml
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The target instance comes from `acu.yaml` (found by walking up from the
|
|
71
|
+
current directory); the one global option `-t/--tenant` overrides the tenant
|
|
72
|
+
that API sessions sign in to. Run `acu <command> --help` for details on any
|
|
73
|
+
command.
|
|
74
|
+
|
|
75
|
+
## Installation
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
Configuration is split into three layers, each answering one question:
|
|
80
|
+
|
|
81
|
+
1. **`acu.yaml`** — *where* to apply. The whole file is the target
|
|
82
|
+
instance: a flat map where `host` is the only required key; everything
|
|
83
|
+
else has a code default matching a stock Acumatica install:
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
host: acu-dev1.vm.internal
|
|
87
|
+
tenant: Company # sign-in name of the tenant API sessions use
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Optional overrides (defaults shown) for nonstandard installs,
|
|
91
|
+
split-horizon DNS, port forwards, or jump hosts:
|
|
92
|
+
|
|
93
|
+
`acu config show` prints the fully resolved instance as a complete,
|
|
94
|
+
valid `acu.yaml` — every knob visible, credentials excluded. To turn
|
|
95
|
+
the resolved state into your working config, redirect and edit:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
acu config show > acu.yaml
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
2. **`.env`** — *who* signs in to the REST API. Loaded from the data-repo
|
|
102
|
+
root (or the plain environment):
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
ACU_PASSWORD=... # required
|
|
106
|
+
ACU_USER=admin # optional, defaults to admin
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The data repo stores it encrypted as `.env.gpg`; `make decrypt` produces
|
|
110
|
+
the plaintext `.env` once per clone.
|
|
111
|
+
|
|
112
|
+
3. **SSH** — *how* the control plane reaches the Windows guest. Configured on
|
|
113
|
+
the guest and in your `~/.ssh`, not in `acu.yaml` — see the next section.
|
|
114
|
+
|
|
115
|
+
Verify the result before touching anything live:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
acu config show # the fully resolved instance as acu.yaml, no secrets
|
|
119
|
+
acu apply --dry-run baseline/
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Two planes
|
|
123
|
+
|
|
124
|
+
`acu` talks to an instance over two independent channels:
|
|
125
|
+
|
|
126
|
+
- **Control plane (SSH):** tenant create/delete/list via `ac.exe
|
|
127
|
+
-cm:CompanyConfig` and `sqlcmd` on the Windows guest — see
|
|
128
|
+
[`docs/ac-exe.md`](docs/ac-exe.md). Used by `acu tenant` and the first step
|
|
129
|
+
of `acu provision`.
|
|
130
|
+
- **Data plane (REST):** baseline configuration and reference data through
|
|
131
|
+
the contract-based API (`/entity/Default/25.200.001/`), where `PUT` is a
|
|
132
|
+
keyed upsert — see [`docs/rest-api.md`](docs/rest-api.md). Used by
|
|
133
|
+
`acu apply`, `acu diff`, and `acu schema`.
|
|
134
|
+
|
|
135
|
+
If you only apply and diff baseline YAML, you never need SSH. SSH setup is
|
|
136
|
+
required only for `acu tenant` and `acu provision`.
|
|
137
|
+
|
|
138
|
+
## SSH setup (control plane)
|
|
139
|
+
|
|
140
|
+
`acu tenant` and `acu provision` run commands on the Windows guest through
|
|
141
|
+
plain `ssh`. Two things about this setup are not obvious, and both are hard
|
|
142
|
+
requirements:
|
|
143
|
+
|
|
144
|
+
**1. The default SSH shell on the Windows guest must be PowerShell.**
|
|
145
|
+
`acu` sends PowerShell syntax over the wire (`& 'C:\...\ac.exe' ...`,
|
|
146
|
+
`Import-Module WebAdministration`, `exit $LASTEXITCODE`). Windows OpenSSH
|
|
147
|
+
ships with `cmd.exe` as the default shell, where every one of those commands
|
|
148
|
+
fails. Switch it once, in an elevated PowerShell on the guest:
|
|
149
|
+
|
|
150
|
+
```powershell
|
|
151
|
+
New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell `
|
|
152
|
+
-Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" `
|
|
153
|
+
-PropertyType String -Force
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**2. Authentication must be key-based and non-interactive.**
|
|
157
|
+
`acu` connects with `BatchMode=yes`, so it will never answer a password
|
|
158
|
+
prompt — without a working key the connection just fails. And because the
|
|
159
|
+
default `ssh_user` is `Administrator` (an administrators-group member),
|
|
160
|
+
Windows OpenSSH reads the key from the *machine-wide* file
|
|
161
|
+
`C:\ProgramData\ssh\administrators_authorized_keys` — **not** from
|
|
162
|
+
`~\.ssh\authorized_keys` like on Linux. On the guest:
|
|
163
|
+
|
|
164
|
+
```powershell
|
|
165
|
+
# install + start the server (once)
|
|
166
|
+
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
|
|
167
|
+
Set-Service sshd -StartupType Automatic
|
|
168
|
+
Start-Service sshd
|
|
169
|
+
|
|
170
|
+
# authorize your public key for administrators
|
|
171
|
+
Add-Content -Path C:\ProgramData\ssh\administrators_authorized_keys Value "ssh-ed25519 AAAA... you@laptop"
|
|
172
|
+
|
|
173
|
+
# the file must be readable by SYSTEM/Administrators only, or sshd ignores it
|
|
174
|
+
icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Then verify from your workstation — this one test proves both requirements
|
|
178
|
+
at once (key auth works, and the shell is PowerShell):
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersion'
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A version table means you are ready; a password prompt or
|
|
185
|
+
`'$PSVersionTable.PSVersion' is not recognized...` means requirement 2 or 1
|
|
186
|
+
(respectively) is not met yet. Host aliases, jump hosts, and ports go in your
|
|
187
|
+
`~/.ssh/config` as usual, or set `ssh: user@alias` in `acu.yaml`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
acumatica_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
acumatica_cli/bootstrap.py,sha256=zCYRqwRLbG4bFgrrwQgxzrtVmtkaujhseKld088Nycw,4411
|
|
3
|
+
acumatica_cli/bootstrap_plugin.cs,sha256=Z3DSemqPY9jmGQUe4n95pbBiRJLJJSW-OJSXULiIJm0,2358
|
|
4
|
+
acumatica_cli/bootstrap_project.xml,sha256=V3WIFX-jZa6wk2uEZduhlvto0aFVHQWFyPp6K52jjC8,5842
|
|
5
|
+
acumatica_cli/cli.py,sha256=ab9XOZZX9qAzg3IjpNnco9QCnciOJZQE6oaHa5GADaM,12685
|
|
6
|
+
acumatica_cli/client.py,sha256=oSQq2f9AG11h0YoyJ0jNA2A9URoMknuAkKn2gGvxyt4,10095
|
|
7
|
+
acumatica_cli/config.py,sha256=ZUDYYOhjTq6kel3rp4Kms_ot5vRp7C0zsZfVPnXkT44,4011
|
|
8
|
+
acumatica_cli/firstlogin.py,sha256=6lW29p2IKHjMvqG7oYvGwibiq0TGZZ0yekg5muxJwrI,6320
|
|
9
|
+
acumatica_cli/models.py,sha256=hwQPWtIVPt6A4pEV6bXV2sLTU5KZ7nDwIGzJ09mQyUw,701
|
|
10
|
+
acumatica_cli/output.py,sha256=2cGSUiBAW0sDQkFIDyKoYOvqTCUc62CjTzixz6WVEdM,2058
|
|
11
|
+
acumatica_cli/seed.py,sha256=wl04PmlKuj4Hn0kvPnpsLGEWe_abZMfAQrJg_EE4E_c,4097
|
|
12
|
+
acumatica_cli/tenant.py,sha256=VIRdiWiqKb7CHcVuw1CzTCfVLcVxBj5bTMM9JyMVJrY,5624
|
|
13
|
+
acumatica_cli-0.2.2.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
|
|
14
|
+
acumatica_cli-0.2.2.dist-info/entry_points.txt,sha256=0DVtG5zKps5rMtP4AuppfHvXFvh5x2WZk0y8-bR1Ohc,48
|
|
15
|
+
acumatica_cli-0.2.2.dist-info/METADATA,sha256=4_-xg67y1WhShxUX8jiTh2TxCi0NmQDuNCpT8FnlARw,7194
|
|
16
|
+
acumatica_cli-0.2.2.dist-info/RECORD,,
|