@bergetai/odoo-cli 0.11.0 → 0.12.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/README.md CHANGED
@@ -16,10 +16,17 @@ Requires Node.js 20 or later.
16
16
 
17
17
  ```bash
18
18
  # Authenticate (API key via stdin — never as a flag)
19
- pass odoo/key | odoo auth login --url https://acme.odoo.com --db acme --username automation
19
+ pass odoo/key | odoo auth login --profile production --url https://acme.odoo.com --db acme --username automation
20
+
21
+ # Authenticate a second environment (kept side by side in one config)
22
+ pass odoo/stage-key | odoo auth login --profile stage --url https://odoo.stage.berget.ai --db berget-stage --username automation
23
+
24
+ # Always select the profile explicitly — commands never guess credentials
25
+ odoo --profile production partners list --company --json
26
+ ODOO_PROFILE=stage odoo partners list --company --json
20
27
 
21
28
  # Check credentials and their configuration sources
22
- odoo auth status --json
29
+ odoo auth status --profile production --json
23
30
 
24
31
  # Unauthenticated connectivity check
25
32
  odoo version
@@ -108,7 +115,30 @@ odoo crm records message-delete 253924 258332 --dry-run
108
115
  odoo crm records message-delete 253924 258332
109
116
  ```
110
117
 
111
- 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.
118
+ Configuration precedence: flags > environment (`ODOO_URL`, `ODOO_DB`, `ODOO_USERNAME`, `ODOO_API_KEY`) > the selected profile in `$XDG_CONFIG_HOME/odoo/config.toml`. The API key is never accepted as a flag value.
119
+
120
+ ### Profiles
121
+
122
+ Credentials are stored per **profile** — one TOML table per environment:
123
+
124
+ ```toml
125
+ [profiles.production]
126
+ url = "https://odoo.berget.ai"
127
+ db = "berget"
128
+ username = "automation"
129
+ api_key = "…"
130
+ server_version = "18.0+e"
131
+
132
+ [profiles.stage]
133
+ url = "https://odoo.stage.berget.ai"
134
+ db = "berget-stage"
135
+ username = "automation"
136
+ api_key = "…"
137
+ ```
138
+
139
+ Every command requires an explicit profile: pass `--profile <name>` or set `ODOO_PROFILE`. Without one the command exits 2 — credentials are never guessed, so a command can never hit the wrong environment by accident. `auth login` and the flags-only `odoo version` check tolerate a profile that does not exist yet (bootstrap), while any other command reports an unknown profile.
140
+
141
+ Older configs (`config.json`, including top-level credential keys) keep working: they read as the `default` profile and migrate to `config.toml` on the next `auth login` — the legacy file is removed once the TOML config holds everything.
112
142
 
113
143
  Note: `odoo --version` prints the CLI's own version; `odoo version` queries the server.
114
144
 
@@ -127,7 +157,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
127
157
  - `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
128
158
  - Internal notes (`add-note`) — creates internal Log Notes only (subtype_id=2, message_type=comment), **NEVER sends email to customer**, by design prevents accidental customer communication. Optional `--author <email|name|id>` attributes the note to the partner who actually sent the logged message (resolved via `res.partner`; ambiguous matches list candidates and never guess)
129
159
  - Log note correction (`message-update`, `message-delete`) — repair formatting or remove duplicate log notes; strictly limited to comment-type messages (emails/notifications refused by design), full `--dry-run` support, unknown delete ids skipped
130
- - Config sources and precedence with atomic `0600` config writes, XML-RPC transport with timeouts and HTTPS enforcement, and an auth failure taxonomy (db missing / bad credentials / unreachable)
160
+ - Config profiles (named environments in one `0600` config file, explicit `--profile`/`ODOO_PROFILE` selection), atomic config writes with legacy migration, XML-RPC transport with timeouts and HTTPS enforcement, and an auth failure taxonomy (db missing / bad credentials / unreachable)
131
161
 
132
162
  ## Compatibility
133
163
 
package/dist/cli.js CHANGED
@@ -60,9 +60,10 @@ CLI's own version, while \`odoo version\` queries the server's version.`)
60
60
  .configureHelp({ sortSubcommands: true });
