@bergetai/odoo-cli 0.9.2 → 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/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.9.2",
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": {