@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.
@@ -0,0 +1,198 @@
1
+ import { createCommand, deleteCommand, parseDate, parseDecimal, parsePriorityFlag, parseProbability, updateCommand, } from "../mutation.js";
2
+ import { listCommand, showCommand, } from "../resource.js";
3
+ /** CRM create/update field→flag mapping (§5.3). */
4
+ const crmLeadsMutation = {
5
+ createPinnedValues: { type: "lead" },
6
+ flags: [
7
+ {
8
+ flags: "--name <name>",
9
+ option: "name",
10
+ field: "name",
11
+ help: "Lead title",
12
+ requiredOnCreate: true,
13
+ },
14
+ {
15
+ flags: "--company-name <name>",
16
+ option: "companyName",
17
+ field: "partner_name",
18
+ help: "Company name",
19
+ },
20
+ {
21
+ flags: "--contact-name <name>",
22
+ option: "contactName",
23
+ field: "contact_name",
24
+ help: "Contact name",
25
+ },
26
+ {
27
+ flags: "--email <address>",
28
+ option: "email",
29
+ field: "email_from",
30
+ help: "Email",
31
+ },
32
+ {
33
+ flags: "--phone <number>",
34
+ option: "phone",
35
+ field: "phone",
36
+ help: "Phone",
37
+ },
38
+ {
39
+ flags: "--expected-revenue <amount>",
40
+ option: "expectedRevenue",
41
+ field: "expected_revenue",
42
+ help: "Expected revenue",
43
+ parse: parseDecimal,
44
+ },
45
+ {
46
+ flags: "--probability <percent>",
47
+ option: "probability",
48
+ field: "probability",
49
+ help: "Probability (0–100)",
50
+ parse: parseProbability,
51
+ },
52
+ {
53
+ flags: "--team <name|id>",
54
+ option: "team",
55
+ field: "team_id",
56
+ help: "Sales team",
57
+ reference: { model: "crm.team", label: "team" },
58
+ },
59
+ {
60
+ flags: "--stage <name|id>",
61
+ option: "stage",
62
+ field: "stage_id",
63
+ help: "Stage (scoped to --team when given)",
64
+ reference: {
65
+ model: "crm.stage",
66
+ label: "stage",
67
+ scopedBy: "team",
68
+ scope: "inclusive",
69
+ },
70
+ },
71
+ {
72
+ flags: "--assignee <name|id>",
73
+ option: "assignee",
74
+ field: "user_id",
75
+ help: "Salesperson (login, email, or name)",
76
+ reference: {
77
+ model: "res.users",
78
+ label: "user",
79
+ searchFields: ["login", "email", "name"],
80
+ },
81
+ },
82
+ {
83
+ flags: "--tag <name|id>",
84
+ option: "tag",
85
+ field: "tag_ids",
86
+ help: "Tag; repeatable, replaces all tags",
87
+ multi: true,
88
+ reference: { model: "crm.tag", label: "tag" },
89
+ },
90
+ {
91
+ flags: "--priority <level>",
92
+ option: "priority",
93
+ field: "priority",
94
+ help: "Priority: low|medium|high|urgent",
95
+ parse: parsePriorityFlag,
96
+ },
97
+ {
98
+ flags: "--deadline <yyyy-mm-dd>",
99
+ option: "deadline",
100
+ field: "date_deadline",
101
+ help: "Expected closing date",
102
+ parse: parseDate,
103
+ },
104
+ {
105
+ flags: "--source <name|id>",
106
+ option: "source",
107
+ field: "source_id",
108
+ help: "Lead source",
109
+ reference: { model: "utm.source", label: "source" },
110
+ },
111
+ {
112
+ flags: "--medium <name|id>",
113
+ option: "medium",
114
+ field: "medium_id",
115
+ help: "Lead medium",
116
+ reference: { model: "utm.medium", label: "medium" },
117
+ },
118
+ {
119
+ flags: "--description <text>",
120
+ option: "description",
121
+ field: "description",
122
+ help: "Notes",
123
+ },
124
+ ],
125
+ };
126
+ export const crmLeads = {
127
+ model: "crm.lead",
128
+ showCommand: "odoo crm leads show",
129
+ defaultListFields: [
130
+ "id",
131
+ "name",
132
+ "partner_name",
133
+ "stage_id",
134
+ "team_id",
135
+ "user_id",
136
+ "expected_revenue",
137
+ "priority",
138
+ "date_deadline",
139
+ ],
140
+ showExtraFields: [
141
+ "contact_name",
142
+ "email_from",
143
+ "phone",
144
+ "source_id",
145
+ "medium_id",
146
+ "tag_ids",
147
+ "lost_reason_id",
148
+ "probability",
149
+ "create_date",
150
+ "write_date",
151
+ ],
152
+ resolutionHints: {
153
+ stage_id: "--stage",
154
+ team_id: "--team",
155
+ user_id: "--assignee",
156
+ tag_ids: "--tag",
157
+ },
158
+ priorityLabels: {
159
+ "0": "Low",
160
+ "1": "Medium",
161
+ "2": "High",
162
+ "3": "Very High",
163
+ },
164
+ pinnedDomain: [["type", "=", "lead"]],
165
+ references: {
166
+ team: { model: "crm.team", label: "team", field: "team_id" },
167
+ stage: {
168
+ model: "crm.stage",
169
+ label: "stage",
170
+ field: "stage_id",
171
+ scope: "inclusive",
172
+ },
173
+ assignee: {
174
+ model: "res.users",
175
+ label: "user",
176
+ field: "user_id",
177
+ searchFields: ["login", "email", "name"],
178
+ },
179
+ tag: { model: "crm.tag", label: "tag", field: "tag_ids" },
180
+ },
181
+ // Phase 3 (§4.4, §4.2, §5.4): dry-run plans for create/update/delete.
182
+ mutation: crmLeadsMutation,
183
+ };
184
+ export function leadsListCommand(opts, globals) {
185
+ return listCommand(crmLeads, opts, globals);
186
+ }
187
+ export function leadsShowCommand(idArg, opts, globals) {
188
+ return showCommand(crmLeads, idArg, opts, globals);
189
+ }
190
+ export function leadsCreateCommand(opts, globals) {
191
+ return createCommand(crmLeads, opts, globals);
192
+ }
193
+ export function leadsUpdateCommand(idArgs, opts, globals) {
194
+ return updateCommand(crmLeads, idArgs, opts, globals);
195
+ }
196
+ export function leadsDeleteCommand(idArgs, globals) {
197
+ return deleteCommand(crmLeads, idArgs, globals);
198
+ }
@@ -0,0 +1,190 @@
1
+ import { createCommand, deleteCommand, parsePriorityFlag, updateCommand, } from "../mutation.js";
2
+ import { listCommand, showCommand, } from "../resource.js";
3
+ /** Helpdesk create/update field→flag mapping (§6.3). */
4
+ const helpdeskTicketsMutation = {
5
+ // --partner cannot combine with --customer-email/--customer-name (§6.3):
6
+ // a ticket either links a partner record or carries free-form customer fields.
7
+ exclusiveGroups: [
8
+ {
9
+ options: ["partner", "customerEmail", "customerName"],
10
+ hint: "a ticket either links a partner (--partner) or carries free-form customer fields (--customer-email/--customer-name)",
11
+ },
12
+ ],
13
+ flags: [
14
+ {
15
+ flags: "--subject <subject>",
16
+ option: "subject",
17
+ field: "name",
18
+ help: "Subject",
19
+ requiredOnCreate: true,
20
+ },
21
+ {
22
+ flags: "--description <text>",
23
+ option: "description",
24
+ field: "description",
25
+ help: "Description",
26
+ },
27
+ {
28
+ flags: "--team <name|id>",
29
+ option: "team",
30
+ field: "team_id",
31
+ help: "Helpdesk team",
32
+ reference: { model: "helpdesk.team", label: "team" },
33
+ },
34
+ {
35
+ flags: "--partner <name|id>",
36
+ option: "partner",
37
+ field: "partner_id",
38
+ help: "Customer (by id, exact email, or name)",
39
+ reference: {
40
+ model: "res.partner",
41
+ label: "partner",
42
+ searchFields: ["name"],
43
+ exactFields: ["email"],
44
+ },
45
+ },
46
+ {
47
+ flags: "--customer-email <address>",
48
+ option: "customerEmail",
49
+ field: "partner_email",
50
+ help: "Customer email (without a partner record)",
51
+ conflictsWith: ["partner"],
52
+ },
53
+ {
54
+ flags: "--customer-name <name>",
55
+ option: "customerName",
56
+ field: "partner_name",
57
+ help: "Customer name (without a partner record)",
58
+ conflictsWith: ["partner"],
59
+ },
60
+ {
61
+ flags: "--assignee <name|id>",
62
+ option: "assignee",
63
+ field: "user_id",
64
+ help: "Assignee (login, email, or name)",
65
+ reference: {
66
+ model: "res.users",
67
+ label: "user",
68
+ searchFields: ["login", "email", "name"],
69
+ },
70
+ },
71
+ {
72
+ flags: "--stage <name|id>",
73
+ option: "stage",
74
+ field: "stage_id",
75
+ help: "Stage (resolved within --team when given)",
76
+ reference: {
77
+ model: "helpdesk.stage",
78
+ label: "stage",
79
+ scopedBy: "team",
80
+ scope: "strict",
81
+ },
82
+ },
83
+ {
84
+ flags: "--priority <level>",
85
+ option: "priority",
86
+ field: "priority",
87
+ help: "Priority: low|medium|high|urgent",
88
+ parse: parsePriorityFlag,
89
+ },
90
+ {
91
+ flags: "--tag <name|id>",
92
+ option: "tag",
93
+ field: "tag_ids",
94
+ help: "Tag; repeatable, replaces all tags",
95
+ multi: true,
96
+ reference: { model: "helpdesk.tag", label: "tag" },
97
+ },
98
+ {
99
+ flags: "--type <name|id>",
100
+ option: "type",
101
+ field: "ticket_type_id",
102
+ help: "Ticket type",
103
+ reference: {
104
+ model: "helpdesk.ticket.type",
105
+ label: "ticket type",
106
+ mayBeAbsent: true,
107
+ },
108
+ },
109
+ ],
110
+ };
111
+ export const helpdeskTickets = {
112
+ model: "helpdesk.ticket",
113
+ showCommand: "odoo helpdesk tickets show",
114
+ defaultListFields: [
115
+ "id",
116
+ "name",
117
+ "team_id",
118
+ "stage_id",
119
+ "user_id",
120
+ "partner_id",
121
+ "priority",
122
+ "create_date",
123
+ ],
124
+ showExtraFields: [
125
+ "description",
126
+ "partner_name",
127
+ "partner_email",
128
+ "tag_ids",
129
+ "ticket_type_id",
130
+ "write_date",
131
+ ],
132
+ resolutionHints: {
133
+ stage_id: "--stage",
134
+ team_id: "--team",
135
+ user_id: "--assignee",
136
+ tag_ids: "--tag",
137
+ partner_id: "--partner",
138
+ },
139
+ priorityLabels: {
140
+ "0": "Low",
141
+ "1": "Medium",
142
+ "2": "High",
143
+ "3": "Urgent",
144
+ },
145
+ pinnedDomain: [],
146
+ references: {
147
+ team: { model: "helpdesk.team", label: "team", field: "team_id" },
148
+ stage: {
149
+ model: "helpdesk.stage",
150
+ label: "stage",
151
+ field: "stage_id",
152
+ scope: "strict",
153
+ },
154
+ assignee: {
155
+ model: "res.users",
156
+ label: "user",
157
+ field: "user_id",
158
+ searchFields: ["login", "email", "name"],
159
+ },
160
+ tag: { model: "helpdesk.tag", label: "tag", field: "tag_ids" },
161
+ },
162
+ // Phase 3 (§4.4, §4.2, §6.3): dry-run plans for create/update/delete.
163
+ mutation: helpdeskTicketsMutation,
164
+ // --open/--closed filter via stage fold (§6.1). fold is a kanban
165
+ // presentation attribute, conventionally set on closing stages — it is the
166
+ // only server-side signal available.
167
+ extraFilters: (opts) => {
168
+ const o = opts;
169
+ if (o.open)
170
+ return Promise.resolve([["stage_id.fold", "=", false]]);
171
+ if (o.closed)
172
+ return Promise.resolve([["stage_id.fold", "=", true]]);
173
+ return Promise.resolve([]);
174
+ },
175
+ };
176
+ export function ticketsListCommand(opts, globals) {
177
+ return listCommand(helpdeskTickets, opts, globals);
178
+ }
179
+ export function ticketsShowCommand(idArg, opts, globals) {
180
+ return showCommand(helpdeskTickets, idArg, opts, globals);
181
+ }
182
+ export function ticketsCreateCommand(opts, globals) {
183
+ return createCommand(helpdeskTickets, opts, globals);
184
+ }
185
+ export function ticketsUpdateCommand(idArgs, opts, globals) {
186
+ return updateCommand(helpdeskTickets, idArgs, opts, globals);
187
+ }
188
+ export function ticketsDeleteCommand(idArgs, globals) {
189
+ return deleteCommand(helpdeskTickets, idArgs, globals);
190
+ }
@@ -0,0 +1,40 @@
1
+ import { OdooClient } from "../client.js";
2
+ import { resolveConfig, missingValueHint, } from "../config.js";
3
+ import { parseDurationMs } from "../duration.js";
4
+ import { usageError } from "../errors.js";
5
+ import { printJson } from "../output.js";
6
+ /**
7
+ * `odoo version` — unauthenticated connectivity check (SPEC §2.2).
8
+ * Only --url is required; db/username/key are irrelevant here.
9
+ */
10
+ export async function versionCommand(globals) {
11
+ const flags = {
12
+ url: globals.url,
13
+ db: globals.db,
14
+ username: globals.username,
15
+ apiKeyFile: globals.apiKeyFile,
16
+ };
17
+ const resolved = await resolveConfig(flags);
18
+ if (!resolved.url) {
19
+ throw usageError("no Odoo URL configured", missingValueHint("url"));
20
+ }
21
+ const client = new OdooClient(resolved.url, undefined, undefined, undefined, {
22
+ timeoutMs: parseDurationMs(globals.timeout),
23
+ verbose: globals.verbose,
24
+ });
25
+ if (!globals.quiet) {
26
+ process.stderr.write(`Checking ${resolved.url} ...\n`);
27
+ }
28
+ const version = await client.version();
29
+ if (globals.json) {
30
+ printJson({
31
+ server_version: version.server_version,
32
+ server_version_info: version.server_version_info,
33
+ server_serie: version.server_serie,
34
+ protocol_version: version.protocol_version,
35
+ });
36
+ }
37
+ else {
38
+ process.stdout.write(`Odoo ${version.server_version}\n`);
39
+ }
40
+ }
package/dist/config.js ADDED
@@ -0,0 +1,153 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { usageError } from "./errors.js";
7
+ export function configFilePath() {
8
+ const xdg = process.env.XDG_CONFIG_HOME;
9
+ const base = xdg && xdg.trim() !== "" ? xdg : join(homedir(), ".config");
10
+ return join(base, "odoo", "config.json");
11
+ }
12
+ export async function readConfigFile() {
13
+ const path = configFilePath();
14
+ let raw;
15
+ try {
16
+ raw = await readFile(path, "utf8");
17
+ }
18
+ catch (cause) {
19
+ if (cause.code === "ENOENT")
20
+ return {};
21
+ throw usageError(`could not read config file ${path}: ${cause.message}`);
22
+ }
23
+ try {
24
+ return JSON.parse(raw);
25
+ }
26
+ catch {
27
+ throw usageError(`config file ${path} is not valid JSON`, "fix the file or re-run `odoo auth login`");
28
+ }
29
+ }
30
+ const CONFIG_KEYS = ["url", "db", "username", "api_key"];
31
+ /**
32
+ * Resolve configuration with precedence flags > env > config file (SPEC §2.1).
33
+ * `overrides.apiKey` represents a key that was already resolved by the caller
34
+ * (e.g. from stdin during `auth login`) and wins over every channel.
35
+ */
36
+ export async function resolveConfig(flags, overrides = {}) {
37
+ const file = await readConfigFile();
38
+ const apiKeyFileValue = flags.apiKeyFile
39
+ ? await readApiKeyFile(flags.apiKeyFile)
40
+ : undefined;
41
+ const candidates = {
42
+ url: [
43
+ [flags.url, "flag"],
44
+ [process.env.ODOO_URL, "env"],
45
+ [file.url, "config"],
46
+ ],
47
+ db: [
48
+ [flags.db, "flag"],
49
+ [process.env.ODOO_DB, "env"],
50
+ [file.db, "config"],
51
+ ],
52
+ username: [
53
+ [flags.username, "flag"],
54
+ [process.env.ODOO_USERNAME, "env"],
55
+ [file.username, "config"],
56
+ ],
57
+ api_key: [
58
+ [overrides.apiKey, "flag"],
59
+ [process.env.ODOO_API_KEY, "env"],
60
+ [apiKeyFileValue, "flag"],
61
+ [file.api_key, "config"],
62
+ ],
63
+ };
64
+ const resolved = {
65
+ url: undefined,
66
+ db: undefined,
67
+ username: undefined,
68
+ apiKey: undefined,
69
+ sources: { url: null, db: null, username: null, api_key: null },
70
+ };
71
+ for (const key of CONFIG_KEYS) {
72
+ for (const [value, source] of candidates[key]) {
73
+ if (value !== undefined && value !== "") {
74
+ if (key === "api_key")
75
+ resolved.apiKey = value;
76
+ else
77
+ resolved[key] = value;
78
+ resolved.sources[key] = source;
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ return resolved;
84
+ }
85
+ async function readApiKeyFile(path) {
86
+ try {
87
+ const stat = statSync(path);
88
+ // Warn (never fail) when the key file is group/world-readable (SPEC §2.1).
89
+ if (stat.mode & 0o077) {
90
+ process.stderr.write(`Warning: API key file ${path} is readable by group/others (mode ${(stat.mode & 0o777).toString(8)}); consider chmod 600\n`);
91
+ }
92
+ }
93
+ catch {
94
+ // statSync failing is reported by the readFile below.
95
+ }
96
+ let raw;
97
+ try {
98
+ raw = await readFile(path, "utf8");
99
+ }
100
+ catch (cause) {
101
+ throw usageError(`could not read API key file ${path}: ${cause.code ?? cause.message}`);
102
+ }
103
+ const key = raw.trim();
104
+ if (key === "") {
105
+ throw usageError(`API key file ${path} is empty`);
106
+ }
107
+ return key;
108
+ }
109
+ /** Atomically write the config file with mode 0600 (SPEC §2.2). */
110
+ export async function writeConfigFile(config) {
111
+ const path = configFilePath();
112
+ const tmp = join(dirname(path), `.config.json.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
113
+ const json = JSON.stringify(config, null, 2) + "\n";
114
+ mkdirSync(dirname(path), { recursive: true });
115
+ // mode 0o600 applies at creation; if the temp file somehow exists already,
116
+ // chmodSync below still enforces the final mode on the renamed file.
117
+ await writeFile(tmp, json, { encoding: "utf8", mode: 0o600 });
118
+ try {
119
+ renameSync(tmp, path);
120
+ }
121
+ catch (cause) {
122
+ rmSync(tmp, { force: true });
123
+ throw usageError(`could not write config file ${path}: ${cause.message}`);
124
+ }
125
+ // renameSync preserves the temp file's mode on POSIX; enforce 0600 regardless
126
+ // (e.g. across filesystems where the rename falls back to copy).
127
+ chmodSync(path, 0o600);
128
+ }
129
+ export function missingValues(resolved) {
130
+ const missing = [];
131
+ if (!resolved.url)
132
+ missing.push("url");
133
+ if (!resolved.db)
134
+ missing.push("db");
135
+ if (!resolved.username)
136
+ missing.push("username");
137
+ if (!resolved.apiKey)
138
+ missing.push("api_key");
139
+ return missing;
140
+ }
141
+ /** Human guidance for where each missing value can be supplied (SPEC §2.2). */
142
+ export function missingValueHint(key) {
143
+ switch (key) {
144
+ case "url":
145
+ return 'set --url, ODOO_URL, or "url" in the config file';
146
+ case "db":
147
+ return 'set --db, ODOO_DB, or "db" in the config file';
148
+ case "username":
149
+ return 'set --username, ODOO_USERNAME, or "username" in the config file';
150
+ case "api_key":
151
+ return "set ODOO_API_KEY, pass --api-key-file, or run `odoo auth login`";
152
+ }
153
+ }
@@ -0,0 +1,33 @@
1
+ import { usageError } from "./errors.js";
2
+ const UNIT_MS = {
3
+ ms: 1,
4
+ s: 1000,
5
+ m: 60_000,
6
+ };
7
+ /**
8
+ * Parse a Go-style duration string into milliseconds.
9
+ * Accepts `500ms`, `30s`, `1.5m`, `2m30s`, and a bare number meaning seconds.
10
+ */
11
+ export function parseDurationMs(input) {
12
+ const trimmed = input.trim();
13
+ if (trimmed === "") {
14
+ throw usageError(`invalid --timeout value ${JSON.stringify(input)}`, expectedHint());
15
+ }
16
+ // Bare number => seconds.
17
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
18
+ return Number(trimmed) * 1000;
19
+ }
20
+ const re = /(\d+(?:\.\d+)?)(ms|s|m)/g;
21
+ let total = 0;
22
+ let consumed = "";
23
+ let match;
24
+ while ((match = re.exec(trimmed)) !== null) {
25
+ total += Number(match[1]) * UNIT_MS[match[2]];
26
+ consumed += match[0];
27
+ }
28
+ if (consumed !== trimmed || total <= 0) {
29
+ throw usageError(`invalid --timeout value ${JSON.stringify(input)}`, expectedHint());
30
+ }
31
+ return total;
32
+ }
33
+ const expectedHint = () => "use a Go-style duration (e.g. 500ms, 30s, 1.5m, 2m30s); a bare number means seconds";
package/dist/errors.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * CLI error taxonomy. `code` is a stable machine-readable identifier used in
3
+ * the --json error envelope; `exitCode` follows SPEC §8.
4
+ */
5
+ export class CliError extends Error {
6
+ code;
7
+ exitCode;
8
+ hint;
9
+ constructor(code, message, exitCode, hint) {
10
+ super(message);
11
+ this.name = "CliError";
12
+ this.code = code;
13
+ this.exitCode = exitCode;
14
+ this.hint = hint;
15
+ }
16
+ }
17
+ export const usageError = (message, hint) => new CliError("usage_error", message, 2, hint);
18
+ export const authError = (message, hint) => new CliError("authentication_failed", message, 3, hint);
19
+ export const notFoundError = (code, message, hint) => new CliError(code, message, 4, hint);
20
+ export const conflictError = (code, message, hint) => new CliError(code, message, 5, hint);
21
+ export const networkError = (message, hint) => new CliError("network_error", message, 6, hint);