61
61
  program.addHelpText("after", `
62
62
  Examples:
63
- $ pass odoo/key | odoo auth login --url https://acme.odoo.com --db acme --username automation
64
- $ odoo auth status --json
65
- $ odoo version
63
+ $ pass odoo/key | odoo auth login --profile production --url https://acme.odoo.com --db acme --username automation
64
+ $ odoo auth login --profile stage --url https://stage.odoo.com --db stage --username automation
65
+ $ odoo auth status --profile production --json
66
+ $ odoo --profile production crm leads list
66
67
 
67
68
  Docs: https://www.odoo.com/documentation/18.0/developer/reference/external_api.html`);
68
69
  applyGlobalOptions(program);
@@ -78,8 +79,8 @@ Docs: https://www.odoo.com/documentation/18.0/developer/reference/external_api.h
78
79
  .description("verify credentials and write them to the config file (key via stdin/env/file)")
79
80
  .addHelpText("after", `
80
81
  Examples:
81
- $ pass odoo/key | odoo auth login --url https://acme.odoo.com --db acme --username automation
82
- $ ODOO_API_KEY=... odoo auth login --url https://acme.odoo.com --db acme --username automation`)
82
+ $ pass odoo/key | odoo auth login --profile production --url https://acme.odoo.com --db acme --username automation
83
+ $ ODOO_API_KEY=... odoo auth login --profile stage --url https://stage.odoo.com --db stage --username automation`)
83
84
  .action(async () => {
84
85
  await run(authLoginCommand, program);
85
86
  });
