@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.
@@ -0,0 +1,184 @@
1
+ import { ModelFields, resolveReference } from "../model.js";
2
+ import { authenticatedClient, showCommand, } from "../resource.js";
3
+ import { deleteCommand } from "../mutation.js";
4
+ import { usageError } from "../errors.js";
5
+ import { colorEnabled, printJson } from "../output.js";
6
+ import { formatTable, humanCell } from "../table.js";
7
+ import { parseDomain } from "../filter.js";
8
+ /**
9
+ * Marketing surface (mailing.list / mailing.contact / mailing.trace).
10
+ *
11
+ * Read-only lists; contacts and traces support explicit, id-based deletion —
12
+ * mail data is personal data for erasure (invariant 4): `mailing.trace`
13
+ * carries a denormalized `email` with NO FK to `mailing.contact`, so deleting
14
+ * contacts never removes traces; both deletions are explicit CLI steps.
15
+ *
16
+ * Email is the only reliable key (name matching showed casing variance in
17
+ * prod): `contacts list --email` matches the email column alone; `--search`
18
+ * keeps the repo-wide name-or-email ilike for interactive browsing.
19
+ */
20
+ export const mailingList = {
21
+ model: "mailing.list",
22
+ showCommand: "odoo marketing lists show",
23
+ defaultListFields: ["id", "name", "contact_count", "active", "create_date"],
24
+ showExtraFields: [
25
+ "is_public",
26
+ "mailing_count",
27
+ "subscription_ids",
28
+ "write_date",
29
+ ],
30
+ resolutionHints: {},
31
+ priorityLabels: {},
32
+ pinnedDomain: [],
33
+ references: {},
34
+ };
35
+ export const mailingContact = {
36
+ model: "mailing.contact",
37
+ showCommand: "odoo marketing contacts show",
38
+ defaultListFields: ["id", "email", "name", "is_blacklisted", "create_date"],
39
+ showExtraFields: [
40
+ "list_ids",
41
+ "subscription_ids",
42
+ "email_normalized",
43
+ "company_name",
44
+ "tag_ids",
45
+ "write_date",
46
+ ],
47
+ resolutionHints: {},
48
+ priorityLabels: {},
49
+ pinnedDomain: [],
50
+ references: {},
51
+ };
52
+ export const mailingTrace = {
53
+ model: "mailing.trace",
54
+ showCommand: "odoo marketing traces show",
55
+ defaultListFields: [
56
+ "id",
57
+ "email",
58
+ "model",
59
+ "res_id",
60
+ "trace_status",
61
+ "mass_mailing_id",
62
+ "create_date",
63
+ ],
64
+ showExtraFields: [
65
+ "trace_type",
66
+ "sent_datetime",
67
+ "open_datetime",
68
+ "reply_datetime",
69
+ "failure_type",
70
+ "write_date",
71
+ ],
72
+ resolutionHints: {},
73
+ priorityLabels: {},
74
+ pinnedDomain: [],
75
+ references: {},
76
+ };
77
+ function parseCount(raw, flag) {
78
+ if (!/^\d+$/.test(raw)) {
79
+ throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
80
+ }
81
+ return Number(raw);
82
+ }
83
+ /** Shared list machinery for the three read-only/list surfaces. */
84
+ async function runList(config, opts, domain, globals) {
85
+ const client = await authenticatedClient(globals);
86
+ const fieldsMeta = await ModelFields.load(client, config.model);
87
+ const limit = parseCount(opts.limit, "--limit");
88
+ const offset = parseCount(opts.offset, "--offset");
89
+ const fields = opts.fields
90
+ ? opts.fields
91
+ .split(",")
92
+ .map((f) => f.trim())
93
+ .filter((f) => f !== "")
94
+ : config.defaultListFields;
95
+ for (const field of fields) {
96
+ if (field !== "id" && !fieldsMeta.get(field)) {
97
+ throw usageError(`unknown field ${JSON.stringify(field)} on ${config.model}`, `run \`odoo models fields ${config.model}\` to check availability`);
98
+ }
99
+ }
100
+ const order = opts.order ?? "id asc";
101
+ const [records, count] = await Promise.all([
102
+ client.executeKw(config.model, "search_read", [domain], {
103
+ fields,
104
+ limit,
105
+ offset,
106
+ order,
107
+ }),
108
+ client.executeKw(config.model, "search_count", [domain]),
109
+ ]);
110
+ if (globals.json) {
111
+ printJson({ model: config.model, records, count, limit, offset });
112
+ return;
113
+ }
114
+ const headers = fields.map((f) => fieldsMeta.get(f)?.string ?? f);
115
+ const rows = records.map((rec) => fields.map((f) => humanCell(rec[f])));
116
+ process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
117
+ }
118
+ export async function listsListCommand(opts, globals) {
119
+ const domain = [];
120
+ if (opts.search)
121
+ domain.push(["name", "ilike", opts.search]);
122
+ if (opts.domain)
123
+ domain.push(...parseDomain(opts.domain));
124
+ return runList(mailingList, opts, domain, globals);
125
+ }
126
+ export function listsShowCommand(idArg, opts, globals) {
127
+ return showCommand(mailingList, idArg, opts, globals);
128
+ }
129
+ /**
130
+ * `--list` resolves a mailing list by name (exact first, then ilike via the
131
+ * shared reference resolution) and filters contacts through the `list_ids`
132
+ * m2m (flat "contains" domain — verified against prod fields_get).
133
+ */
134
+ export async function contactsListCommand(opts, globals) {
135
+ const client = await authenticatedClient(globals);
136
+ await ModelFields.load(client, "mailing.contact"); // module-installed check
137
+ const domain = [];
138
+ if (opts.list) {
139
+ const list = await resolveReference(client, "mailing.list", opts.list, {
140
+ label: "mailing list",
141
+ });
142
+ domain.push(["list_ids", "=", list.id]);
143
+ }
144
+ if (opts.email && opts.search) {
145
+ throw usageError("--email and --search are mutually exclusive", "--email is the identity-safe filter (email column only); --search matches name OR email");
146
+ }
147
+ if (opts.email) {
148
+ domain.push(["email", "ilike", opts.email]);
149
+ }
150
+ else if (opts.search) {
151
+ domain.push("|", ["name", "ilike", opts.search], ["email", "ilike", opts.search]);
152
+ }
153
+ if (opts.domain)
154
+ domain.push(...parseDomain(opts.domain));
155
+ return runList(mailingContact, opts, domain, globals);
156
+ }
157
+ export function contactsShowCommand(idArg, opts, globals) {
158
+ return showCommand(mailingContact, idArg, opts, globals);
159
+ }
160
+ export function contactsDeleteCommand(idArgs, globals) {
161
+ return deleteCommand(mailingContact, idArgs, globals);
162
+ }
163
+ export async function tracesListCommand(opts, globals) {
164
+ const client = await authenticatedClient(globals);
165
+ await ModelFields.load(client, "mailing.trace"); // module-installed check
166
+ const domain = [];
167
+ if (opts.email)
168
+ domain.push(["email", "ilike", opts.email]);
169
+ if (opts.mailing) {
170
+ const mailing = await resolveReference(client, "mailing.mailing", opts.mailing, { label: "mailing" });
171
+ domain.push(["mass_mailing_id", "=", mailing.id]);
172
+ }
173
+ if (opts.state)
174
+ domain.push(["trace_status", "=", opts.state]);
175
+ if (opts.domain)
176
+ domain.push(...parseDomain(opts.domain));
177
+ return runList(mailingTrace, opts, domain, globals);
178
+ }
179
+ export function tracesShowCommand(idArg, opts, globals) {
180
+ return showCommand(mailingTrace, idArg, opts, globals);
181
+ }
182
+ export function tracesDeleteCommand(idArgs, globals) {
183
+ return deleteCommand(mailingTrace, idArgs, globals);
184
+ }
@@ -0,0 +1,309 @@
1
+ import { authenticatedClient, parseId, showCommand, } from "../resource.js";
2
+ import { usageError } from "../errors.js";
3
+ import { printJson } from "../output.js";
4
+ import { parseDomain } from "../filter.js";
5
+ /**
6
+ * mail.message surface, erasure-scoped. Chatter attaches to every model
7
+ * (res.partner, helpdesk.ticket, crm.lead, ...), so `list` REQUIRES an
8
+ * explicit scope (--res-model / --res-id) and `scrub`/`delete` REQUIRE BOTH
9
+ * and cross-check every id against it — a message on another record is
10
+ * skipped, never touched. Bodies are excluded from `list` output (metadata
11
+ * plus lengths only); `show` reads the body for the single-record case.
12
+ *
13
+ * `scrub` implements the erasure SOP's tracked-change remediation: removing
14
+ * personal data from a record leaves a chatter `mail.message` whose
15
+ * `mail.tracking.value` rows carry the PRE-change values (old email, old
16
+ * name). Scrub unlinks those rows and rewrites the body to an erasure
17
+ * marker. The default marker is fixed SOP text (decision #1 discipline);
18
+ * `--marker` overrides it for message classes on other models, with two
19
+ * guardrails: plain text only (escaped, wrapped in a <p> block) and a hard
20
+ * refusal on email-shaped content — the residue this command exists to
21
+ * remove must never be re-embeddable through it.
22
+ */
23
+ export const mailMessage = {
24
+ model: "mail.message",
25
+ showCommand: "odoo messages show",
26
+ defaultListFields: [
27
+ "id",
28
+ "model",
29
+ "res_id",
30
+ "message_type",
31
+ "subtype_id",
32
+ "author_id",
33
+ "date",
34
+ ],
35
+ showExtraFields: ["body", "email_from", "tracking_value_ids"],
36
+ resolutionHints: {},
37
+ priorityLabels: {},
38
+ pinnedDomain: [],
39
+ references: {},
40
+ };
41
+ /** SOP-verbatim default; a variant is a decision (TDD), not a flag value. */
42
+ export const ERASURE_BODY_MARKER = "<p>Partner anonymized (erasure flow)</p>";
43
+ const MARKER_MAX_LENGTH = 1000;
44
+ const EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
45
+ function parseCount(raw, flag) {
46
+ if (!/^\d+$/.test(raw)) {
47
+ throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
48
+ }
49
+ return Number(raw);
50
+ }
51
+ function parseScope(resModel, resId, flags) {
52
+ if (resModel === undefined || resId === undefined) {
53
+ throw usageError(`${flags} requires both --res-model and --res-id`, "the scope cross-check must fully qualify the parent record — combine 'messages list --res-model X --res-id Y' with 'messages scrub/delete <ids> --res-model X --res-id Y'");
54
+ }
55
+ return { resModel, resId: parseCount(resId, "--res-id") };
56
+ }
57
+ function resolveMarker(opts) {
58
+ const raw = opts.marker?.trim();
59
+ if (raw === undefined || raw === "")
60
+ return ERASURE_BODY_MARKER;
61
+ if (raw.length > MARKER_MAX_LENGTH) {
62
+ throw usageError("--marker exceeds the 1000 character limit", "markers are short annotations; the erasure register holds the details");
63
+ }
64
+ if (EMAIL_PATTERN.test(raw)) {
65
+ throw usageError("--marker contains an email-shaped token", "scrub exists to remove personal data from chatter; a marker that can carry an email address re-creates the residue through the remediation itself. Name the record by id, never by address");
66
+ }
67
+ const escaped = raw
68
+ .replace(/&/g, "&amp;")
69
+ .replace(/</g, "&lt;")
70
+ .replace(/>/g, "&gt;");
71
+ return `<p>${escaped}</p>`;
72
+ }
73
+ function scopeMismatch(rec, scope) {
74
+ return rec.model !== scope.resModel || rec.res_id !== scope.resId;
75
+ }
76
+ export async function messagesListCommand(opts, globals) {
77
+ const client = await authenticatedClient(globals);
78
+ const resId = opts.resId !== undefined ? parseCount(opts.resId, "--res-id") : undefined;
79
+ // Scope guard: mail.message is a global chatter table across every
80
+ // model; never browse it unbounded (PII read footgun).
81
+ if (opts.resModel === undefined && resId === undefined) {
82
+ throw usageError("messages list requires an explicit scope", "pass --res-model and --res-id (AND-ed); a model-only list is allowed but still large — prefer the pair");
83
+ }
84
+ const limit = parseCount(opts.limit, "--limit");
85
+ const offset = parseCount(opts.offset, "--offset");
86
+ const domain = [];
87
+ if (opts.resModel !== undefined)
88
+ domain.push(["model", "=", opts.resModel]);
89
+ if (resId !== undefined)
90
+ domain.push(["res_id", "=", resId]);
91
+ if (opts.domain)
92
+ domain.push(...parseDomain(opts.domain));
93
+ const readFields = Array.from(new Set([...mailMessage.defaultListFields, "body", "tracking_value_ids"]));
94
+ const order = opts.order ?? "id asc";
95
+ const [records, count] = await Promise.all([
96
+ client.executeKw("mail.message", "search_read", [domain], {
97
+ fields: readFields,
98
+ limit,
99
+ offset,
100
+ order,
101
+ }),
102
+ client.executeKw("mail.message", "search_count", [
103
+ domain,
104
+ ]),
105
+ ]);
106
+ // Body content never leaves the command in list output — length only.
107
+ const rows = records.map((rec) => {
108
+ const body = rec.body;
109
+ const row = { ...rec };
110
+ delete row.body;
111
+ row.body_length =
112
+ typeof body === "string" && body.length > 0 ? body.length : 0;
113
+ const tv = rec.tracking_value_ids;
114
+ row.tracking_values = Array.isArray(tv) ? tv.length : 0;
115
+ delete row.tracking_value_ids;
116
+ return row;
117
+ });
118
+ if (globals.json) {
119
+ printJson({ model: "mail.message", records: rows, count, limit, offset });
120
+ return;
121
+ }
122
+ const headers = [
123
+ "id",
124
+ "model",
125
+ "res_id",
126
+ "message_type",
127
+ "author_id",
128
+ "date",
129
+ "body_length",
130
+ "tracking_values",
131
+ ];
132
+ const human = records.map((rec) => {
133
+ const body = rec.body;
134
+ const tv = rec.tracking_value_ids;
135
+ return [
136
+ String(rec.id ?? ""),
137
+ String(rec.model ?? ""),
138
+ String(rec.res_id ?? ""),
139
+ String(rec.message_type ?? ""),
140
+ String(Array.isArray(rec.author_id) ? rec.author_id[1] : (rec.author_id ?? "")),
141
+ String(rec.date ?? ""),
142
+ String(typeof body === "string" && body.length > 0 ? body.length : 0),
143
+ String(Array.isArray(tv) ? tv.length : 0),
144
+ ];
145
+ });
146
+ const widths = headers.map((h, i) => Math.max(h.length, ...human.map((r) => r[i].length)));
147
+ const fmt = (cells) => cells
148
+ .map((c, i) => c.padEnd(widths[i]))
149
+ .join(" ")
150
+ .trimEnd();
151
+ const lines = [fmt(headers), ...human.map(fmt)];
152
+ process.stdout.write(lines.join("\n") + "\n");
153
+ }
154
+ export function messagesShowCommand(idArg, opts, globals) {
155
+ return showCommand(mailMessage, idArg, opts, globals);
156
+ }
157
+ /**
158
+ * Strip tracked-change residue from in-scope messages: unlink every
159
+ * mail.tracking.value row (they hold the pre-change values) and rewrite the
160
+ * body to the erasure marker. Messages without tracking rows are skipped —
161
+ * this command never rewrites arbitrary chatter uninvited; the explicit
162
+ * weapon for verified personal data in message bodies is `messages delete`.
163
+ */
164
+ export async function messagesScrubCommand(idArgs, opts, globals) {
165
+ const client = await authenticatedClient(globals);
166
+ const scope = parseScope(opts.resModel, opts.resId, "messages scrub");
167
+ const ids = idArgs.map((a) => parseId(a));
168
+ const marker = resolveMarker(opts);
169
+ const records = (await client.executeKw("mail.message", "read", [ids], {
170
+ fields: ["model", "res_id", "tracking_value_ids"],
171
+ }));
172
+ const scrubbed = [];
173
+ const skipped = [];
174
+ for (const id of ids) {
175
+ const rec = records.find((r) => r.id === id);
176
+ if (!rec) {
177
+ skipped.push({ id, reason: "not found" });
178
+ continue;
179
+ }
180
+ if (scopeMismatch(rec, scope)) {
181
+ skipped.push({
182
+ id,
183
+ reason: `scope mismatch (message is on ${rec.model}/${rec.res_id})`,
184
+ });
185
+ continue;
186
+ }
187
+ const tv = Array.isArray(rec.tracking_value_ids)
188
+ ? rec.tracking_value_ids
189
+ : [];
190
+ if (tv.length === 0) {
191
+ skipped.push({ id, reason: "no tracking values" });
192
+ continue;
193
+ }
194
+ // Field NAMES only — old/new values are the residue being removed and
195
+ // must never echo back through command output, not even in dry-run.
196
+ const tvRows = (await client.executeKw("mail.tracking.value", "read", [[...tv]], { fields: ["field_id", "field_info"] }));
197
+ const fieldNames = tvRows.map((tvRow) => {
198
+ const info = tvRow.field_info;
199
+ if (info && typeof info === "object" && "name" in info) {
200
+ return String(info.name);
201
+ }
202
+ const f = tvRow.field_id;
203
+ return Array.isArray(f) ? String(f[1]) : String(f ?? "unknown");
204
+ });
205
+ if (globals.dryRun) {
206
+ scrubbed.push({
207
+ id,
208
+ trackingRows: tv.length,
209
+ fields: fieldNames,
210
+ marker,
211
+ });
212
+ continue;
213
+ }
214
+ await client.executeKw("mail.tracking.value", "unlink", [tv]);
215
+ // In production Odoo the unlink cascades the m2m; clear it explicitly so
216
+ // the message row itself no longer dangles (and so test servers see the
217
+ // same end state).
218
+ await client.executeKw("mail.message", "write", [
219
+ [id],
220
+ { tracking_value_ids: [[6, 0, []]] },
221
+ ]);
222
+ await client.executeKw("mail.message", "write", [[id], { body: marker }]);
223
+ scrubbed.push({
224
+ id,
225
+ trackingRowsRemoved: tv.length,
226
+ fields: fieldNames,
227
+ marker,
228
+ });
229
+ }
230
+ if (globals.json) {
231
+ printJson({
232
+ model: "mail.message",
233
+ scope,
234
+ marker,
235
+ scrubbed,
236
+ skipped,
237
+ removedTrackingRows: scrubbed.reduce((n, s) => n + Number(s.trackingRowsRemoved ?? s.trackingRows ?? 0), 0),
238
+ });
239
+ return;
240
+ }
241
+ for (const s of scrubbed) {
242
+ const fields = s.fields ?? [];
243
+ if (globals.dryRun) {
244
+ process.stdout.write(`Would scrub mail.message ${s.id}: remove ${s.trackingRows} tracking row(s) [${fields.join(", ")}], set marker\n`);
245
+ }
246
+ else {
247
+ process.stdout.write(`Scrubbed mail.message ${s.id}: removed ${s.trackingRowsRemoved} tracking row(s) [${fields.join(", ")}], marker written\n`);
248
+ }
249
+ }
250
+ for (const s of skipped) {
251
+ process.stdout.write(`Skipping mail.message ${s.id}: ${s.reason}\n`);
252
+ }
253
+ if (!globals.dryRun && scrubbed.length > 0) {
254
+ process.stdout.write(`Done — verify with: odoo messages list --res-model ${scope.resModel} --res-id ${scope.resId} --json (tracking_values must be 0)`);
255
+ }
256
+ process.stdout.write("\n");
257
+ }
258
+ /**
259
+ * Delete in-scope messages outright. This is the §6/escalation weapon for
260
+ * verified personal data in message bodies (after the SQL/checks confirmed
261
+ * it) — distinct from `scrub`, which annotates tracked-change residue while
262
+ * keeping the accountability trail.
263
+ */
264
+ export async function messagesDeleteCommand(idArgs, opts, globals) {
265
+ const client = await authenticatedClient(globals);
266
+ const scope = parseScope(opts.resModel, opts.resId, "messages delete");
267
+ const ids = idArgs.map((a) => parseId(a));
268
+ const records = (await client.executeKw("mail.message", "read", [ids], {
269
+ fields: ["model", "res_id"],
270
+ }));
271
+ const deleted = [];
272
+ const skipped = [];
273
+ for (const id of ids) {
274
+ const rec = records.find((r) => r.id === id);
275
+ if (!rec) {
276
+ skipped.push({ id, reason: "not found" });
277
+ continue;
278
+ }
279
+ if (scopeMismatch(rec, scope)) {
280
+ skipped.push({
281
+ id,
282
+ reason: `scope mismatch (message is on ${rec.model}/${rec.res_id})`,
283
+ });
284
+ continue;
285
+ }
286
+ deleted.push(id);
287
+ }
288
+ if (!globals.dryRun && deleted.length > 0) {
289
+ await client.executeKw("mail.message", "unlink", [deleted]);
290
+ }
291
+ if (globals.json) {
292
+ printJson({
293
+ model: "mail.message",
294
+ scope,
295
+ ...(globals.dryRun ? { wouldDelete: deleted } : { deleted }),
296
+ skipped,
297
+ });
298
+ return;
299
+ }
300
+ for (const id of deleted) {
301
+ process.stdout.write(globals.dryRun
302
+ ? `Would delete mail.message ${id}\n`
303
+ : `Deleted mail.message ${id}\n`);
304
+ }
305
+ for (const s of skipped) {
306
+ process.stdout.write(`Skipping mail.message ${s.id}: ${s.reason}\n`);
307
+ }
308
+ process.stdout.write("\n");
309
+ }
@@ -0,0 +1,58 @@
1
+ import { ModelFields } from "../model.js";
2
+ import { authenticatedClient, parseId, showCommand, } from "../resource.js";
3
+ import { deleteCommand } from "../mutation.js";
4
+ import { conflictError } from "../errors.js";
5
+ /**
6
+ * res.users surface (sign-in identities). Deliberately narrow: `show` and a
7
+ * guarded `delete` — no list, no create/update. `res.users` holds internal
8
+ * accounts (employees, service accounts) that must never be removed via the
9
+ * CLI, so `users delete` enforces portal-only (`share=true`) always:
10
+ * any internal user in the batch aborts the whole command without deleting
11
+ * anything (see docs/plans/users-module.md §5).
12
+ */
13
+ export const resUsers = {
14
+ model: "res.users",
15
+ showCommand: "odoo users show",
16
+ defaultListFields: [
17
+ "id",
18
+ "login",
19
+ "email",
20
+ "name",
21
+ "share",
22
+ "active",
23
+ "partner_id",
24
+ ],
25
+ showExtraFields: ["company_id", "create_date", "login_date", "write_date"],
26
+ resolutionHints: {},
27
+ priorityLabels: {},
28
+ pinnedDomain: [],
29
+ references: {},
30
+ };
31
+ export function usersShowCommand(idArg, opts, globals) {
32
+ return showCommand(resUsers, idArg, opts, globals);
33
+ }
34
+ /**
35
+ * Portal-only delete: read `share` for every requested id first (missing
36
+ * ids are absent from the response and left for deleteCommand's skipped
37
+ * reporting), and refuse the entire batch when any id is an internal user
38
+ * (`share=false`). There is no override flag by design.
39
+ */
40
+ export async function usersDeleteCommand(idArgs, globals) {
41
+ const ids = idArgs.map(parseId);
42
+ const client = await authenticatedClient(globals);
43
+ // fields_get first: doubles as the module/model presence check (§1).
44
+ await ModelFields.load(client, resUsers.model);
45
+ if (ids.length > 0) {
46
+ const rows = (await client.executeKw("res.users", "read", [ids], {
47
+ fields: ["share", "login", "name"],
48
+ }));
49
+ const internal = rows.filter((r) => r.share !== true);
50
+ if (internal.length > 0) {
51
+ const named = internal
52
+ .map((r) => `${r.id} (${r.login || r.name || "unknown login"})`)
53
+ .join(", ");
54
+ throw conflictError("protected_record", `internal user(s) cannot be deleted via the CLI: ${named}`, "users delete only removes portal users (share=true); internal accounts are managed in the Odoo UI");
55
+ }
56
+ }
57
+ return deleteCommand(resUsers, idArgs, globals);
58
+ }
@@ -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
  }