@bergetai/odoo-cli 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Berget AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # odoo-cli
2
+
3
+ Agent-first CLI for the [Odoo External API](https://www.odoo.com/documentation/18.0/developer/reference/external_api.html).
4
+
5
+ Every command is non-interactive, scriptable, and supports `--json` for stable machine-readable output.
6
+
7
+ ## Usage
8
+
9
+ ```bash
10
+ # Authenticate (API key via stdin — never as a flag)
11
+ pass odoo/key | odoo auth login --url https://acme.odoo.com --db acme --username automation
12
+
13
+ # Check credentials and their configuration sources
14
+ odoo auth status --json
15
+
16
+ # Unauthenticated connectivity check
17
+ odoo version
18
+ ```
19
+
20
+ Configuration precedence: flags > environment (`ODOO_URL`, `ODOO_DB`, `ODOO_USERNAME`, `ODOO_API_KEY`) > `$XDG_CONFIG_HOME/odoo/config.json`. The API key is never accepted as a flag value.
21
+
22
+ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the server.
23
+
24
+ ## Development
25
+
26
+ ```bash
27
+ npm install
28
+ npm run build # compile to dist/
29
+ npm test # build + unit/e2e tests against a fake XML-RPC server
30
+ ```
31
+
32
+ ## Status
33
+
34
+ - [x] Skeleton, transport, and auth — command tree, global flags, config sources and precedence with atomic `0600` config writes, XML-RPC transport with timeouts and HTTPS enforcement, `odoo version`, `odoo auth login`, `odoo auth status` with the auth failure taxonomy (db missing / bad credentials / unreachable)
35
+ - [x] Read-only CRM surface (`crm leads list/show`) — default columns, `--fields`/`--all-fields`, `--limit`/`--offset`/`--order`, `--domain`/`--filter` with `fields_get` type checking, reference resolution (`--stage`/`--team`/`--assignee`/`--tag`), table/JSON output with `search_count` envelope
36
+ - [x] Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
37
+ - [x] `--dry-run` machinery for mutations — full validation, resolution, and before/after diffs without mutating
38
+ - [x] Writes (`create`, `update`, `delete`) — single-call atomic batch `write`, client-side no-op diffing, delete convergence with `skipped` ids, post-failure "record may have been created" hint on `create`
39
+ - [ ] Polish and packaging
40
+
41
+ ## Production-instance notes
42
+
43
+ Validated against a real Odoo 18.0+e instance. Schema realities discovered
44
+ against the live server:
45
+
46
+ - `helpdesk.ticket.ticket_type_id` may be absent (edition/version-dependent).
47
+ Curated `show` fields are intersected with `fields_get`, so absent optional
48
+ fields degrade gracefully instead of faulting the read.
49
+ - `helpdesk.stage` links to teams via `team_ids` (many2many, shared stages) on
50
+ some instances rather than `team_id` (many2one). Stage scoping introspects
51
+ the stage model's schema and uses whichever field exists.
52
+ - `execute_kw` kwargs must be a separate trailing dict param; packing it into
53
+ the positional-args array makes the server silently ignore it (this caused
54
+ `fields_get` to return full/empty structs).
package/dist/cli.js ADDED
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ import { Command, CommanderError } from "commander";
3
+ import { authLoginCommand, authStatusCommand } from "./commands/auth.js";
4
+ import { leadsCreateCommand, leadsDeleteCommand, leadsListCommand, leadsShowCommand, leadsUpdateCommand, crmLeads, } from "./commands/crm.js";
5
+ import { ticketsCreateCommand, ticketsDeleteCommand, ticketsListCommand, ticketsShowCommand, ticketsUpdateCommand, helpdeskTickets, } from "./commands/helpdesk.js";
6
+ import { versionCommand } from "./commands/version.js";
7
+ import { CliError } from "./errors.js";
8
+ import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
9
+ import { printError } from "./output.js";
10
+ const CLI_VERSION = "0.1.0";
11
+ /** commander value parser for repeatable options (accumulates values). */
12
+ function collect(value, previous) {
13
+ return [...previous, value];
14
+ }
15
+ /**
16
+ * Register a resource's first-class mutation flags + --set/--values onto a
17
+ * commander command (SPEC §4.2). Help text states the underlying field
18
+ * (§10). Returns the command so create/update can chain .argument/.action.
19
+ */
20
+ function addMutationFlags(cmd, config) {
21
+ const spec = config.mutation;
22
+ if (!spec)
23
+ return cmd;
24
+ for (const flag of spec.flags) {
25
+ if (flag.multi) {
26
+ cmd.option(flag.flags, `${flag.help} [${flag.field}]`, collect, []);
27
+ }
28
+ else {
29
+ cmd.option(flag.flags, `${flag.help} [${flag.field}]`);
30
+ }
31
+ }
32
+ cmd.option("--set <f=v>", "set any field (technical name, JSON scalar); repeatable", collect, []);
33
+ cmd.option("--values <file>", "read field values from a JSON file ('-' for stdin)");
34
+ return cmd;
35
+ }
36
+ function buildProgram() {
37
+ const program = new Command();
38
+ program
39
+ .name("odoo")
40
+ .description(`Agent-first CLI for the Odoo External API.
41
+
42
+ All commands are non-interactive and scriptable; every command supports
43
+ --json for machine-readable output. Note that \`odoo --version\` prints the
44
+ CLI's own version, while \`odoo version\` queries the server's version.`)
45
+ .version(CLI_VERSION, "--version", "print the CLI's own version")
46
+ .showHelpAfterError("(add --help for additional information)")
47
+ .showSuggestionAfterError(true)
48
+ .configureHelp({ sortSubcommands: true });
49
+ program.addHelpText("after", `
50
+ Examples:
51
+ $ pass odoo/key | odoo auth login --url https://acme.odoo.com --db acme --username automation
52
+ $ odoo auth status --json
53
+ $ odoo version
54
+
55
+ Docs: https://www.odoo.com/documentation/18.0/developer/reference/external_api.html`);
56
+ applyGlobalOptions(program);
57
+ program
58
+ .command("version")
59
+ .description("query the server's version (unauthenticated connectivity check)")
60
+ .action(async () => {
61
+ await run(versionCommand, program);
62
+ });
63
+ const auth = program.command("auth").description("manage credentials");
64
+ auth
65
+ .command("login")
66
+ .description("verify credentials and write them to the config file (key via stdin/env/file)")
67
+ .addHelpText("after", `
68
+ Examples:
69
+ $ pass odoo/key | odoo auth login --url https://acme.odoo.com --db acme --username automation
70
+ $ ODOO_API_KEY=... odoo auth login --url https://acme.odoo.com --db acme --username automation`)
71
+ .action(async () => {
72
+ await run(authLoginCommand, program);
73
+ });
74
+ auth
75
+ .command("status")
76
+ .description("show resolved credentials and verify them against the server")
77
+ .action(async () => {
78
+ await run(authStatusCommand, program);
79
+ });
80
+ const crm = program.command("crm").description("CRM app (crm.lead)");
81
+ const leads = crm.command("leads").description("leads (type=lead)");
82
+ leads
83
+ .command("list")
84
+ .description("search and list leads with curated default columns")
85
+ .option("--stage <name|id>", "filter by stage (resolved by name)")
86
+ .option("--team <name|id>", "filter by sales team (resolved by name)")
87
+ .option("--assignee <name|id>", "filter by salesperson (login, email, or name)")
88
+ .option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
89
+ .option("--priority <level>", "filter by priority: low, medium, high, urgent")
90
+ .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
91
+ .option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
92
+ .option("--fields <a,b,c>", "columns to display")
93
+ .option("--limit <n>", "max records (default: 50)", "50")
94
+ .option("--offset <n>", "skip the first n records", "0")
95
+ .option("--order <order>", 'sort order, e.g. "expected_revenue desc"', undefined)
96
+ .action(async (opts) => {
97
+ await run((g) => leadsListCommand(opts, g), program);
98
+ });
99
+ leads
100
+ .command("show")
101
+ .description("read one lead")
102
+ .argument("<id>", "record id")
103
+ .option("--fields <a,b,c>", "fields to display")
104
+ .option("--all-fields", "display every readable field")
105
+ .action(async (id, opts) => {
106
+ await run((g) => leadsShowCommand(id, opts, g), program);
107
+ });
108
+ addMutationFlags(leads
109
+ .command("create")
110
+ .description("create a lead (preview with --dry-run)"), crmLeads).action(async (opts) => {
111
+ await run((g) => leadsCreateCommand(opts, g), program);
112
+ });
113
+ addMutationFlags(leads
114
+ .command("update")
115
+ .description("update leads (preview with --dry-run)"), crmLeads)
116
+ .argument("<id...>", "record ids")
117
+ .action(async (ids, opts) => {
118
+ await run((g) => leadsUpdateCommand(ids, opts, g), program);
119
+ });
120
+ leads
121
+ .command("delete")
122
+ .description("delete leads (preview with --dry-run; unknown ids are skipped)")
123
+ .argument("<id...>", "record ids")
124
+ .action(async (ids) => {
125
+ await run((g) => leadsDeleteCommand(ids, g), program);
126
+ });
127
+ const helpdesk = program
128
+ .command("helpdesk")
129
+ .description("Helpdesk app (helpdesk.ticket, Enterprise)");
130
+ const tickets = helpdesk.command("tickets").description("support tickets");
131
+ tickets
132
+ .command("list")
133
+ .description("search and list tickets with curated default columns")
134
+ .option("--team <name|id>", "filter by helpdesk team (resolved by name)")
135
+ .option("--stage <name|id>", "filter by stage (resolved within --team when given)")
136
+ .option("--assignee <name|id>", "filter by assignee (login, email, or name)")
137
+ .option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
138
+ .option("--priority <level>", "filter by priority: low, medium, high, urgent")
139
+ .option("--open", "only tickets in unfolded (open) stages — see note below")
140
+ .option("--closed", "only tickets in folded (closed) stages — see note below")
141
+ .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
142
+ .option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
143
+ .option("--fields <a,b,c>", "columns to display")
144
+ .option("--limit <n>", "max records (default: 50)", "50")
145
+ .option("--offset <n>", "skip the first n records", "0")
146
+ .option("--order <order>", 'sort order, e.g. "create_date desc"', undefined)
147
+ .addHelpText("after", `
148
+ Note: --open/--closed select on the stage's "fold" flag, a kanban presentation
149
+ attribute conventionally set on closing stages (Done, Cancelled). It is the
150
+ only server-side signal available, but an admin can fold any stage — for exact
151
+ stage selection use --stage, or --domain '[["stage_id.fold","=",false]]' for
152
+ the raw form.`)
153
+ .action(async (opts) => {
154
+ await run((g) => ticketsListCommand(opts, g), program);
155
+ });
156
+ tickets
157
+ .command("show")
158
+ .description("read one ticket")
159
+ .argument("<id>", "record id")
160
+ .option("--fields <a,b,c>", "fields to display")
161
+ .option("--all-fields", "display every readable field")
162
+ .action(async (id, opts) => {
163
+ await run((g) => ticketsShowCommand(id, opts, g), program);
164
+ });
165
+ addMutationFlags(tickets
166
+ .command("create")
167
+ .description("create a ticket (preview with --dry-run)"), helpdeskTickets).action(async (opts) => {
168
+ await run((g) => ticketsCreateCommand(opts, g), program);
169
+ });
170
+ addMutationFlags(tickets
171
+ .command("update")
172
+ .description("update tickets (preview with --dry-run)"), helpdeskTickets)
173
+ .argument("<id...>", "record ids")
174
+ .action(async (ids, opts) => {
175
+ await run((g) => ticketsUpdateCommand(ids, opts, g), program);
176
+ });
177
+ tickets
178
+ .command("delete")
179
+ .description("delete tickets (preview with --dry-run; unknown ids are skipped)")
180
+ .argument("<id...>", "record ids")
181
+ .action(async (ids) => {
182
+ await run((g) => ticketsDeleteCommand(ids, g), program);
183
+ });
184
+ return program;
185
+ }
186
+ async function run(fn, program) {
187
+ const globals = collectGlobalOptions(program);
188
+ // URL security is enforced here for flag/env-provided URLs; config-file
189
+ // URLs are validated after resolution inside each command.
190
+ const url = globals.url ?? process.env.ODOO_URL;
191
+ if (url) {
192
+ enforceTransportSecurity(url, globals.allowInsecureHttp);
193
+ }
194
+ await fn(globals);
195
+ }
196
+ function collectGlobalOptions(program) {
197
+ const o = program.opts();
198
+ return {
199
+ json: Boolean(o.json),
200
+ dryRun: Boolean(o.dryRun),
201
+ url: o.url,
202
+ db: o.db,
203
+ username: o.username,
204
+ apiKeyFile: o.apiKeyFile,
205
+ allowInsecureHttp: Boolean(o.allowInsecureHttp),
206
+ timeout: o.timeout ??
207
+ process.env.ODOO_TIMEOUT ??
208
+ DEFAULT_TIMEOUT,
209
+ quiet: Boolean(o.quiet),
210
+ verbose: Boolean(o.verbose),
211
+ noColor: Boolean(o.noColor),
212
+ };
213
+ }
214
+ async function main() {
215
+ const program = buildProgram();
216
+ try {
217
+ await program.parseAsync(process.argv);
218
+ }
219
+ catch (error) {
220
+ if (error instanceof CommanderError) {
221
+ // commander already printed help/usage text
222
+ process.exitCode = error.exitCode;
223
+ return;
224
+ }
225
+ const globals = safeGlobals(program);
226
+ if (error instanceof CliError) {
227
+ printError(error, { json: globals.json, verbose: globals.verbose });
228
+ process.exitCode = error.exitCode;
229
+ return;
230
+ }
231
+ // Unexpected/internal error (exit 1)
232
+ const err = error;
233
+ process.stderr.write(`Error: internal error: ${err.message}\n`);
234
+ if (globals.verbose && err.stack) {
235
+ process.stderr.write(`${err.stack}\n`);
236
+ }
237
+ process.exitCode = 1;
238
+ }
239
+ }
240
+ function safeGlobals(program) {
241
+ try {
242
+ const o = program.opts();
243
+ return { json: Boolean(o.json), verbose: Boolean(o.verbose) };
244
+ }
245
+ catch {
246
+ return { json: false, verbose: false };
247
+ }
248
+ }
249
+ await main();
package/dist/client.js ADDED
@@ -0,0 +1,97 @@
1
+ import { authError, networkError, notFoundError, usageError, } from "./errors.js";
2
+ import { callMethod, XmlRpcFaultError } from "./xmlrpc.js";
3
+ const DB_MISSING_RE = /database "([^"]+)" does not exist/;
4
+ export class OdooClient {
5
+ db;
6
+ username;
7
+ apiKey;
8
+ commonUrl;
9
+ objectUrl;
10
+ timeoutMs;
11
+ verbose;
12
+ uid;
13
+ constructor(baseUrl, db, username, apiKey, options) {
14
+ this.db = db;
15
+ this.username = username;
16
+ this.apiKey = apiKey;
17
+ const root = baseUrl.replace(/\/+$/, "");
18
+ this.commonUrl = `${root}/xmlrpc/2/common`;
19
+ this.objectUrl = `${root}/xmlrpc/2/object`;
20
+ this.timeoutMs = options.timeoutMs;
21
+ this.verbose = options.verbose ?? false;
22
+ }
23
+ callOptions(url) {
24
+ return { url, timeoutMs: this.timeoutMs, verbose: this.verbose };
25
+ }
26
+ /**
27
+ * Unauthenticated connectivity check. A reachable XML-RPC endpoint that
28
+ * does not behave like Odoo (no parseable version payload) is a usage
29
+ * error — the URL is wrong — not a network failure (SPEC §2.2).
30
+ */
31
+ async version() {
32
+ let result;
33
+ try {
34
+ result = await callMethod(this.callOptions(this.commonUrl), "version", []);
35
+ }
36
+ catch (error) {
37
+ if (error instanceof XmlRpcFaultError) {
38
+ throw usageError(`${this.commonUrl} does not look like an Odoo server (method 'version' failed)`, "check --url");
39
+ }
40
+ throw error;
41
+ }
42
+ if (typeof result !== "object" ||
43
+ result === null ||
44
+ typeof result.server_version !==
45
+ "string") {
46
+ throw usageError(`${this.commonUrl} does not look like an Odoo server (unexpected version response)`, "check --url");
47
+ }
48
+ return result;
49
+ }
50
+ /**
51
+ * Authenticate once and cache the uid for the process (SPEC §2).
52
+ * Maps the three server outcomes of §2.3 onto distinct errors:
53
+ * db missing (exit 4), bad credentials (exit 3), success (uid).
54
+ */
55
+ async authenticate() {
56
+ if (this.uid !== undefined)
57
+ return this.uid;
58
+ if (!this.db || !this.username || !this.apiKey) {
59
+ throw authError("authentication requires db, username, and api key");
60
+ }
61
+ let result;
62
+ try {
63
+ result = await callMethod(this.callOptions(this.commonUrl), "authenticate", [this.db, this.username, this.apiKey, {}]);
64
+ }
65
+ catch (error) {
66
+ if (error instanceof XmlRpcFaultError) {
67
+ const match = DB_MISSING_RE.exec(error.faultString);
68
+ if (match) {
69
+ throw notFoundError("database_not_found", `database "${match[1]}" does not exist on the server`, "check --db");
70
+ }
71
+ throw networkError(`authentication request failed (fault ${error.faultCode})`, "retry with --verbose to see the full server response");
72
+ }
73
+ throw error;
74
+ }
75
+ if (typeof result === "number" && Number.isInteger(result) && result > 0) {
76
+ this.uid = result;
77
+ return result;
78
+ }
79
+ if (result === false) {
80
+ throw authError("invalid credentials: the server rejected the username/API key pair", "verify the username and generate a new API key under Odoo preferences");
81
+ }
82
+ throw networkError(`unexpected authenticate() response from the server: ${JSON.stringify(result)}`);
83
+ }
84
+ /** execute_kw against /xmlrpc/2/object (used from Phase 1 onward). */
85
+ async executeKw(model, method, args, kwargs = {}) {
86
+ const uid = await this.authenticate();
87
+ return callMethod(this.callOptions(this.objectUrl), "execute_kw", [
88
+ this.db,
89
+ uid,
90
+ this.apiKey,
91
+ model,
92
+ method,
93
+ args,
94
+ kwargs,
95
+ ]);
96
+ }
97
+ }
@@ -0,0 +1,133 @@
1
+ import { OdooClient } from "../client.js";
2
+ import { missingValueHint, missingValues, resolveConfig, writeConfigFile, } from "../config.js";
3
+ import { parseDurationMs } from "../duration.js";
4
+ import { usageError } from "../errors.js";
5
+ import { printJson } from "../output.js";
6
+ async function readStdinKey() {
7
+ const chunks = [];
8
+ for await (const chunk of process.stdin) {
9
+ chunks.push(chunk);
10
+ }
11
+ return Buffer.concat(chunks).toString("utf8").trim();
12
+ }
13
+ function authFlags(globals) {
14
+ return {
15
+ url: globals.url,
16
+ db: globals.db,
17
+ username: globals.username,
18
+ apiKeyFile: globals.apiKeyFile,
19
+ };
20
+ }
21
+ /**
22
+ * `odoo auth login` (SPEC §2.2): verify credentials against the server, then
23
+ * write the config file. Key resolution order: stdin (non-TTY, consumed even
24
+ * if empty) → ODOO_API_KEY → --api-key-file. Never prompts.
25
+ */
26
+ export async function authLoginCommand(globals) {
27
+ let stdinKey;
28
+ if (!process.stdin.isTTY) {
29
+ stdinKey = await readStdinKey();
30
+ if (stdinKey === "") {
31
+ throw usageError("API key read from stdin is empty", "pipe a key via stdin, or unset the pipe and use ODOO_API_KEY / --api-key-file");
32
+ }
33
+ }
34
+ else if (!process.env.ODOO_API_KEY && !globals.apiKeyFile) {
35
+ throw usageError("no API key provided", "pipe the key via stdin, set ODOO_API_KEY, or pass --api-key-file — login never prompts");
36
+ }
37
+ const resolved = await resolveConfig(authFlags(globals), {
38
+ apiKey: stdinKey,
39
+ });
40
+ const missing = missingValues(resolved);
41
+ if (missing.length > 0) {
42
+ const details = missing
43
+ .map((k) => ` ${k}: ${missingValueHint(k)}`)
44
+ .join("\n");
45
+ throw usageError(`missing required configuration: ${missing.join(", ")}`, `\n${details}`);
46
+ }
47
+ const client = new OdooClient(resolved.url, resolved.db, resolved.username, resolved.apiKey, {
48
+ timeoutMs: parseDurationMs(globals.timeout),
49
+ verbose: globals.verbose,
50
+ });
51
+ if (!globals.quiet) {
52
+ process.stderr.write(`Verifying credentials against ${resolved.url} ...\n`);
53
+ }
54
+ // 1. Prove the URL is a reachable Odoo server before authenticating.
55
+ const version = await client.version();
56
+ // 2. Authenticate; throws exit 3 (bad credentials) / exit 4 (db missing).
57
+ const uid = await client.authenticate();
58
+ await writeConfigFile({
59
+ url: resolved.url,
60
+ db: resolved.db,
61
+ username: resolved.username,
62
+ api_key: resolved.apiKey,
63
+ server_version: version.server_version,
64
+ });
65
+ if (globals.json) {
66
+ printJson({
67
+ url: resolved.url,
68
+ db: resolved.db,
69
+ username: resolved.username,
70
+ uid,
71
+ server_version: version.server_version,
72
+ authenticated: true,
73
+ });
74
+ }
75
+ else {
76
+ process.stdout.write(`Logged in as '${resolved.username}' (uid ${uid}) on ${resolved.url} (Odoo ${version.server_version})\n`);
77
+ }
78
+ }
79
+ /**
80
+ * `odoo auth status` (SPEC §2.2): report resolved credentials and verify them,
81
+ * without changing anything. With --json the envelope is always printed on
82
+ * stdout, even when exiting non-zero (SPEC §7.3 exception).
83
+ */
84
+ export async function authStatusCommand(globals) {
85
+ const resolved = await resolveConfig(authFlags(globals));
86
+ const missing = missingValues(resolved);
87
+ const envelope = {
88
+ url: resolved.url ?? null,
89
+ db: resolved.db ?? null,
90
+ username: resolved.username ?? null,
91
+ server_version: null,
92
+ uid: null,
93
+ authenticated: false,
94
+ config_source: resolved.sources,
95
+ };
96
+ if (missing.length > 0) {
97
+ envelope.missing = missing;
98
+ if (globals.json) {
99
+ printJson(envelope);
100
+ }
101
+ else {
102
+ process.stderr.write("Error: missing configuration values:\n");
103
+ for (const key of missing) {
104
+ process.stderr.write(` ${key}: ${missingValueHint(key)}\n`);
105
+ }
106
+ }
107
+ process.exitCode = 2;
108
+ return;
109
+ }
110
+ const client = new OdooClient(resolved.url, resolved.db, resolved.username, resolved.apiKey, {
111
+ timeoutMs: parseDurationMs(globals.timeout),
112
+ verbose: globals.verbose,
113
+ });
114
+ if (!globals.quiet) {
115
+ process.stderr.write(`Verifying credentials against ${resolved.url} ...\n`);
116
+ }
117
+ const version = await client.version();
118
+ envelope.server_version = version.server_version;
119
+ const uid = await client.authenticate();
120
+ envelope.uid = uid;
121
+ envelope.authenticated = true;
122
+ if (globals.json) {
123
+ printJson(envelope);
124
+ }
125
+ else {
126
+ process.stdout.write(`URL: ${envelope.url} (${resolved.sources.url})\n`);
127
+ process.stdout.write(`Database: ${envelope.db} (${resolved.sources.db})\n`);
128
+ process.stdout.write(`Username: ${envelope.username} (${resolved.sources.username})\n`);
129
+ process.stdout.write(`API key: configured (${resolved.sources.api_key})\n`);
130
+ process.stdout.write(`Server version: ${envelope.server_version}\n`);
131
+ process.stdout.write(`Authenticated: yes (uid ${uid})\n`);
132
+ }
133
+ }