@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 +21 -0
- package/README.md +54 -0
- package/dist/cli.js +249 -0
- package/dist/client.js +97 -0
- package/dist/commands/auth.js +133 -0
- package/dist/commands/crm.js +198 -0
- package/dist/commands/helpdesk.js +190 -0
- package/dist/commands/version.js +40 -0
- package/dist/config.js +153 -0
- package/dist/duration.js +33 -0
- package/dist/errors.js +21 -0
- package/dist/filter.js +61 -0
- package/dist/global-options.js +40 -0
- package/dist/model.js +149 -0
- package/dist/mutation.js +605 -0
- package/dist/output.js +39 -0
- package/dist/resource.js +211 -0
- package/dist/table.js +44 -0
- package/dist/xmlrpc.js +102 -0
- package/package.json +49 -0
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
|
+
}
|