@@ -1088,6 +1089,7 @@ function collectGlobalOptions(program) {
1088
1089
  return {
1089
1090
  json: Boolean(o.json),
1090
1091
  dryRun: Boolean(o.dryRun),
1092
+ profile: o.profile,
1091
1093
  url: o.url,
1092
1094
  db: o.db,
1093
1095
  username: o.username,
@@ -1,5 +1,5 @@
1
1
  import { OdooClient } from "../client.js";
2
- import { missingValueHint, missingValues, resolveConfig, writeConfigFile, } from "../config.js";
2
+ import { missingValueHint, missingValues, profileSelectionHint, resolveConfig, resolveProfileName, effectiveProfiles, readConfigFile, writeConfigFile, } from "../config.js";
3
3
  import { parseDurationMs } from "../duration.js";
4
4
  import { usageError } from "../errors.js";
5
5
  import { printJson } from "../output.js";
@@ -12,6 +12,7 @@ async function readStdinKey() {
12
12
  }
13
13
  function authFlags(globals) {
14
14
  return {
15
+ profile: globals.profile,
15
16
  url: globals.url,
16
17
  db: globals.db,
17
18
  username: globals.username,
@@ -34,9 +35,9 @@ export async function authLoginCommand(globals) {
34
35
  else if (!process.env.ODOO_API_KEY && !globals.apiKeyFile) {
35
36
  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
  }
37
- const resolved = await resolveConfig(authFlags(globals), {
38
- apiKey: stdinKey,
39
- });
38
+ const resolved = await resolveConfig(authFlags(globals), { apiKey: stdinKey },
39
+ // Bootstrap: the profile may not exist yet — login creates it.
40
+ { allowUnknownProfile: true });
40
41
  const missing = missingValues(resolved);
41
42
  if (missing.length > 0) {
42
43
  const details = missing
@@ -61,9 +62,10 @@ export async function authLoginCommand(globals) {
61
62
  username: resolved.username,
62
63
  api_key: resolved.apiKey,
63
64
  server_version: version.server_version,
64
- });
65
+ }, resolved.profile);
65
66
  if (globals.json) {
66
67
  printJson({
68
+ profile: resolved.profile,
67
69
  url: resolved.url,
68
70
  db: resolved.db,
69
71
  username: resolved.username,
@@ -73,7 +75,7 @@ export async function authLoginCommand(globals) {
73
75
  });
74
76
  }
75
77
  else {
76
- process.stdout.write(`Logged in as '${resolved.username}' (uid ${uid}) on ${resolved.url} (Odoo ${version.server_version})\n`);
78
+ process.stdout.write(`Logged in as '${resolved.username}' (uid ${uid}) on ${resolved.url} (Odoo ${version.server_version}) [profile: ${resolved.profile}]\n`);
77
79
  }
78
80
  }
79
81
  /**
@@ -82,17 +84,50 @@ export async function authLoginCommand(globals) {
82
84
  * stdout, even when exiting non-zero (SPEC §7.3 exception).
83
85
  */
84
86
  export async function authStatusCommand(globals) {
85
- const resolved = await resolveConfig(authFlags(globals));
86
- const missing = missingValues(resolved);
87
+ // Profile resolution is handled softly so the §7.3 envelope (with the
88
+ // missing "profile" key) still reaches stdout under --json, both for a
89
+ // missing selection and for a selected profile with no stored credentials.
90
+ const profileName = resolveProfileName(globals);
87
91
  const envelope = {
88
- url: resolved.url ?? null,
89
- db: resolved.db ?? null,
90
- username: resolved.username ?? null,
92
+ profile: profileName ?? null,
93
+ url: null,
94
+ db: null,
95
+ username: null,
91
96
  server_version: null,
92
97
  uid: null,
93
98
  authenticated: false,
94
- config_source: resolved.sources,
99
+ config_source: null,
95
100
  };
101
+ if (!profileName) {
102
+ const hint = await profileSelectionHint();
103
+ if (globals.json) {
104
+ envelope.missing = ["profile"];
105
+ printJson(envelope);
106
+ }
107
+ else {
108
+ process.stderr.write(`Error: no profile selected — ${hint}\n`);
109
+ }
110
+ process.exitCode = 2;
111
+ return;
112
+ }
113
+ const knownProfiles = effectiveProfiles(await readConfigFile());
114
+ if (!knownProfiles.has(profileName)) {
115
+ if (globals.json) {
116
+ envelope.missing = ["profile"];
117
+ printJson(envelope);
118
+ }
119
+ else {
120
+ process.stderr.write(`Error: no stored credentials for profile ${JSON.stringify(profileName)} — run \`odoo auth login --profile ${profileName} ...\`\n`);
121
+ }
122
+ process.exitCode = 2;
123
+ return;
124
+ }
125
+ const resolved = await resolveConfig(authFlags(globals));
126
+ envelope.url = resolved.url ?? null;
127
+ envelope.db = resolved.db ?? null;
128
+ envelope.username = resolved.username ?? null;
129
+ envelope.config_source = resolved.sources;
130
+ const missing = missingValues(resolved);
96
131
  if (missing.length > 0) {
97
132
  envelope.missing = missing;
98
133
  if (globals.json) {
@@ -123,6 +158,7 @@ export async function authStatusCommand(globals) {
123
158
  printJson(envelope);
124
159
  }
125
160
  else {
161
+ process.stdout.write(`Profile: ${profileName}\n`);
126
162
  process.stdout.write(`URL: ${envelope.url} (${resolved.sources.url})\n`);
127
163
  process.stdout.write(`Database: ${envelope.db} (${resolved.sources.db})\n`);
128
164
  process.stdout.write(`Username: ${envelope.username} (${resolved.sources.username})\n`);
@@ -1,5 +1,5 @@
1
1
  import { OdooClient } from "../client.js";
2
- import { resolveConfig, missingValueHint, } from "../config.js";
2
+ import { resolveConfig, missingValueHint } from "../config.js";
3
3
  import { parseDurationMs } from "../duration.js";
4
4
  import { usageError } from "../errors.js";
5
5
  import { printJson } from "../output.js";
@@ -9,12 +9,16 @@ import { printJson } from "../output.js";
9
9
  */
10
10
  export async function versionCommand(globals) {
11
11
  const flags = {
12
+ profile: globals.profile,
12
13
  url: globals.url,
13
14
  db: globals.db,
14
15
  username: globals.username,
15
16
  apiKeyFile: globals.apiKeyFile,
16
17
  };
17
- const resolved = await resolveConfig(flags);
18
+ const resolved = await resolveConfig(flags, {},
19
+ // Flags-only connectivity check — profile values are unused, so a
20
+ // not-yet-existing profile is fine.
21
+ { allowUnknownProfile: true });
18
22
  if (!resolved.url) {
19
23
  throw usageError("no Odoo URL configured", missingValueHint("url"));
20
24
  }
package/dist/config.js CHANGED
@@ -3,8 +3,15 @@ import { chmodSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
3
3
  import { readFile, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
6
7
  import { usageError } from "./errors.js";
7
8
  export function configFilePath() {
9
+ const xdg = process.env.XDG_CONFIG_HOME;
10
+ const base = xdg && xdg.trim() !== "" ? xdg : join(homedir(), ".config");
11
+ return join(base, "odoo", "config.toml");
12
+ }
13
+ /** Pre-TOML config location; supported read-only for one-way migration. */
14
+ function legacyConfigFilePath() {
8
15
  const xdg = process.env.XDG_CONFIG_HOME;
9
16
  const base = xdg && xdg.trim() !== "" ? xdg : join(homedir(), ".config");
10
17
  return join(base, "odoo", "config.json");
@@ -15,6 +22,26 @@ export async function readConfigFile() {
15
22
  try {
16
23
  raw = await readFile(path, "utf8");
17
24
  }
25
+ catch (cause) {
26
+ // No config.toml — fall back to the legacy JSON config, if any.
27
+ if (cause.code === "ENOENT") {
28
+ return readLegacyJsonConfigFile();
29
+ }
30
+ throw usageError(`could not read config file ${path}: ${cause.message}`);
31
+ }
32
+ try {
33
+ return parseToml(raw);
34
+ }
35
+ catch {
36
+ throw usageError(`config file ${path} is not valid TOML`, "fix the file or re-run `odoo auth login`");
37
+ }
38
+ }
39
+ async function readLegacyJsonConfigFile() {
40
+ const path = legacyConfigFilePath();
41
+ let raw;
42
+ try {
43
+ raw = await readFile(path, "utf8");
44
+ }
18
45
  catch (cause) {
19
46
  if (cause.code === "ENOENT")
20
47
  return {};
@@ -27,14 +54,85 @@ export async function readConfigFile() {
27
54
  throw usageError(`config file ${path} is not valid JSON`, "fix the file or re-run `odoo auth login`");
28
55
  }
29
56
  }
57
+ const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
58
+ /**
59
+ * Profile selection: `--profile` flag > `ODOO_PROFILE` env. Returns undefined
60
+ * when neither is set (callers decide whether that is fatal).
61
+ */
62
+ export function resolveProfileName(flags) {
63
+ const raw = flags?.profile ?? process.env.ODOO_PROFILE;
64
+ const name = raw?.trim();
65
+ if (!name)
66
+ return undefined;
67
+ if (!PROFILE_NAME_RE.test(name)) {
68
+ throw usageError(`invalid profile name ${JSON.stringify(name)}`, "use letters, digits, '.', '_' or '-' (must start with a letter or digit)");
69
+ }
70
+ return name;
71
+ }
72
+ /**
73
+ * Effective profiles: explicit `profiles` entries, plus the legacy top-level
74
+ * keys, which read as the "default" profile when no profiles.default exists.
75
+ */
76
+ export function effectiveProfiles(file) {
77
+ const profiles = new Map();
78
+ const hasExplicitDefault = Object.hasOwn(file.profiles ?? {}, "default");
79
+ if (!hasExplicitDefault) {
80
+ const legacy = {};
81
+ let hasLegacy = false;
82
+ for (const key of CONFIG_KEYS) {
83
+ const value = file[key];
84
+ if (value !== undefined) {
85
+ legacy[key] = value;
86
+ hasLegacy = true;
87
+ }
88
+ }
89
+ if (hasLegacy)
90
+ profiles.set("default", legacy);
91
+ }
92
+ for (const [name, credentials] of Object.entries(file.profiles ?? {})) {
93
+ profiles.set(name, credentials);
94
+ }
95
+ return profiles;
96
+ }
97
+ /** Sorted names of the profiles a config file effectively provides. */
98
+ export async function availableProfileNames() {
99
+ const profiles = effectiveProfiles(await readConfigFile());
100
+ return [...profiles.keys()].sort();
101
+ }
102
+ export async function profileSelectionHint() {
103
+ const available = await availableProfileNames();
104
+ return available.length > 0
105
+ ? `pass --profile <name> or set ODOO_PROFILE (configured profiles: ${available.join(", ")})`
106
+ : "pass --profile <name> or set ODOO_PROFILE (no profiles configured yet — run `odoo auth login --profile <name> ...`)";
107
+ }
30
108
  const CONFIG_KEYS = ["url", "db", "username", "api_key"];
31
109
  /**
32
- * Resolve configuration with precedence flags > env > config file (SPEC §2.1).
110
+ * Resolve configuration with precedence flags > env > profile (SPEC §2.1).
111
+ * A profile must be selected explicitly (`--profile` or `ODOO_PROFILE`) —
112
+ * commands never fall back to "some" credentials, so a command can never hit
113
+ * the wrong environment by accident.
33
114
  * `overrides.apiKey` represents a key that was already resolved by the caller
34
115
  * (e.g. from stdin during `auth login`) and wins over every channel.
35
116
  */
36
- export async function resolveConfig(flags, overrides = {}) {
117
+ export async function resolveConfig(flags, overrides = {}, options = {}) {
118
+ const profile = resolveProfileName(flags);
119
+ if (!profile) {
120
+ throw usageError("no profile selected", await profileSelectionHint());
121
+ }
37
122
  const file = await readConfigFile();
123
+ const profiles = effectiveProfiles(file);
124
+ let credentials = profiles.get(profile);
125
+ if (!credentials) {
126
+ if (options.allowUnknownProfile) {
127
+ // Bootstrap: `auth login`/`version` never read profile values, so a
128
+ // not-yet-existing profile is fine (all needed values come from
129
+ // flags/env). Regular commands still fail below.
130
+ credentials = {};
131
+ }
132
+ else {
133
+ throw usageError(`unknown profile ${JSON.stringify(profile)}`, await profileSelectionHint());
134
+ }
135
+ }
38
136
  const apiKeyFileValue = flags.apiKeyFile
39
137
  ? await readApiKeyFile(flags.apiKeyFile)
40
138
  : undefined;
@@ -42,26 +140,27 @@ export async function resolveConfig(flags, overrides = {}) {
42
140
  url: [
43
141
  [flags.url, "flag"],
44
142
  [process.env.ODOO_URL, "env"],
45
- [file.url, "config"],
143
+ [credentials.url, "config"],
46
144
  ],
47
145
  db: [
48
146
  [flags.db, "flag"],
49
147
  [process.env.ODOO_DB, "env"],
50
- [file.db, "config"],
148
+ [credentials.db, "config"],
51
149
  ],
52
150
  username: [
53
151
  [flags.username, "flag"],
54
152
  [process.env.ODOO_USERNAME, "env"],
55
- [file.username, "config"],
153
+ [credentials.username, "config"],
56
154
  ],
57
155
  api_key: [
58
156
  [overrides.apiKey, "flag"],
59
157
  [process.env.ODOO_API_KEY, "env"],
60
158
  [apiKeyFileValue, "flag"],
61
- [file.api_key, "config"],
159
+ [credentials.api_key, "config"],
62
160
  ],
63
161
  };
64
162
  const resolved = {
163
+ profile,
65
164
  url: undefined,
66
165
  db: undefined,
67
166
  username: undefined,
@@ -106,15 +205,25 @@ async function readApiKeyFile(path) {
106
205
  }
107
206
  return key;
108
207
  }
109
- /** Atomically write the config file with mode 0600 (SPEC §2.2). */
110
- export async function writeConfigFile(config) {
208
+ /**
209
+ * Atomically write the credentials for one profile with mode 0600 (SPEC §2.2),
210
+ * as TOML. Preserves every other profile; the legacy top-level keys and
211
+ * `config.json` are folded in, so the first write completes the migration.
212
+ */
213
+ export async function writeConfigFile(config, profile) {
214
+ const profiles = effectiveProfiles(await readConfigFile());
215
+ profiles.set(profile, config);
216
+ const next = { profiles: {} };
217
+ for (const name of [...profiles.keys()].sort()) {
218
+ next.profiles[name] = profiles.get(name);
219
+ }
111
220
  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";
221
+ const tmp = join(dirname(path), `.config.toml.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
222
+ const toml = `${stringifyToml(next)}\n`;
114
223
  mkdirSync(dirname(path), { recursive: true });
115
224
  // mode 0o600 applies at creation; if the temp file somehow exists already,
116
225
  // chmodSync below still enforces the final mode on the renamed file.
117
- await writeFile(tmp, json, { encoding: "utf8", mode: 0o600 });
226
+ await writeFile(tmp, toml, { encoding: "utf8", mode: 0o600 });
118
227
  try {
119
228
  renameSync(tmp, path);
120
229
  }
@@ -125,6 +234,9 @@ export async function writeConfigFile(config) {
125
234
  // renameSync preserves the temp file's mode on POSIX; enforce 0600 regardless
126
235
  // (e.g. across filesystems where the rename falls back to copy).
127
236
  chmodSync(path, 0o600);
237
+ // Migration complete: the TOML file now holds everything, so the legacy
238
+ // JSON config would only risk divergence (it is shadowed on reads).
239
+ rmSync(legacyConfigFilePath(), { force: true });
128
240
  }
129
241
  export function missingValues(resolved) {
130
242
  const missing = [];
@@ -4,6 +4,7 @@ export const DEFAULT_TIMEOUT = "30s";
4
4
  export function applyGlobalOptions(cmd) {
5
5
  cmd.option("--json", "emit a single JSON object on stdout");
6
6
  cmd.option("--dry-run, -n", "preview the mutation without applying it");
7
+ cmd.option("--profile <name>", "configuration profile to use (env: ODOO_PROFILE)");
7
8
  cmd.option("--url <url>", "Odoo server URL (env: ODOO_URL)");
8
9
  cmd.option("--db <name>", "database name (env: ODOO_DB)");
9
10
  cmd.option("--username <login>", "login (env: ODOO_USERNAME)");
package/dist/resource.js CHANGED
@@ -189,6 +189,7 @@ function humanCellWithLabels(config, field, value) {
189
189
  }
190
190
  export async function authenticatedClient(globals) {
191
191
  const resolved = await resolveConfig({
192
+ profile: globals.profile,
192
193
  url: globals.url,
193
194
  db: globals.db,
194
195
  username: globals.username,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "commander": "^15.0.0",
38
+ "smol-toml": "^1.8.0",
38
39
  "xmlrpc": "^1.3.2"
39
40
  },
40
41
  "devDependencies": {