@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/dist/filter.js ADDED
@@ -0,0 +1,61 @@
1
+ import { usageError } from "./errors.js";
2
+ /**
3
+ * Parse a --domain JSON array verbatim (SPEC §4.6). Must be a JSON array of
4
+ * domain clauses; anything else is a usage error quoting the input.
5
+ */
6
+ export function parseDomain(raw) {
7
+ let parsed;
8
+ try {
9
+ parsed = JSON.parse(raw);
10
+ }
11
+ catch {
12
+ throw usageError(`invalid --domain JSON: ${JSON.stringify(raw)}`, 'expected an Odoo domain as a JSON array, e.g. [["is_company","=",true]]');
13
+ }
14
+ if (!Array.isArray(parsed)) {
15
+ throw usageError(`invalid --domain: expected a JSON array, got ${typeof parsed}`, 'expected an Odoo domain as a JSON array, e.g. [["is_company","=",true]]');
16
+ }
17
+ return parsed;
18
+ }
19
+ /** Parse a --filter value as a JSON scalar when possible, else a string. */
20
+ export function parseFilterScalar(raw) {
21
+ const trimmed = raw.trim();
22
+ if (trimmed === "")
23
+ return "";
24
+ try {
25
+ return JSON.parse(trimmed);
26
+ }
27
+ catch {
28
+ return raw;
29
+ }
30
+ }
31
+ /**
32
+ * Build domain clauses from repeatable --filter f=v flags (SPEC §4.6).
33
+ * many2one fields only accept numeric ids — a non-numeric value is a usage
34
+ * error pointing at the resolution flags, never a silently empty result.
35
+ */
36
+ export function buildFilterClauses(filters, fields, resolutionHints) {
37
+ const clauses = [];
38
+ for (const filter of filters) {
39
+ const eq = filter.indexOf("=");
40
+ if (eq <= 0) {
41
+ throw usageError(`invalid --filter ${JSON.stringify(filter)}`, "expected the form field=value");
42
+ }
43
+ const field = filter.slice(0, eq);
44
+ const rawValue = filter.slice(eq + 1);
45
+ const meta = fields.get(field);
46
+ if (!meta) {
47
+ throw usageError(`unknown field ${JSON.stringify(field)} in --filter`, "check the field name with `odoo crm leads show <id> --all-fields`");
48
+ }
49
+ const value = parseFilterScalar(rawValue);
50
+ if (meta.type === "many2one" &&
51
+ typeof value !== "number" &&
52
+ value !== false) {
53
+ const hintFlag = resolutionHints[field];
54
+ throw usageError(`--filter ${field}=... only accepts a numeric record id (got ${JSON.stringify(rawValue)})`, hintFlag
55
+ ? `use ${hintFlag} for name-based matching, or --domain for arbitrary domains`
56
+ : "use --domain for non-id matching");
57
+ }
58
+ clauses.push([field, "=", value]);
59
+ }
60
+ return clauses;
61
+ }
@@ -0,0 +1,40 @@
1
+ import { usageError } from "./errors.js";
2
+ export const DEFAULT_TIMEOUT = "30s";
3
+ /** Wire the §3 global flags onto a commander program/command. */
4
+ export function applyGlobalOptions(cmd) {
5
+ cmd.option("--json", "emit a single JSON object on stdout");
6
+ cmd.option("--dry-run, -n", "preview the mutation without applying it");
7
+ cmd.option("--url <url>", "Odoo server URL (env: ODOO_URL)");
8
+ cmd.option("--db <name>", "database name (env: ODOO_DB)");
9
+ cmd.option("--username <login>", "login (env: ODOO_USERNAME)");
10
+ cmd.option("--api-key-file <path>", "read the API key from a file");
11
+ cmd.option("--allow-insecure-http", "permit plain HTTP to non-localhost hosts");
12
+ cmd.option("--timeout <duration>", `per-request timeout (default: ${DEFAULT_TIMEOUT}, env: ODOO_TIMEOUT)`);
13
+ cmd.option("--quiet, -q", "suppress non-essential stderr output");
14
+ cmd.option("--verbose", "debug output on stderr (secrets redacted)");
15
+ cmd.option("--no-color", "disable color output");
16
+ }
17
+ /**
18
+ * Enforce SPEC §11: HTTPS required unless the host is localhost/127.0.0.1;
19
+ * plain HTTP elsewhere needs --allow-insecure-http.
20
+ */
21
+ export function enforceTransportSecurity(url, allowInsecureHttp) {
22
+ let parsed;
23
+ try {
24
+ parsed = new URL(url);
25
+ }
26
+ catch {
27
+ throw usageError(`invalid URL ${JSON.stringify(url)}`, "check --url");
28
+ }
29
+ if (parsed.protocol === "https:")
30
+ return;
31
+ if (parsed.protocol === "http:") {
32
+ const host = parsed.hostname;
33
+ if (host === "localhost" || host === "127.0.0.1")
34
+ return;
35
+ if (allowInsecureHttp)
36
+ return;
37
+ throw usageError(`refusing plain HTTP to non-localhost host ${JSON.stringify(host)}`, "use HTTPS, or pass --allow-insecure-http for dev/CI environments");
38
+ }
39
+ throw usageError(`unsupported URL scheme ${JSON.stringify(parsed.protocol)} in ${JSON.stringify(url)}`, "use an http(s):// URL");
40
+ }
package/dist/model.js ADDED
@@ -0,0 +1,149 @@
1
+ import { conflictError, notFoundError } from "./errors.js";
2
+ import { XmlRpcFaultError } from "./xmlrpc.js";
3
+ /** Cached fields_get metadata for one model (one call per model per process,
4
+ * SPEC §4.6).
5
+ */
6
+ export class ModelFields {
7
+ fields;
8
+ static cache = new Map();
9
+ constructor(fields) {
10
+ this.fields = fields;
11
+ }
12
+ static async load(client, model) {
13
+ const existing = ModelFields.cache.get(model);
14
+ if (existing)
15
+ return existing;
16
+ const promise = ModelFields.uncachedLoad(client, model);
17
+ ModelFields.cache.set(model, promise);
18
+ // Drop the cache entry on failure so a later retry can re-attempt.
19
+ promise.catch(() => ModelFields.cache.delete(model));
20
+ return promise;
21
+ }
22
+ static async uncachedLoad(client, model) {
23
+ let result;
24
+ try {
25
+ result = await client.executeKw(model, "fields_get", [[]], {
26
+ attributes: ["string", "type", "relation"],
27
+ });
28
+ }
29
+ catch (error) {
30
+ throw mapModelMissing(error, model);
31
+ }
32
+ if (typeof result !== "object" || result === null) {
33
+ throw notFoundError("model_not_found", `model ${JSON.stringify(model)} not found on the server`, `the ${model.split(".")[0]} module may not be installed`);
34
+ }
35
+ return new ModelFields(result);
36
+ }
37
+ get(name) {
38
+ return this.fields[name];
39
+ }
40
+ }
41
+ const INVALID_MODEL_RE = /(?:Invalid model|Model not found)[: ]+['"]?([\w.]+)['"]?/;
42
+ /**
43
+ * §1: if a module's model is absent from the server (e.g. helpdesk.ticket —
44
+ * Helpdesk is an Enterprise app), fail with exit 4 and a hint naming the
45
+ * module rather than a raw server fault.
46
+ */
47
+ function mapModelMissing(error, model) {
48
+ if (error instanceof XmlRpcFaultError) {
49
+ const match = INVALID_MODEL_RE.exec(error.faultString);
50
+ if (match) {
51
+ const module = model.split(".")[0];
52
+ return notFoundError("model_not_found", `${module} module not installed (model '${model}' not found)`, `install the ${module} app on the server to use these commands`);
53
+ }
54
+ }
55
+ return error;
56
+ }
57
+ /**
58
+ * Resolve a record reference by numeric id or case-insensitive name
59
+ * (SPEC §4.3). Exactly one match required; zero matches list available
60
+ * options (exit 4), multiple matches list candidates (exit 5). Never guesses.
61
+ */
62
+ export async function resolveReference(client, model, input, opts = {}) {
63
+ const label = opts.label ?? model;
64
+ const searchFields = opts.searchFields ?? ["name"];
65
+ const baseDomain = opts.domain ?? [];
66
+ // Numeric ids are used verbatim but still verified — with active_test off
67
+ // so an archived record is reported as archived, not "not found" (§4.3).
68
+ // Only models with an `active` field carry the archive concept; requesting
69
+ // `active` on a model that lacks it faults server-side, so introspect.
70
+ if (/^\d+$/.test(input)) {
71
+ const id = Number(input);
72
+ const refMeta = await ModelFields.load(client, model);
73
+ const hasActive = refMeta.get("active") !== undefined;
74
+ const fields = hasActive ? ["display_name", "active"] : ["display_name"];
75
+ const rows = (await client.executeKw(model, "search_read", [[["id", "=", id]]], {
76
+ fields,
77
+ limit: 1,
78
+ context: { active_test: false },
79
+ }));
80
+ if (rows.length === 0) {
81
+ throw notFoundError("reference_not_found", `no ${label} with id ${id}`);
82
+ }
83
+ if (hasActive && rows[0].active === false) {
84
+ throw notFoundError("reference_archived", `${label} ${id} "${rows[0].display_name}" is archived`);
85
+ }
86
+ return { id, name: rows[0].display_name };
87
+ }
88
+ // Name resolution: OR across searchable fields. `exactFields` use a
89
+ // case-sensitive `=` (e.g. res.partner email); `searchFields` use ilike.
90
+ const nameClauses = [];
91
+ for (const field of searchFields) {
92
+ nameClauses.push([field, "ilike", input]);
93
+ }
94
+ for (const field of opts.exactFields ?? []) {
95
+ nameClauses.push([field, "=", input]);
96
+ }
97
+ const domain = nameClauses.length === 1
98
+ ? [...baseDomain, nameClauses[0]]
99
+ : [...baseDomain, ...orChain(nameClauses)];
100
+ const rows = (await client.executeKw(model, "search_read", [domain], {
101
+ fields: ["display_name"],
102
+ limit: 20,
103
+ }));
104
+ if (rows.length === 1) {
105
+ return { id: rows[0].id, name: rows[0].display_name };
106
+ }
107
+ if (rows.length === 0) {
108
+ const options = await listOptions(client, model, baseDomain);
109
+ throw notFoundError("reference_not_found", `no ${label} named "${input}"`, options ? `available: ${options}` : undefined);
110
+ }
111
+ throw conflictError("ambiguous_reference", `"${input}" matches ${rows.length} ${label} records`, `candidates: ${rows.map((r) => `${r.display_name} (${r.id})`).join(", ")} — use a numeric id`);
112
+ }
113
+ function orChain(clauses) {
114
+ // Odoo prefix notation: OR-ing n clauses takes n-1 "|" operators, each
115
+ // consuming the next two expressions. [a, b, c] -> ["|", "|", a, b, c]
116
+ // which parses as ((a | b) | c).
117
+ if (clauses.length <= 1)
118
+ return clauses;
119
+ const operators = Array.from({ length: clauses.length - 1 }, () => "|");
120
+ return [...operators, ...clauses];
121
+ }
122
+ async function listOptions(client, model, domain) {
123
+ const rows = (await client.executeKw(model, "search_read", [domain], {
124
+ fields: ["display_name"],
125
+ limit: 10,
126
+ order: "id asc",
127
+ }));
128
+ if (rows.length === 0)
129
+ return undefined;
130
+ return rows.map((r) => `${r.display_name} (${r.id})`).join(", ");
131
+ }
132
+ /**
133
+ * Build the scoping domain for stage resolution under a --team
134
+ * (SPEC §4.3): CRM stages may be team-less (shared), so an inclusive
135
+ * scope matches the team's stages plus global ones; Helpdesk scopes
136
+ * strictly to the team, introspecting the stage model's team-link field
137
+ * (team_id m2o vs team_ids m2m) rather than assuming it.
138
+ */
139
+ export async function stageScopeDomain(client, stageModel, scope, teamId) {
140
+ if (scope === "inclusive") {
141
+ return [["team_id", "in", [teamId, false]]];
142
+ }
143
+ const stageMeta = await ModelFields.load(client, stageModel);
144
+ if (stageMeta.get("team_id"))
145
+ return [["team_id", "=", teamId]];
146
+ if (stageMeta.get("team_ids"))
147
+ return [["team_ids", "in", [teamId]]];
148
+ return [];
149
+ }