@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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/cli.js +249 -0
- package/dist/client.js +97 -0
- package/dist/commands/auth.js +133 -0
- package/dist/commands/crm.js +198 -0
- package/dist/commands/helpdesk.js +190 -0
- package/dist/commands/version.js +40 -0
- package/dist/config.js +153 -0
- package/dist/duration.js +33 -0
- package/dist/errors.js +21 -0
- package/dist/filter.js +61 -0
- package/dist/global-options.js +40 -0
- package/dist/model.js +149 -0
- package/dist/mutation.js +605 -0
- package/dist/output.js +39 -0
- package/dist/resource.js +211 -0
- package/dist/table.js +44 -0
- package/dist/xmlrpc.js +102 -0
- package/package.json +49 -0
package/dist/mutation.js
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation machinery (SPEC §4.4, §4.2, §5.4, §9): building, validating, and
|
|
3
|
+
* previewing `create`/`update`/`delete` values — and, since Phase 4, applying
|
|
4
|
+
* them via `create`/`write`/`unlink`. `--dry-run` replaces the final call with
|
|
5
|
+
* a printed plan.
|
|
6
|
+
*
|
|
7
|
+
* The shared engine is module-agnostic: a {@link MutationConfig} declares the
|
|
8
|
+
* first-class flag→field mapping, resolution references, and exclusions; the
|
|
9
|
+
* module surface wires it onto `ResourceConfig.mutation`. The CLI registers
|
|
10
|
+
* the flags and dispatches here.
|
|
11
|
+
*/
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { conflictError, networkError, notFoundError, usageError, } from "./errors.js";
|
|
14
|
+
import { parseFilterScalar } from "./filter.js";
|
|
15
|
+
import { ModelFields, resolveReference, stageScopeDomain, } from "./model.js";
|
|
16
|
+
import { colorEnabled, printJson, verbose as logVerbose } from "./output.js";
|
|
17
|
+
import { parseId, authenticatedClient, } from "./resource.js";
|
|
18
|
+
import { XmlRpcFaultError } from "./xmlrpc.js";
|
|
19
|
+
const kebab = (option) => option.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
20
|
+
/** read all of stdin as utf8 */
|
|
21
|
+
function readStdin() {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
25
|
+
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
26
|
+
process.stdin.on("error", reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the wire-format values dict from first-class flags + --set + --values,
|
|
31
|
+
* resolving references and validating every field client-side (§9) before any
|
|
32
|
+
* mutation. `mode === "create"` applies pinned values and checks required flags.
|
|
33
|
+
*/
|
|
34
|
+
export async function buildValues(client, config, spec, opts, mode, globals) {
|
|
35
|
+
const fieldsMeta = await ModelFields.load(client, config.model);
|
|
36
|
+
const values = {};
|
|
37
|
+
const resolved = {};
|
|
38
|
+
const sourceOf = {};
|
|
39
|
+
/** option name → resolved record id, for stage scoping lookups */
|
|
40
|
+
const resolvedByOption = {};
|
|
41
|
+
const claim = (field, source) => {
|
|
42
|
+
const prev = sourceOf[field];
|
|
43
|
+
if (prev === undefined) {
|
|
44
|
+
sourceOf[field] = source;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (prev === "pinned") {
|
|
48
|
+
throw usageError(`field "${field}" is pinned by this collection and cannot be overridden`, `remove --${kebab(/* context only */ "")}: the ${config.model.split(".")[0]} surface fixes ${field}`);
|
|
49
|
+
}
|
|
50
|
+
throw usageError(`field "${field}" given by both ${prev} and ${source}`, "pass each field exactly once (SPEC §4.2)");
|
|
51
|
+
};
|
|
52
|
+
// 1. Pinned create values first (e.g. type=lead). They cannot be overridden
|
|
53
|
+
// by any other source — claiming them again is a usage error.
|
|
54
|
+
if (mode === "create") {
|
|
55
|
+
for (const [k, v] of Object.entries(spec.createPinnedValues ?? {})) {
|
|
56
|
+
values[k] = v;
|
|
57
|
+
sourceOf[k] = "pinned";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// 2. Exclusive groups (--partner vs --customer-*/--customer-name, §6.3).
|
|
61
|
+
for (const group of spec.exclusiveGroups ?? []) {
|
|
62
|
+
const present = group.options.filter((o) => opts[o] !== undefined);
|
|
63
|
+
if (present.length > 1) {
|
|
64
|
+
throw usageError(`--${kebab(present[0])} and --${kebab(present[1])} are mutually exclusive`, group.hint ?? "pass at most one");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// 3. First-class flags in declared order (team before stage so stage scoping
|
|
68
|
+
// can reuse the resolved team id, mirrors list filtering, §4.3).
|
|
69
|
+
for (const flag of spec.flags) {
|
|
70
|
+
const raw = opts[flag.option];
|
|
71
|
+
if (raw === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
if (flag.multi && (!Array.isArray(raw) || raw.length === 0))
|
|
74
|
+
continue;
|
|
75
|
+
// Per-flag mutual exclusions (e.g. --customer-email conflicts with --partner).
|
|
76
|
+
const pair = (flag.conflictsWith ?? []).find((o) => opts[o] !== undefined);
|
|
77
|
+
if (pair !== undefined) {
|
|
78
|
+
throw usageError(`--${kebab(flag.option)} and --${kebab(pair)} are mutually exclusive`, "pass at most one");
|
|
79
|
+
}
|
|
80
|
+
const meta = fieldsMeta.get(flag.field);
|
|
81
|
+
if (!meta) {
|
|
82
|
+
const flagName = `--${kebab(flag.option)}`;
|
|
83
|
+
const msg = `${config.model} has no field '${flag.field}' on this instance`;
|
|
84
|
+
if (flag.reference?.mayBeAbsent) {
|
|
85
|
+
// §6.3: edition/version-dependent field missing → exit 4 naming the field
|
|
86
|
+
throw notFoundError("field_not_found", `${msg} — ${flagName} is not available`, `the field is edition/version-dependent (SPEC §6.3)`);
|
|
87
|
+
}
|
|
88
|
+
throw usageError(`${msg} — ${flagName} is not available`);
|
|
89
|
+
}
|
|
90
|
+
const flagName = `--${kebab(flag.option)}`;
|
|
91
|
+
if (flag.reference) {
|
|
92
|
+
const ref = flag.reference;
|
|
93
|
+
let domain = [];
|
|
94
|
+
if (ref.scopedBy && ref.scope) {
|
|
95
|
+
const depId = resolvedByOption[ref.scopedBy];
|
|
96
|
+
if (depId !== undefined) {
|
|
97
|
+
domain = await stageScopeDomain(client, ref.model, ref.scope, depId);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (flag.multi) {
|
|
101
|
+
const pairs = [];
|
|
102
|
+
for (const input of raw) {
|
|
103
|
+
const r = await resolveReference(client, ref.model, input, {
|
|
104
|
+
label: ref.label,
|
|
105
|
+
domain,
|
|
106
|
+
searchFields: ref.searchFields,
|
|
107
|
+
exactFields: ref.exactFields,
|
|
108
|
+
});
|
|
109
|
+
pairs.push([r.id, r.name]);
|
|
110
|
+
logVerbose(`resolved ${flagName} "${input}" -> [${r.id}, "${r.name}"]`, globals.verbose);
|
|
111
|
+
}
|
|
112
|
+
const ids = pairs.map(([id]) => id);
|
|
113
|
+
values[flag.field] = [[6, 0, ids]]; // replace-all command
|
|
114
|
+
resolved[flag.field] = pairs;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const r = await resolveReference(client, ref.model, raw, {
|
|
118
|
+
label: ref.label,
|
|
119
|
+
domain,
|
|
120
|
+
searchFields: ref.searchFields,
|
|
121
|
+
exactFields: ref.exactFields,
|
|
122
|
+
});
|
|
123
|
+
values[flag.field] = r.id;
|
|
124
|
+
// single m2o: flat [id, name] in the resolved map (x2many stays a list)
|
|
125
|
+
resolved[flag.field] = [r.id, r.name];
|
|
126
|
+
resolvedByOption[flag.option] = r.id;
|
|
127
|
+
logVerbose(`resolved ${flagName} "${raw}" -> [${r.id}, "${r.name}"]`, globals.verbose);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
values[flag.field] = flag.parse
|
|
132
|
+
? flag.parse(raw, flagName)
|
|
133
|
+
: raw;
|
|
134
|
+
}
|
|
135
|
+
claim(flag.field, flagName);
|
|
136
|
+
}
|
|
137
|
+
// 4. --set f=v (repeatable, JSON-scalar values; any field of the model).
|
|
138
|
+
for (const entry of opts.set ?? []) {
|
|
139
|
+
const eq = entry.indexOf("=");
|
|
140
|
+
if (eq <= 0) {
|
|
141
|
+
throw usageError(`invalid --set ${JSON.stringify(entry)}`, "expected the form field=value");
|
|
142
|
+
}
|
|
143
|
+
const field = entry.slice(0, eq);
|
|
144
|
+
if (!fieldsMeta.get(field)) {
|
|
145
|
+
// §8: unknown field on the target model → exit 5
|
|
146
|
+
throw conflictError("unknown_field", `unknown field ${JSON.stringify(field)} on ${config.model}`, `run \`${config.showCommand} <id> --all-fields\` to see available fields`);
|
|
147
|
+
}
|
|
148
|
+
if (sourceOf[field] !== undefined) {
|
|
149
|
+
throw usageError(`field "${field}" given by both ${sourceOf[field]} and --set`, "pass each field exactly once (SPEC §4.2)");
|
|
150
|
+
}
|
|
151
|
+
values[field] = parseFilterScalar(entry.slice(eq + 1));
|
|
152
|
+
sourceOf[field] = "--set";
|
|
153
|
+
}
|
|
154
|
+
// 5. --values <file> (JSON object; complex values pass through verbatim).
|
|
155
|
+
const valuesFile = opts.values;
|
|
156
|
+
if (valuesFile !== undefined) {
|
|
157
|
+
const obj = await readValuesFile(valuesFile);
|
|
158
|
+
for (const [field, v] of Object.entries(obj)) {
|
|
159
|
+
if (!fieldsMeta.get(field)) {
|
|
160
|
+
throw conflictError("unknown_field", `unknown field ${JSON.stringify(field)} on ${config.model}`, `run \`${config.showCommand} <id> --all-fields\` to see available fields`);
|
|
161
|
+
}
|
|
162
|
+
if (sourceOf[field] !== undefined) {
|
|
163
|
+
throw usageError(`field "${field}" given by both ${sourceOf[field]} and --values`, "pass each field exactly once (SPEC §4.2)");
|
|
164
|
+
}
|
|
165
|
+
values[field] = v;
|
|
166
|
+
sourceOf[field] = "--values";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// 6. Required flags on create (checked against the union of all sources).
|
|
170
|
+
if (mode === "create") {
|
|
171
|
+
for (const flag of spec.flags) {
|
|
172
|
+
if (flag.requiredOnCreate && values[flag.field] === undefined) {
|
|
173
|
+
throw usageError(`missing required --${kebab(flag.option)}`, `--${kebab(flag.option)} (field "${flag.field}") is required to create a ${config.model} record`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { values, resolved };
|
|
178
|
+
}
|
|
179
|
+
async function readValuesFile(path) {
|
|
180
|
+
let raw;
|
|
181
|
+
if (path === "-") {
|
|
182
|
+
if (process.stdin.isTTY) {
|
|
183
|
+
throw usageError("--values - expects a JSON object on stdin, but stdin is a TTY", "pipe a JSON object on stdin, e.g. echo '{\"field\": value}' | odoo ... --values -");
|
|
184
|
+
}
|
|
185
|
+
raw = await readStdin();
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
try {
|
|
189
|
+
raw = await readFile(path, "utf8");
|
|
190
|
+
}
|
|
191
|
+
catch (cause) {
|
|
192
|
+
throw usageError(`could not read --values file ${path}: ${cause.code ?? cause.message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(raw);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
throw usageError(`--values ${path === "-" ? "stdin" : path} is not valid JSON`, "expected a JSON object of field=value pairs");
|
|
201
|
+
}
|
|
202
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
203
|
+
throw usageError("--values must be a JSON object of field=value pairs");
|
|
204
|
+
}
|
|
205
|
+
return parsed;
|
|
206
|
+
}
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// create
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
/**
|
|
211
|
+
* A server-side fault on a mutation (e.g. a constraint violation) is a
|
|
212
|
+
* conflict (exit 5, SPEC §8); other transport/fault classes stay exit 6.
|
|
213
|
+
*/
|
|
214
|
+
function mutationFault(error, verb) {
|
|
215
|
+
if (error instanceof XmlRpcFaultError) {
|
|
216
|
+
throw conflictError("server_fault", `the server rejected the ${verb} (fault ${error.faultCode})`, "retry with --verbose to see the full server response");
|
|
217
|
+
}
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
/** `odoo <module> <resource> create` (§5.3, §6.3). */
|
|
221
|
+
export async function createCommand(config, opts, globals) {
|
|
222
|
+
const spec = config.mutation;
|
|
223
|
+
if (!spec)
|
|
224
|
+
throw usageError(`${config.model} has no mutation support configured`);
|
|
225
|
+
const client = await authenticatedClient(globals);
|
|
226
|
+
const built = await buildValues(client, config, spec, opts, "create", globals);
|
|
227
|
+
if (globals.dryRun) {
|
|
228
|
+
if (globals.json) {
|
|
229
|
+
printJson({
|
|
230
|
+
model: config.model,
|
|
231
|
+
action: "create",
|
|
232
|
+
dry_run: true,
|
|
233
|
+
values: built.values,
|
|
234
|
+
...(hasResolved(built) ? { resolved: built.resolved } : {}),
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
process.stdout.write(renderCreatePlan(config, built));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
let id;
|
|
242
|
+
try {
|
|
243
|
+
id = await client.executeKw(config.model, "create", [built.values]);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
// §9: the request may have been sent before a transport failure, and the
|
|
247
|
+
// server does not guarantee idempotent create — tell the caller to
|
|
248
|
+
// verify before blindly retrying.
|
|
249
|
+
if (!(error instanceof XmlRpcFaultError) &&
|
|
250
|
+
error.exitCode === 6) {
|
|
251
|
+
throw networkError(error.message, "the request reached the server; the record may have been created — verify with list before retrying");
|
|
252
|
+
}
|
|
253
|
+
mutationFault(error, "create");
|
|
254
|
+
}
|
|
255
|
+
if (typeof id !== "number" || !Number.isInteger(id)) {
|
|
256
|
+
throw networkError(`unexpected create() response from the server: ${JSON.stringify(id)}`);
|
|
257
|
+
}
|
|
258
|
+
if (globals.json) {
|
|
259
|
+
printJson({
|
|
260
|
+
model: config.model,
|
|
261
|
+
action: "create",
|
|
262
|
+
dry_run: false,
|
|
263
|
+
id,
|
|
264
|
+
...(hasResolved(built) ? { resolved: built.resolved } : {}),
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
269
|
+
const line = `Created ${config.model} ${id}`;
|
|
270
|
+
process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
|
|
271
|
+
}
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// update
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
export async function updateCommand(config, idArgs, opts, globals) {
|
|
276
|
+
const spec = config.mutation;
|
|
277
|
+
if (!spec)
|
|
278
|
+
throw usageError(`${config.model} has no mutation support configured`);
|
|
279
|
+
const ids = idArgs.map(parseId);
|
|
280
|
+
const client = await authenticatedClient(globals);
|
|
281
|
+
const built = await buildValues(client, config, spec, opts, "update", globals);
|
|
282
|
+
if (Object.keys(built.values).length === 0) {
|
|
283
|
+
throw usageError("nothing to update", "pass at least one field flag, --set, or --values");
|
|
284
|
+
}
|
|
285
|
+
// Read-first pass (§5.4): powers existence check, diff, and no-op detection.
|
|
286
|
+
const readFields = [
|
|
287
|
+
...new Set([...Object.keys(built.values), "display_name"]),
|
|
288
|
+
];
|
|
289
|
+
const rows = (await client.executeKw(config.model, "read", [ids], {
|
|
290
|
+
fields: readFields,
|
|
291
|
+
}));
|
|
292
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
293
|
+
const missing = ids.filter((id) => !byId.has(id));
|
|
294
|
+
if (missing.length > 0) {
|
|
295
|
+
throw notFoundError("record_not_found", `no ${config.model} record(s) with id(s): ${missing.join(", ")}`, "no records were modified — update aborts the whole batch when any id is missing (SPEC §5.4)");
|
|
296
|
+
}
|
|
297
|
+
const fieldsMeta = await ModelFields.load(client, config.model);
|
|
298
|
+
const diffs = ids.map((id) => {
|
|
299
|
+
const rec = byId.get(id);
|
|
300
|
+
const changes = {};
|
|
301
|
+
for (const [field, after] of Object.entries(built.values)) {
|
|
302
|
+
const before = rec[field];
|
|
303
|
+
if (!fieldValuesEqual(fieldsMeta.get(field)?.type, before, after)) {
|
|
304
|
+
changes[field] = { before, after };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return { id, display_name: String(rec.display_name ?? id), changes };
|
|
308
|
+
});
|
|
309
|
+
if (globals.dryRun) {
|
|
310
|
+
if (globals.json) {
|
|
311
|
+
const changes = {};
|
|
312
|
+
for (const d of diffs)
|
|
313
|
+
changes[String(d.id)] = d.changes;
|
|
314
|
+
printJson({
|
|
315
|
+
model: config.model,
|
|
316
|
+
action: "update",
|
|
317
|
+
dry_run: true,
|
|
318
|
+
...(hasResolved(built) ? { resolved: built.resolved } : {}),
|
|
319
|
+
changes,
|
|
320
|
+
});
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
process.stdout.write(renderUpdatePlan(config, diffs, built));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// §5.4: same values on every record are already a no-op per record; only
|
|
327
|
+
// records with an actual change go into the single atomic write call.
|
|
328
|
+
const toWrite = diffs.filter((d) => Object.keys(d.changes).length > 0);
|
|
329
|
+
if (toWrite.length > 0) {
|
|
330
|
+
const writeIds = toWrite.map((d) => d.id);
|
|
331
|
+
try {
|
|
332
|
+
await client.executeKw(config.model, "write", [writeIds, built.values]);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
mutationFault(error, "update");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (globals.json) {
|
|
339
|
+
const changes = {};
|
|
340
|
+
for (const d of diffs)
|
|
341
|
+
changes[String(d.id)] = d.changes;
|
|
342
|
+
printJson({
|
|
343
|
+
model: config.model,
|
|
344
|
+
action: "update",
|
|
345
|
+
dry_run: false,
|
|
346
|
+
...(hasResolved(built) ? { resolved: built.resolved } : {}),
|
|
347
|
+
changes,
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
352
|
+
const lines = [];
|
|
353
|
+
for (const d of diffs) {
|
|
354
|
+
if (Object.keys(d.changes).length === 0) {
|
|
355
|
+
lines.push(`Unchanged ${config.model} ${d.id}`);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
const line = `Updated ${config.model} ${d.id}`;
|
|
359
|
+
lines.push(color ? `\x1b[33m${line}\x1b[0m` : line);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Does the requested value equal the current value? Client-side diff used for
|
|
366
|
+
* no-op detection (§5.4) — Odoo's `write` returns True whether or not anything
|
|
367
|
+
* changed, so this diffing is client-side only.
|
|
368
|
+
*/
|
|
369
|
+
function fieldValuesEqual(type, before, after) {
|
|
370
|
+
if (type === "many2one") {
|
|
371
|
+
const beforeId = Array.isArray(before) ? before[0] : before;
|
|
372
|
+
return beforeId === after;
|
|
373
|
+
}
|
|
374
|
+
if (type === "many2many" || type === "one2many") {
|
|
375
|
+
// after is the replace-all command [[6, 0, [ids]]]
|
|
376
|
+
const afterIds = extractReplaceIds(after);
|
|
377
|
+
if (afterIds !== undefined) {
|
|
378
|
+
return setsEqual(before, afterIds);
|
|
379
|
+
}
|
|
380
|
+
return JSON.stringify(before) === JSON.stringify(after);
|
|
381
|
+
}
|
|
382
|
+
if (before === false && after === false)
|
|
383
|
+
return true;
|
|
384
|
+
return before === after;
|
|
385
|
+
}
|
|
386
|
+
function extractReplaceIds(after) {
|
|
387
|
+
if (Array.isArray(after) &&
|
|
388
|
+
after.length === 1 &&
|
|
389
|
+
Array.isArray(after[0]) &&
|
|
390
|
+
after[0][0] === 6) {
|
|
391
|
+
return after[0][2];
|
|
392
|
+
}
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
function setsEqual(a, b) {
|
|
396
|
+
const sa = [...(a ?? [])].sort((x, y) => x - y);
|
|
397
|
+
const sb = [...b].sort((x, y) => x - y);
|
|
398
|
+
return JSON.stringify(sa) === JSON.stringify(sb);
|
|
399
|
+
}
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// delete
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
export async function deleteCommand(config, idArgs, globals) {
|
|
404
|
+
const ids = idArgs.map(parseId);
|
|
405
|
+
// fields_get first: doubles as the module-installed check (§1) so delete on
|
|
406
|
+
// a Community-only instance reports the model-missing hint, not a raw fault.
|
|
407
|
+
const client = await authenticatedClient(globals);
|
|
408
|
+
await ModelFields.load(client, config.model);
|
|
409
|
+
const rows = (await client.executeKw(config.model, "read", [ids], {
|
|
410
|
+
fields: ["display_name"],
|
|
411
|
+
}));
|
|
412
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
413
|
+
const records = ids
|
|
414
|
+
.filter((id) => byId.has(id))
|
|
415
|
+
.map((id) => ({
|
|
416
|
+
id,
|
|
417
|
+
display_name: String(byId.get(id).display_name ?? id),
|
|
418
|
+
}));
|
|
419
|
+
const skipped = ids.filter((id) => !byId.has(id));
|
|
420
|
+
if (globals.dryRun) {
|
|
421
|
+
if (globals.json) {
|
|
422
|
+
printJson({
|
|
423
|
+
model: config.model,
|
|
424
|
+
action: "delete",
|
|
425
|
+
dry_run: true,
|
|
426
|
+
records,
|
|
427
|
+
skipped,
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
process.stdout.write(renderDeletePlan(config, records, skipped));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
// §4.1: unknown ids are reported as skipped, not errors — delete converges
|
|
435
|
+
// on retry (the end state "these ids do not exist" is already reached).
|
|
436
|
+
if (records.length > 0) {
|
|
437
|
+
try {
|
|
438
|
+
await client.executeKw(config.model, "unlink", [
|
|
439
|
+
records.map((r) => r.id),
|
|
440
|
+
]);
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
mutationFault(error, "delete");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (globals.json) {
|
|
447
|
+
printJson({
|
|
448
|
+
model: config.model,
|
|
449
|
+
action: "delete",
|
|
450
|
+
dry_run: false,
|
|
451
|
+
deleted: records.map((r) => r.id),
|
|
452
|
+
skipped,
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
457
|
+
const lines = records.map((r) => {
|
|
458
|
+
const line = `Deleted ${config.model} ${r.id} (${r.display_name})`;
|
|
459
|
+
return color ? `\x1b[31m${line}\x1b[0m` : line;
|
|
460
|
+
});
|
|
461
|
+
if (skipped.length > 0) {
|
|
462
|
+
lines.push(`Skipped (not found): ${skipped.join(", ")}`);
|
|
463
|
+
}
|
|
464
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
465
|
+
}
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// scalar parsers (§9 input validation)
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
import { parsePriority as priorityParser } from "./resource.js";
|
|
470
|
+
/** Parse a decimal number flag (e.g. --expected-revenue). */
|
|
471
|
+
export function parseDecimal(raw, flagName) {
|
|
472
|
+
const n = Number(raw);
|
|
473
|
+
if (!Number.isFinite(n)) {
|
|
474
|
+
throw usageError(`invalid ${flagName} value ${JSON.stringify(raw)}`, "expected a number");
|
|
475
|
+
}
|
|
476
|
+
return n;
|
|
477
|
+
}
|
|
478
|
+
/** Parse a probability in the closed range 0–100 (e.g. --probability). */
|
|
479
|
+
export function parseProbability(raw, flagName) {
|
|
480
|
+
const n = parseDecimal(raw, flagName);
|
|
481
|
+
if (n < 0 || n > 100) {
|
|
482
|
+
throw usageError(`invalid ${flagName} value ${JSON.stringify(raw)}`, "expected a number between 0 and 100");
|
|
483
|
+
}
|
|
484
|
+
return n;
|
|
485
|
+
}
|
|
486
|
+
/** Parse a YYYY-MM-DD date flag (e.g. --deadline). */
|
|
487
|
+
export function parseDate(raw, flagName) {
|
|
488
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
489
|
+
throw usageError(`invalid ${flagName} value ${JSON.stringify(raw)}`, "expected a date in YYYY-MM-DD format");
|
|
490
|
+
}
|
|
491
|
+
return raw;
|
|
492
|
+
}
|
|
493
|
+
/** Parse a --priority flag (§4.5 friendly name → raw Odoo selection value). */
|
|
494
|
+
export const parsePriorityFlag = (raw, flagName) => priorityParser(raw, flagName);
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
// rendering
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
function hasResolved(built) {
|
|
499
|
+
return Object.keys(built.resolved).length > 0;
|
|
500
|
+
}
|
|
501
|
+
/** Render a wire value, annotating resolved references inline (§12). */
|
|
502
|
+
function wire(value) {
|
|
503
|
+
if (Array.isArray(value))
|
|
504
|
+
return `[${value.map(wire).join(", ")}]`;
|
|
505
|
+
if (typeof value === "string")
|
|
506
|
+
return JSON.stringify(value);
|
|
507
|
+
if (value === false || value === null)
|
|
508
|
+
return String(value);
|
|
509
|
+
return JSON.stringify(value) ?? String(value);
|
|
510
|
+
}
|
|
511
|
+
function renderCreatePlan(config, built) {
|
|
512
|
+
const keys = Object.keys(built.values);
|
|
513
|
+
const width = Math.max(1, ...keys.map((k) => k.length));
|
|
514
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
515
|
+
const lines = [
|
|
516
|
+
`Would create ${config.model}:`,
|
|
517
|
+
...keys.map((field) => {
|
|
518
|
+
const label = `${field}:`.padEnd(width + 2);
|
|
519
|
+
const annotation = annotationFor(field, built);
|
|
520
|
+
return ` ${label}${wire(valueWithResolved(field, built))}${annotation}`;
|
|
521
|
+
}),
|
|
522
|
+
];
|
|
523
|
+
if (color) {
|
|
524
|
+
lines[0] = `\x1b[32m${lines[0]}\x1b[0m`;
|
|
525
|
+
}
|
|
526
|
+
return lines.join("\n") + "\n";
|
|
527
|
+
}
|
|
528
|
+
/** True for a single m2o resolved pair [id, "name"] stored flat. */
|
|
529
|
+
function isFlatPair(x) {
|
|
530
|
+
return (Array.isArray(x) &&
|
|
531
|
+
x.length === 2 &&
|
|
532
|
+
typeof x[0] === "number" &&
|
|
533
|
+
typeof x[1] === "string");
|
|
534
|
+
}
|
|
535
|
+
/** Normalize a resolved entry to a list of pairs (flat m2o → one pair). */
|
|
536
|
+
function asPairs(entry) {
|
|
537
|
+
if (entry === undefined)
|
|
538
|
+
return undefined;
|
|
539
|
+
if (isFlatPair(entry))
|
|
540
|
+
return [entry];
|
|
541
|
+
return entry;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* For rendering, prefer the resolved `[id, "name"]` pair over a bare id (§12).
|
|
545
|
+
*/
|
|
546
|
+
function valueWithResolved(field, built) {
|
|
547
|
+
const entry = built.resolved[field];
|
|
548
|
+
const v = built.values[field];
|
|
549
|
+
if (entry && isFlatPair(entry))
|
|
550
|
+
return entry;
|
|
551
|
+
return v;
|
|
552
|
+
}
|
|
553
|
+
function annotationFor(field, built) {
|
|
554
|
+
const pairs = asPairs(built.resolved[field]);
|
|
555
|
+
if (!pairs || pairs.length === 0)
|
|
556
|
+
return "";
|
|
557
|
+
// only x2many commands get the inline `# [id] = "name"` comment (§12);
|
|
558
|
+
// a flat m2o pair is rendered inline by valueWithResolved.
|
|
559
|
+
if (!isTagCommand(built.values[field]))
|
|
560
|
+
return "";
|
|
561
|
+
const comment = pairs
|
|
562
|
+
.map(([id, name]) => `[${id}] = ${JSON.stringify(name)}`)
|
|
563
|
+
.join(", ");
|
|
564
|
+
return ` # ${comment}`;
|
|
565
|
+
}
|
|
566
|
+
function isTagCommand(value) {
|
|
567
|
+
return (Array.isArray(value) &&
|
|
568
|
+
value.length === 1 &&
|
|
569
|
+
Array.isArray(value[0]) &&
|
|
570
|
+
value[0][0] === 6);
|
|
571
|
+
}
|
|
572
|
+
function renderUpdatePlan(config, diffs, built) {
|
|
573
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
574
|
+
const lines = [];
|
|
575
|
+
for (const d of diffs) {
|
|
576
|
+
if (Object.keys(d.changes).length === 0) {
|
|
577
|
+
lines.push(`Unchanged ${config.model} ${d.id} (${d.display_name}) — nothing to change`);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
const header = `Would update ${config.model} ${d.id} (${d.display_name}):`;
|
|
581
|
+
lines.push(color ? `\x1b[33m${header}\x1b[0m` : header);
|
|
582
|
+
for (const [field, change] of Object.entries(d.changes)) {
|
|
583
|
+
const entry = built.resolved[field];
|
|
584
|
+
const after = entry && isFlatPair(entry) && !isTagCommand(change.after)
|
|
585
|
+
? entry
|
|
586
|
+
: change.after;
|
|
587
|
+
lines.push(` ${field}: ${wire(change.before)} -> ${wire(after)}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return lines.join("\n") + "\n";
|
|
591
|
+
}
|
|
592
|
+
function renderDeletePlan(config, records, skipped) {
|
|
593
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
594
|
+
const lines = [];
|
|
595
|
+
for (const r of records) {
|
|
596
|
+
const line = `Would delete ${config.model} ${r.id} (${r.display_name})`;
|
|
597
|
+
lines.push(color ? `\x1b[31m${line}\x1b[0m` : line);
|
|
598
|
+
}
|
|
599
|
+
if (skipped.length > 0) {
|
|
600
|
+
lines.push(`Skipped (not found): ${skipped.join(", ")}`);
|
|
601
|
+
}
|
|
602
|
+
return lines.join("\n") + "\n";
|
|
603
|
+
}
|
|
604
|
+
// re-exports of shared helpers the CLI wiring needs
|
|
605
|
+
// (parseId + authenticatedClient imported above)
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** True when stdout is a TTY and color is allowed (SPEC §3, §7.2). */
|
|
2
|
+
export function colorEnabled(noColorFlag) {
|
|
3
|
+
if (noColorFlag)
|
|
4
|
+
return false;
|
|
5
|
+
if (process.env.NO_COLOR !== undefined)
|
|
6
|
+
return false;
|
|
7
|
+
if (process.env.TERM === "dumb")
|
|
8
|
+
return false;
|
|
9
|
+
return process.stdout.isTTY === true;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Print exactly one JSON object on stdout (SPEC §7.3): pretty-printed when
|
|
13
|
+
* stdout is a TTY, compact otherwise.
|
|
14
|
+
*/
|
|
15
|
+
export function printJson(value) {
|
|
16
|
+
const pretty = process.stdout.isTTY === true;
|
|
17
|
+
process.stdout.write(JSON.stringify(value, null, pretty ? 2 : 0) + "\n");
|
|
18
|
+
}
|
|
19
|
+
export function printError(error, opts) {
|
|
20
|
+
if (opts.json) {
|
|
21
|
+
const envelope = {
|
|
22
|
+
error: {
|
|
23
|
+
code: error.code,
|
|
24
|
+
message: error.message,
|
|
25
|
+
...(error.hint ? { hint: error.hint } : {}),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
process.stderr.write(JSON.stringify(envelope) + "\n");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
32
|
+
if (error.hint) {
|
|
33
|
+
process.stderr.write(`Hint: ${error.hint}\n`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function verbose(message, enabled) {
|
|
37
|
+
if (enabled)
|
|
38
|
+
process.stderr.write(`${message}\n`);
|
|
39
|
+
}
|