@bergetai/odoo-cli 0.9.1 → 0.11.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 +32 -0
- package/dist/cli.js +319 -0
- package/dist/commands/attachments.js +158 -0
- package/dist/commands/footprint.js +843 -0
- package/dist/commands/marketing.js +184 -0
- package/dist/commands/messages.js +309 -0
- package/dist/commands/users.js +58 -0
- package/package.json +1 -1
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
import { ModelFields } from "../model.js";
|
|
2
|
+
import { authenticatedClient } from "../resource.js";
|
|
3
|
+
import { usageError } from "../errors.js";
|
|
4
|
+
import { colorEnabled, printJson, verbose as logVerbose } from "../output.js";
|
|
5
|
+
import { formatTable, humanCell } from "../table.js";
|
|
6
|
+
/** Minimal inline accounting configs (from the removed erasure-base-commands
|
|
7
|
+
* plan §2.1–2.3): read-only modules. */
|
|
8
|
+
export const accountMove = {
|
|
9
|
+
model: "account.move",
|
|
10
|
+
showCommand: "odoo invoices show",
|
|
11
|
+
defaultListFields: [
|
|
12
|
+
"id",
|
|
13
|
+
"name",
|
|
14
|
+
"invoice_date",
|
|
15
|
+
"state",
|
|
16
|
+
"move_type",
|
|
17
|
+
"partner_id",
|
|
18
|
+
"invoice_partner_display_name",
|
|
19
|
+
],
|
|
20
|
+
showExtraFields: ["create_date", "write_date"],
|
|
21
|
+
resolutionHints: {},
|
|
22
|
+
priorityLabels: {},
|
|
23
|
+
pinnedDomain: [],
|
|
24
|
+
references: {},
|
|
25
|
+
};
|
|
26
|
+
export const saleOrder = {
|
|
27
|
+
model: "sale.order",
|
|
28
|
+
showCommand: "odoo orders show",
|
|
29
|
+
defaultListFields: [
|
|
30
|
+
"id",
|
|
31
|
+
"name",
|
|
32
|
+
"state",
|
|
33
|
+
"partner_id",
|
|
34
|
+
"partner_invoice_id",
|
|
35
|
+
"partner_shipping_id",
|
|
36
|
+
"date_order",
|
|
37
|
+
],
|
|
38
|
+
showExtraFields: ["create_date", "write_date"],
|
|
39
|
+
resolutionHints: {},
|
|
40
|
+
priorityLabels: {},
|
|
41
|
+
pinnedDomain: [],
|
|
42
|
+
references: {},
|
|
43
|
+
};
|
|
44
|
+
export const accountPayment = {
|
|
45
|
+
model: "account.payment",
|
|
46
|
+
showCommand: "odoo payments show",
|
|
47
|
+
defaultListFields: [
|
|
48
|
+
"id",
|
|
49
|
+
"name",
|
|
50
|
+
"state",
|
|
51
|
+
"partner_id",
|
|
52
|
+
"amount",
|
|
53
|
+
"currency_id",
|
|
54
|
+
"date",
|
|
55
|
+
],
|
|
56
|
+
showExtraFields: ["create_date", "write_date"],
|
|
57
|
+
resolutionHints: {},
|
|
58
|
+
priorityLabels: {},
|
|
59
|
+
pinnedDomain: [],
|
|
60
|
+
references: {},
|
|
61
|
+
};
|
|
62
|
+
/** Linked groups are actionable in this order — identity first (portal users
|
|
63
|
+
* block partner unlink: res_users.partner_id is ondelete=restrict), partner
|
|
64
|
+
* shells last. keep-groups (invoices/orders/payments) are reference surfaces,
|
|
65
|
+
* not steps, and are excluded. */
|
|
66
|
+
export const RUN_ORDER = [
|
|
67
|
+
"users",
|
|
68
|
+
"leads",
|
|
69
|
+
"mailingContacts",
|
|
70
|
+
"mailingTraces",
|
|
71
|
+
"tickets",
|
|
72
|
+
"messages",
|
|
73
|
+
"attachments",
|
|
74
|
+
"partners",
|
|
75
|
+
];
|
|
76
|
+
/** Odoo OR-domain over clause groups: "|" c1 "|" c2 c3 (prefix notation).
|
|
77
|
+
* Implicit conjunction cannot nest under "|", so groups with more than one
|
|
78
|
+
* clause are wrapped in an explicit "&". An empty OR matches nothing. */
|
|
79
|
+
function orGroups(groups) {
|
|
80
|
+
const wrap = (g) => (g.length > 1 ? ["&", ...g] : [...g]);
|
|
81
|
+
const present = groups.filter((g) => g.length > 0).map(wrap);
|
|
82
|
+
if (present.length === 0)
|
|
83
|
+
return [["id", "=", 0]];
|
|
84
|
+
let acc = [...present[present.length - 1]];
|
|
85
|
+
for (let i = present.length - 2; i >= 0; i--) {
|
|
86
|
+
acc = ["|", ...present[i], ...acc];
|
|
87
|
+
}
|
|
88
|
+
return acc;
|
|
89
|
+
}
|
|
90
|
+
function m2oId(value) {
|
|
91
|
+
const v = value;
|
|
92
|
+
return Array.isArray(v) && typeof v[0] === "number" ? v[0] : false;
|
|
93
|
+
}
|
|
94
|
+
function or(...groups) {
|
|
95
|
+
return orGroups(groups);
|
|
96
|
+
}
|
|
97
|
+
const GROUPS = [
|
|
98
|
+
{
|
|
99
|
+
group: "partners",
|
|
100
|
+
config: {
|
|
101
|
+
model: "res.partner",
|
|
102
|
+
showCommand: "odoo partners show",
|
|
103
|
+
defaultListFields: [],
|
|
104
|
+
showExtraFields: [],
|
|
105
|
+
resolutionHints: {},
|
|
106
|
+
priorityLabels: {},
|
|
107
|
+
pinnedDomain: [],
|
|
108
|
+
references: {},
|
|
109
|
+
},
|
|
110
|
+
fields: [
|
|
111
|
+
"id",
|
|
112
|
+
"name",
|
|
113
|
+
"email",
|
|
114
|
+
"parent_id",
|
|
115
|
+
"is_company",
|
|
116
|
+
"active",
|
|
117
|
+
// step-2/4 decision inputs: identities on the partner, member
|
|
118
|
+
// contacts (sole-member test), and the personnummer-derived vat
|
|
119
|
+
// that step 4 must blank on enskild-firma shells
|
|
120
|
+
"user_ids",
|
|
121
|
+
"child_ids",
|
|
122
|
+
"vat",
|
|
123
|
+
],
|
|
124
|
+
reason: "",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
group: "users",
|
|
128
|
+
config: {
|
|
129
|
+
model: "res.users",
|
|
130
|
+
showCommand: "odoo users show",
|
|
131
|
+
defaultListFields: [],
|
|
132
|
+
showExtraFields: [],
|
|
133
|
+
resolutionHints: {},
|
|
134
|
+
priorityLabels: {},
|
|
135
|
+
pinnedDomain: [],
|
|
136
|
+
references: {},
|
|
137
|
+
},
|
|
138
|
+
fields: ["id", "login", "email", "share", "active", "partner_id"],
|
|
139
|
+
reason: "",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
group: "leads",
|
|
143
|
+
config: {
|
|
144
|
+
model: "crm.lead",
|
|
145
|
+
showCommand: "odoo crm records show",
|
|
146
|
+
defaultListFields: [],
|
|
147
|
+
showExtraFields: [],
|
|
148
|
+
resolutionHints: {},
|
|
149
|
+
priorityLabels: {},
|
|
150
|
+
pinnedDomain: [],
|
|
151
|
+
references: {},
|
|
152
|
+
},
|
|
153
|
+
fields: ["id", "name", "email_from", "partner_id", "active"],
|
|
154
|
+
reason: "CRM lead for the requesting person (no retention exception)",
|
|
155
|
+
hint: (_r, id) => `odoo crm records delete ${id} --dry-run`,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
group: "mailingContacts",
|
|
159
|
+
config: {
|
|
160
|
+
model: "mailing.contact",
|
|
161
|
+
showCommand: "odoo marketing contacts show",
|
|
162
|
+
defaultListFields: [],
|
|
163
|
+
showExtraFields: [],
|
|
164
|
+
resolutionHints: {},
|
|
165
|
+
priorityLabels: {},
|
|
166
|
+
pinnedDomain: [],
|
|
167
|
+
references: {},
|
|
168
|
+
},
|
|
169
|
+
fields: ["id", "email", "name"],
|
|
170
|
+
reason: "marketing contact — strict delete (TDD decision #7)",
|
|
171
|
+
hint: (_r, id) => `odoo marketing contacts delete ${id} --dry-run`,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
group: "mailingTraces",
|
|
175
|
+
config: {
|
|
176
|
+
model: "mailing.trace",
|
|
177
|
+
showCommand: "odoo marketing traces show",
|
|
178
|
+
defaultListFields: [],
|
|
179
|
+
showExtraFields: [],
|
|
180
|
+
resolutionHints: {},
|
|
181
|
+
priorityLabels: {},
|
|
182
|
+
pinnedDomain: [],
|
|
183
|
+
references: {},
|
|
184
|
+
},
|
|
185
|
+
fields: ["id", "email", "model", "res_id", "trace_status"],
|
|
186
|
+
reason: "send trace with denormalized email — no cascade from contact deletion (invariant 4)",
|
|
187
|
+
hint: (_r, id) => `odoo marketing traces delete ${id} --dry-run`,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
group: "tickets",
|
|
191
|
+
config: {
|
|
192
|
+
model: "helpdesk.ticket",
|
|
193
|
+
showCommand: "odoo helpdesk tickets show",
|
|
194
|
+
defaultListFields: [],
|
|
195
|
+
showExtraFields: [],
|
|
196
|
+
resolutionHints: {},
|
|
197
|
+
priorityLabels: {},
|
|
198
|
+
pinnedDomain: [],
|
|
199
|
+
references: {},
|
|
200
|
+
},
|
|
201
|
+
fields: ["id", "name", "partner_id", "partner_email", "team_id", "active"],
|
|
202
|
+
reason: "delete unless an active legal claim exists (Flow A step 6)",
|
|
203
|
+
hint: (_r, id) => `odoo helpdesk tickets delete ${id} --dry-run`,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
group: "messages",
|
|
207
|
+
config: {
|
|
208
|
+
model: "mail.message",
|
|
209
|
+
showCommand: "odoo crm records message-delete",
|
|
210
|
+
defaultListFields: [],
|
|
211
|
+
showExtraFields: [],
|
|
212
|
+
resolutionHints: {},
|
|
213
|
+
priorityLabels: {},
|
|
214
|
+
pinnedDomain: [],
|
|
215
|
+
references: {},
|
|
216
|
+
},
|
|
217
|
+
fields: [
|
|
218
|
+
"id",
|
|
219
|
+
"model",
|
|
220
|
+
"res_id",
|
|
221
|
+
"author_id",
|
|
222
|
+
"email_from",
|
|
223
|
+
"date",
|
|
224
|
+
"body",
|
|
225
|
+
],
|
|
226
|
+
tableExcludeFields: ["body"],
|
|
227
|
+
reason: "correspondence on a to-be-deleted record",
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
group: "attachments",
|
|
231
|
+
config: {
|
|
232
|
+
model: "ir.attachment",
|
|
233
|
+
showCommand: "odoo attachments show",
|
|
234
|
+
defaultListFields: [],
|
|
235
|
+
showExtraFields: [],
|
|
236
|
+
resolutionHints: {},
|
|
237
|
+
priorityLabels: {},
|
|
238
|
+
pinnedDomain: [],
|
|
239
|
+
references: {},
|
|
240
|
+
},
|
|
241
|
+
fields: ["id", "name", "res_model", "res_id"],
|
|
242
|
+
reason: "",
|
|
243
|
+
hint: (_r, id) => `odoo attachments delete ${id} --dry-run`,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
group: "invoices",
|
|
247
|
+
config: accountMove,
|
|
248
|
+
fields: [
|
|
249
|
+
"id",
|
|
250
|
+
"name",
|
|
251
|
+
"invoice_date",
|
|
252
|
+
"state",
|
|
253
|
+
"move_type",
|
|
254
|
+
"partner_id",
|
|
255
|
+
"invoice_partner_display_name",
|
|
256
|
+
],
|
|
257
|
+
reason: "accounting record — bokföringslagen 7 kap. 2 § (7 years)",
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
group: "orders",
|
|
261
|
+
config: saleOrder,
|
|
262
|
+
fields: [
|
|
263
|
+
"id",
|
|
264
|
+
"name",
|
|
265
|
+
"state",
|
|
266
|
+
"partner_id",
|
|
267
|
+
"partner_invoice_id",
|
|
268
|
+
"partner_shipping_id",
|
|
269
|
+
"date_order",
|
|
270
|
+
],
|
|
271
|
+
reason: "accounting/order documentation — RESTRICT reference surface (§6.2.4)",
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
group: "payments",
|
|
275
|
+
config: accountPayment,
|
|
276
|
+
fields: ["id", "name", "state", "partner_id", "amount", "date"],
|
|
277
|
+
reason: "accounting record — bokföringslagen 7 kap. 2 § (7 years)",
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
/** search_read + search_count in one round trip (same context, so the count
|
|
281
|
+
* reflects the same rule/active filtering). The count drives truncation
|
|
282
|
+
* flags: a group can silently cap (server-side limit or record rules) and
|
|
283
|
+
* the request register needs exact footprint counts (TDD step 1). */
|
|
284
|
+
async function searchRowsWithCount(client, model, domain, fields, limit, includeArchived) {
|
|
285
|
+
// fields_get doubles as the module-installed check; curated fields
|
|
286
|
+
// intersect with the schema so edition-dependent fields degrade
|
|
287
|
+
// gracefully (helpdesk.ticket.ticket_type_id precedent).
|
|
288
|
+
const meta = await ModelFields.load(client, model);
|
|
289
|
+
const select = fields.filter((f) => f === "id" || meta.get(f));
|
|
290
|
+
// Archived rows are still personal data (TDD decision #10): search with
|
|
291
|
+
// active_test disabled unless --active-only asks for the UI's view.
|
|
292
|
+
const context = includeArchived ? { context: { active_test: false } } : {};
|
|
293
|
+
const [rows, total] = (await Promise.all([
|
|
294
|
+
client.executeKw(model, "search_read", [domain], {
|
|
295
|
+
fields: select.length > 0 ? select : undefined,
|
|
296
|
+
limit,
|
|
297
|
+
...context,
|
|
298
|
+
}),
|
|
299
|
+
client.executeKw(model, "search_count", [domain], context),
|
|
300
|
+
]));
|
|
301
|
+
return { rows, total };
|
|
302
|
+
}
|
|
303
|
+
function parseCount(raw, flag) {
|
|
304
|
+
if (!/^\d+$/.test(raw)) {
|
|
305
|
+
throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
|
|
306
|
+
}
|
|
307
|
+
return Number(raw);
|
|
308
|
+
}
|
|
309
|
+
const GROUP_ORDER = GROUPS.map((g) => g.group);
|
|
310
|
+
export async function footprintCommand(emailArg, opts, globals) {
|
|
311
|
+
const email = emailArg.trim();
|
|
312
|
+
if (email === "") {
|
|
313
|
+
throw usageError("email must not be empty");
|
|
314
|
+
}
|
|
315
|
+
const limit = parseCount(opts.limit ?? "500", "--limit");
|
|
316
|
+
const includeArchived = !opts.activeOnly;
|
|
317
|
+
const client = await authenticatedClient(globals);
|
|
318
|
+
const groupErrors = [];
|
|
319
|
+
const rows = new Map();
|
|
320
|
+
const groupTotals = {};
|
|
321
|
+
async function runGroup(group, model, fn) {
|
|
322
|
+
try {
|
|
323
|
+
const result = await fn();
|
|
324
|
+
rows.set(group, result.rows);
|
|
325
|
+
groupTotals[group] = result.total;
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
groupErrors.push({
|
|
329
|
+
group,
|
|
330
|
+
model,
|
|
331
|
+
message: error.message,
|
|
332
|
+
});
|
|
333
|
+
rows.set(group, []);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Phase A: email-keyed enumeration (independent of each other).
|
|
337
|
+
await Promise.all([
|
|
338
|
+
runGroup("partners", "res.partner", () => searchRowsWithCount(client, "res.partner", [["email", "ilike", email]], [
|
|
339
|
+
"id",
|
|
340
|
+
"name",
|
|
341
|
+
"email",
|
|
342
|
+
"parent_id",
|
|
343
|
+
"is_company",
|
|
344
|
+
"active",
|
|
345
|
+
"user_ids",
|
|
346
|
+
"child_ids",
|
|
347
|
+
"vat",
|
|
348
|
+
], limit, includeArchived)),
|
|
349
|
+
runGroup("mailingContacts", "mailing.contact", () => searchRowsWithCount(client, "mailing.contact", or([["email", "ilike", email]], [["name", "ilike", email]]), ["id", "email", "name"], limit, includeArchived)),
|
|
350
|
+
runGroup("mailingTraces", "mailing.trace", () => searchRowsWithCount(client, "mailing.trace", [["email", "ilike", email]], ["id", "email", "model", "res_id", "trace_status"], limit, includeArchived)),
|
|
351
|
+
]);
|
|
352
|
+
// Phase B: users (direct login/email, and partner_id of found partners).
|
|
353
|
+
const partnerRows = rows.get("partners") ?? [];
|
|
354
|
+
const phase1PartnerIds = partnerRows.map((r) => r.id);
|
|
355
|
+
await runGroup("users", "res.users", () => searchRowsWithCount(client, "res.users", or([["login", "ilike", email]], [["email", "ilike", email]], phase1PartnerIds.length > 0
|
|
356
|
+
? [["partner_id", "in", phase1PartnerIds]]
|
|
357
|
+
: []), ["id", "login", "email", "share", "active", "partner_id"], limit, includeArchived));
|
|
358
|
+
// Correlation set: every partner id the person touches (their contacts,
|
|
359
|
+
// plus partners referenced directly by sign-in identities — the
|
|
360
|
+
// root-attached-portal-user pattern from TDD step 2 needs this).
|
|
361
|
+
const userRows = rows.get("users") ?? [];
|
|
362
|
+
const partnerIds = [
|
|
363
|
+
...new Set([
|
|
364
|
+
...phase1PartnerIds,
|
|
365
|
+
...userRows
|
|
366
|
+
.map((r) => m2oId(r.partner_id))
|
|
367
|
+
.filter((v) => v !== false),
|
|
368
|
+
]),
|
|
369
|
+
];
|
|
370
|
+
// Phase C: partner-keyed stores. Leads join here (not phase A) so the
|
|
371
|
+
// OR branch can correlate on partner_id — a lead may carry the person's
|
|
372
|
+
// partner instead of their email.
|
|
373
|
+
await Promise.all([
|
|
374
|
+
runGroup("leads", "crm.lead", () => searchRowsWithCount(client, "crm.lead", or([["email_from", "ilike", email]], partnerIds.length > 0 ? [["partner_id", "in", partnerIds]] : []), ["id", "name", "email_from", "partner_id", "active"], limit, includeArchived)),
|
|
375
|
+
runGroup("tickets", "helpdesk.ticket", () => searchRowsWithCount(client, "helpdesk.ticket", or(partnerIds.length > 0 ? [["partner_id", "in", partnerIds]] : [], [
|
|
376
|
+
["partner_email", "ilike", email],
|
|
377
|
+
]), ["id", "name", "partner_id", "partner_email", "team_id", "active"], limit, includeArchived)),
|
|
378
|
+
runGroup("invoices", "account.move", () => searchRowsWithCount(client, "account.move", partnerIds.length > 0
|
|
379
|
+
? [["partner_id", "in", partnerIds]]
|
|
380
|
+
: [["id", "=", -1]], [
|
|
381
|
+
"id",
|
|
382
|
+
"name",
|
|
383
|
+
"invoice_date",
|
|
384
|
+
"state",
|
|
385
|
+
"move_type",
|
|
386
|
+
"partner_id",
|
|
387
|
+
"invoice_partner_display_name",
|
|
388
|
+
], limit, includeArchived)),
|
|
389
|
+
runGroup("orders", "sale.order", () => searchRowsWithCount(client, "sale.order", or(partnerIds.length > 0 ? [["partner_id", "in", partnerIds]] : [], partnerIds.length > 0
|
|
390
|
+
? [["partner_invoice_id", "in", partnerIds]]
|
|
391
|
+
: [], partnerIds.length > 0
|
|
392
|
+
? [["partner_shipping_id", "in", partnerIds]]
|
|
393
|
+
: []), [
|
|
394
|
+
"id",
|
|
395
|
+
"name",
|
|
396
|
+
"state",
|
|
397
|
+
"partner_id",
|
|
398
|
+
"partner_invoice_id",
|
|
399
|
+
"partner_shipping_id",
|
|
400
|
+
"date_order",
|
|
401
|
+
], limit, includeArchived)),
|
|
402
|
+
runGroup("payments", "account.payment", () => searchRowsWithCount(client, "account.payment", partnerIds.length > 0
|
|
403
|
+
? [["partner_id", "in", partnerIds]]
|
|
404
|
+
: [["id", "=", -1]], ["id", "name", "state", "partner_id", "amount", "date"], limit, includeArchived)),
|
|
405
|
+
]);
|
|
406
|
+
// Phase C2: contact verdicts — the §6.2.4 pre-flight on the RESTRICT
|
|
407
|
+
// surfaces. Computed per contact: posted account.move, any
|
|
408
|
+
// account.payment, or a confirmed/done sale.order referencing the
|
|
409
|
+
// partner through any of its three reference fields → anonymize,
|
|
410
|
+
// never unlink (invariant 1).
|
|
411
|
+
const invoiceRows = rows.get("invoices") ?? [];
|
|
412
|
+
const orderRows = rows.get("orders") ?? [];
|
|
413
|
+
const paymentRows = rows.get("payments") ?? [];
|
|
414
|
+
const referencedBy = [
|
|
415
|
+
[
|
|
416
|
+
"posted invoice (account.move)",
|
|
417
|
+
refPartnerIds(invoiceRows.filter((r) => r.state === "posted"), ["partner_id"]),
|
|
418
|
+
],
|
|
419
|
+
["payment (account.payment)", refPartnerIds(paymentRows, ["partner_id"])],
|
|
420
|
+
[
|
|
421
|
+
"confirmed order (sale.order)",
|
|
422
|
+
refPartnerIds(orderRows.filter((r) => r.state === "sale" || r.state === "done"), ["partner_id", "partner_invoice_id", "partner_shipping_id"]),
|
|
423
|
+
],
|
|
424
|
+
];
|
|
425
|
+
const referenceGroupsErrored = groupErrors.some((g) => ["invoices", "orders", "payments"].includes(g.group));
|
|
426
|
+
// Phase D: messages on/around the found records.
|
|
427
|
+
const ticketRows = rows.get("tickets") ?? [];
|
|
428
|
+
const leadRows = rows.get("leads") ?? [];
|
|
429
|
+
const ticketIds = ticketRows.map((r) => r.id);
|
|
430
|
+
const leadIds = leadRows.map((r) => r.id);
|
|
431
|
+
const authorIds = partnerIds;
|
|
432
|
+
await runGroup("messages", "mail.message", () => searchRowsWithCount(client, "mail.message", or([["email_from", "ilike", email]], authorIds.length > 0 ? [["author_id", "in", authorIds]] : [], ticketIds.length > 0
|
|
433
|
+
? [
|
|
434
|
+
["model", "=", "helpdesk.ticket"],
|
|
435
|
+
["res_id", "in", ticketIds],
|
|
436
|
+
]
|
|
437
|
+
: [], leadIds.length > 0
|
|
438
|
+
? [
|
|
439
|
+
["model", "=", "crm.lead"],
|
|
440
|
+
["res_id", "in", leadIds],
|
|
441
|
+
]
|
|
442
|
+
: [],
|
|
443
|
+
// notes ON the partners themselves — the database audit found
|
|
444
|
+
// provisioning notes ("Portal Access Granted (<email>)")
|
|
445
|
+
// authored by the service account on the partner record, which
|
|
446
|
+
// no email/author/parent branch reaches
|
|
447
|
+
partnerIds.length > 0
|
|
448
|
+
? [
|
|
449
|
+
["model", "=", "res.partner"],
|
|
450
|
+
["res_id", "in", partnerIds],
|
|
451
|
+
]
|
|
452
|
+
: []), ["id", "model", "res_id", "author_id", "email_from", "date", "body"], limit, includeArchived));
|
|
453
|
+
// Phase E: attachments — by name, and by (res_model, res_id) pairs on
|
|
454
|
+
// the parents found above (invariant 4: nothing cascades) — including
|
|
455
|
+
// the partners themselves: avatars are stored as ir.attachment rows on
|
|
456
|
+
// res.partner and are never removed by partner deletion.
|
|
457
|
+
const messageRows = rows.get("messages") ?? [];
|
|
458
|
+
const parentPairs = new Map();
|
|
459
|
+
for (const [model, groupRows] of [
|
|
460
|
+
["helpdesk.ticket", ticketRows],
|
|
461
|
+
["mail.message", messageRows],
|
|
462
|
+
["crm.lead", leadRows],
|
|
463
|
+
["res.partner", partnerRows],
|
|
464
|
+
]) {
|
|
465
|
+
const ids = new Set(groupRows.map((r) => r.id));
|
|
466
|
+
if (ids.size > 0)
|
|
467
|
+
parentPairs.set(model, ids);
|
|
468
|
+
}
|
|
469
|
+
await runGroup("attachments", "ir.attachment", () => searchRowsWithCount(client, "ir.attachment", or([["name", "ilike", email]], ...[...parentPairs.entries()].map(([model, ids]) => [
|
|
470
|
+
["res_model", "=", model],
|
|
471
|
+
["res_id", "in", [...ids]],
|
|
472
|
+
])), ["id", "name", "res_model", "res_id"], limit, includeArchived));
|
|
473
|
+
const attachmentRows = rows.get("attachments") ?? [];
|
|
474
|
+
// Phase F: resolve out-of-scope parents INLINE for `review` records so
|
|
475
|
+
// the output names the thing instead of pointing at it (bounded — see
|
|
476
|
+
// DETAIL_FETCH_CAP). a parent's name answers "what is this?" without a
|
|
477
|
+
// follow-up command; beyond the cap, records keep their inspectHint.
|
|
478
|
+
const detailRefs = new Map();
|
|
479
|
+
const refFor = (row) => [
|
|
480
|
+
(row.model ?? row.res_model),
|
|
481
|
+
(row.res_id ?? row.id),
|
|
482
|
+
];
|
|
483
|
+
for (const row of messageRows) {
|
|
484
|
+
const [parentModel, parentId] = refFor(row);
|
|
485
|
+
const onTicket = parentModel === "helpdesk.ticket" && ticketIds.includes(parentId);
|
|
486
|
+
const onLead = parentModel === "crm.lead" && leadIds.includes(parentId);
|
|
487
|
+
const onPartner = parentModel === "res.partner" && partnerIds.includes(parentId);
|
|
488
|
+
if (!onTicket && !onLead && !onPartner && parentModel && parentId) {
|
|
489
|
+
const set = detailRefs.get(parentModel) ?? new Set();
|
|
490
|
+
set.add(parentId);
|
|
491
|
+
detailRefs.set(parentModel, set);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
for (const row of attachmentRows) {
|
|
495
|
+
if (row.res_model !== "account.move" &&
|
|
496
|
+
!parentPairs.get(row.res_model)?.has(row.res_id)) {
|
|
497
|
+
const set = detailRefs.get(row.res_model) ?? new Set();
|
|
498
|
+
set.add(row.res_id);
|
|
499
|
+
detailRefs.set(row.res_model, set);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const DETAIL_FETCH_CAP = 25;
|
|
503
|
+
const detailNames = new Map(); // "model#id" → display name
|
|
504
|
+
const overCap = [...detailRefs.entries()].filter(([, ids]) => ids.size > DETAIL_FETCH_CAP);
|
|
505
|
+
if (overCap.length > 0) {
|
|
506
|
+
logVerbose(`skipping inline detail resolution for ${overCap.length} over-cap model(s)`, globals.verbose);
|
|
507
|
+
}
|
|
508
|
+
for (const [model, ids] of [...detailRefs.entries()].filter(([, ids]) => ids.size <= DETAIL_FETCH_CAP)) {
|
|
509
|
+
try {
|
|
510
|
+
// display_name is computed and safe; name may be absent on
|
|
511
|
+
// some models — intersect with fields_get
|
|
512
|
+
const meta = await ModelFields.load(client, model);
|
|
513
|
+
const fields = ["name", "display_name"].filter((f) => meta.get(f));
|
|
514
|
+
const parents = (await client.executeKw(model, "read", [[...ids]], fields.length > 0 ? { fields } : {}));
|
|
515
|
+
for (const p of parents) {
|
|
516
|
+
detailNames.set(`${model}#${p.id}`, String(p.display_name ?? p.name ?? `#${p.id}`));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
// unreadable parent (record rules / missing module):
|
|
521
|
+
// records keep their inspectHint
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Assemble records with dispositions.
|
|
525
|
+
const records = [];
|
|
526
|
+
const summary = {};
|
|
527
|
+
const specByGroup = new Map(GROUPS.map((g) => [g.group, g]));
|
|
528
|
+
function push(group, row, disposition, reason, hint, inspectHint) {
|
|
529
|
+
const spec = specByGroup.get(group);
|
|
530
|
+
records.push({
|
|
531
|
+
group,
|
|
532
|
+
model: spec?.config.model ?? "",
|
|
533
|
+
id: row.id,
|
|
534
|
+
disposition,
|
|
535
|
+
reason,
|
|
536
|
+
fields: row,
|
|
537
|
+
...(hint ? { commandHint: hint } : {}),
|
|
538
|
+
...(inspectHint ? { inspectHint } : {}),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
// partners — verdict computed (anonymize vs delete), roots always review.
|
|
542
|
+
const partnerDispById = new Map();
|
|
543
|
+
for (const row of partnerRows) {
|
|
544
|
+
const id = row.id;
|
|
545
|
+
let disposition;
|
|
546
|
+
let reason;
|
|
547
|
+
if (m2oId(row.parent_id) === false) {
|
|
548
|
+
disposition = "review";
|
|
549
|
+
reason =
|
|
550
|
+
"account root — sole-member/enskild-firma handling (Flow A step 4)";
|
|
551
|
+
}
|
|
552
|
+
else if (referenceGroupsErrored) {
|
|
553
|
+
disposition = "review";
|
|
554
|
+
reason =
|
|
555
|
+
"reference check unavailable (accounting group error) — run the §6.2.4 pre-flight before deleting";
|
|
556
|
+
}
|
|
557
|
+
else if (referencedBy.some(([, ids]) => ids.has(id))) {
|
|
558
|
+
disposition = "anonymize";
|
|
559
|
+
const sources = referencedBy
|
|
560
|
+
.filter(([, ids]) => ids.has(id))
|
|
561
|
+
.map(([label]) => label);
|
|
562
|
+
reason = `referenced by ${sources.join(", ")} — anonymize, never unlink (invariant 1)`;
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
disposition = "delete";
|
|
566
|
+
reason =
|
|
567
|
+
"no RESTRICT references found — confirm with the §6.2.4 pre-flight before unlink";
|
|
568
|
+
}
|
|
569
|
+
const hint = disposition === "delete"
|
|
570
|
+
? `odoo partners delete ${id} --dry-run`
|
|
571
|
+
: disposition === "anonymize"
|
|
572
|
+
? `odoo partners update ${id} --set name='Raderad kontaktperson (ID ${id})' --set email=null --set phone=null --set function=null`
|
|
573
|
+
: undefined;
|
|
574
|
+
// step-4 decision inputs (child_ids/user_ids/vat) are fetched on the
|
|
575
|
+
// partners group itself, so the root review needs no follow-up command
|
|
576
|
+
partnerDispById.set(id, disposition);
|
|
577
|
+
push("partners", row, disposition, reason, hint);
|
|
578
|
+
}
|
|
579
|
+
// users — portal deletion is the SOP step 2; internal users are listed
|
|
580
|
+
// but never removable via the CLI.
|
|
581
|
+
for (const row of userRows) {
|
|
582
|
+
if (row.share === false) {
|
|
583
|
+
push("users", row, "review", "internal user (share=false) — never removed via the CLI");
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
push("users", row, "delete", "portal sign-in identity — delete before partner unlink (res_users.partner_id is ondelete=restrict)", `odoo users delete ${row.id} --dry-run`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
for (const row of leadRows) {
|
|
590
|
+
push("leads", row, "delete", specByGroup.get("leads").reason, `odoo crm records delete ${row.id} --dry-run`);
|
|
591
|
+
}
|
|
592
|
+
for (const row of rows.get("mailingContacts") ?? []) {
|
|
593
|
+
push("mailingContacts", row, "delete", specByGroup.get("mailingContacts").reason, `odoo marketing contacts delete ${row.id} --dry-run`);
|
|
594
|
+
}
|
|
595
|
+
for (const row of rows.get("mailingTraces") ?? []) {
|
|
596
|
+
push("mailingTraces", row, "delete", specByGroup.get("mailingTraces").reason, `odoo marketing traces delete ${row.id} --dry-run`);
|
|
597
|
+
}
|
|
598
|
+
for (const row of ticketRows) {
|
|
599
|
+
push("tickets", row, "delete", specByGroup.get("tickets").reason, `odoo helpdesk tickets delete ${row.id} --dry-run`);
|
|
600
|
+
}
|
|
601
|
+
for (const row of messageRows) {
|
|
602
|
+
const parentModel = row.model;
|
|
603
|
+
const parentId = row.res_id;
|
|
604
|
+
const onTicket = parentModel === "helpdesk.ticket" && ticketIds.includes(parentId);
|
|
605
|
+
const onLead = parentModel === "crm.lead" && leadIds.includes(parentId);
|
|
606
|
+
if (onTicket) {
|
|
607
|
+
push("messages", row, "delete", `correspondence on to-be-deleted ticket #${parentId}`,
|
|
608
|
+
// ticket unlink removes its thread messages; the next runnable
|
|
609
|
+
// command is the parent's, not a per-message delete
|
|
610
|
+
`odoo helpdesk tickets delete ${parentId} --dry-run`);
|
|
611
|
+
}
|
|
612
|
+
else if (onLead) {
|
|
613
|
+
push("messages", row, "delete", `correspondence on to-be-deleted lead #${parentId}`, `odoo crm records delete ${parentId} --dry-run`);
|
|
614
|
+
}
|
|
615
|
+
else if (parentModel === "res.partner" && partnerIds.includes(parentId)) {
|
|
616
|
+
// provisioning/system notes ON the partner record itself — found
|
|
617
|
+
// in prod ("Portal Access Granted (<email>)"), authored by the
|
|
618
|
+
// service account, carrying the email. On a to-be-deleted partner
|
|
619
|
+
// the note dies with the unlink; on a kept shell (anonymize /
|
|
620
|
+
// review) it must be deleted explicitly.
|
|
621
|
+
const pd = partnerDispById.get(parentId);
|
|
622
|
+
if (pd === "delete") {
|
|
623
|
+
push("messages", row, "delete", `system note on to-be-deleted partner #${parentId}`, `odoo partners delete ${parentId} --dry-run`);
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
push("messages", row, "delete", `system note on kept partner #${parentId} (shell/active) — carries the email; delete explicitly (partner unlink won't remove it)`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
// e.g. project.task / calendar.event / mailing.mailing — parents
|
|
631
|
+
// outside the Flow A deletion set are never deleted by the SOP;
|
|
632
|
+
// deleting stray messages there is a human judgment call.
|
|
633
|
+
const detailKey = `${parentModel}#${parentId}`;
|
|
634
|
+
const name = detailNames.get(detailKey);
|
|
635
|
+
push("messages", row, "review", name
|
|
636
|
+
? `correspondence on ${parentModel} #${parentId} (“${name}”) — parent is outside the erasure scope; verify before deleting`
|
|
637
|
+
: `correspondence on ${parentModel} #${parentId} — parent is outside the erasure scope; verify before deleting`, undefined,
|
|
638
|
+
// when the parent resolved inline the question is answered;
|
|
639
|
+
// only unfetched parents keep an inspect hint
|
|
640
|
+
name
|
|
641
|
+
? undefined
|
|
642
|
+
: `odoo models read ${parentModel} --ids ${parentId} --json`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
for (const row of attachmentRows) {
|
|
646
|
+
const parentKey = `${row.res_model}#${row.res_id}`;
|
|
647
|
+
if (row.res_model === "account.move") {
|
|
648
|
+
push("attachments", row, "keep", "linked to account.move — bokföringsunderlag (7 years)");
|
|
649
|
+
}
|
|
650
|
+
else if (row.res_model === "res.partner") {
|
|
651
|
+
// partner avatars are personal data and NEVER cascade with the
|
|
652
|
+
// partner (invariant 4) — explicit delete whether the partner is
|
|
653
|
+
// being deleted or kept as an anonymized shell (step 4)
|
|
654
|
+
const pd = partnerDispById.get(row.res_id);
|
|
655
|
+
push("attachments", row, "delete", pd === "delete"
|
|
656
|
+
? `attached to to-be-deleted partner #${row.res_id} — explicit delete (attachments never cascade)`
|
|
657
|
+
: `attached to kept partner #${row.res_id} (shell/active) — avatar is personal data; explicit delete (step 4)`, `odoo attachments delete ${row.id} --dry-run`);
|
|
658
|
+
}
|
|
659
|
+
else if (parentPairs.get(row.res_model)?.has(row.res_id)) {
|
|
660
|
+
push("attachments", row, "delete", `attached to ${row.res_model} #${row.res_id} — deletes with its parent`, `odoo attachments delete ${row.id} --dry-run`);
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
const name = detailNames.get(parentKey);
|
|
664
|
+
push("attachments", row, "review", name
|
|
665
|
+
? `name matches the email, parent (${parentKey}, “${name}”) is not in the footprint — verify ownership`
|
|
666
|
+
: `name matches the email, but parent (${parentKey}) is not in the footprint — verify ownership`, undefined, name ? undefined : `odoo attachments show ${row.id} --json`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
for (const [group, groupRows] of [
|
|
670
|
+
["invoices", invoiceRows],
|
|
671
|
+
["orders", orderRows],
|
|
672
|
+
["payments", paymentRows],
|
|
673
|
+
]) {
|
|
674
|
+
const reason = specByGroup.get(group)?.reason ?? "";
|
|
675
|
+
for (const row of groupRows) {
|
|
676
|
+
push(group, row, "keep", reason);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
records.sort((a, b) => GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group) ||
|
|
680
|
+
a.id - b.id);
|
|
681
|
+
for (const g of GROUP_ORDER) {
|
|
682
|
+
summary[g] = records.filter((r) => r.group === g).length;
|
|
683
|
+
}
|
|
684
|
+
summary.total = records.length;
|
|
685
|
+
for (const g of GROUP_ORDER) {
|
|
686
|
+
if (groupTotals[g] === undefined || groupErrors.some((e) => e.group === g))
|
|
687
|
+
continue;
|
|
688
|
+
groupTotals[g] = Math.max(groupTotals[g], summary[g] ?? 0);
|
|
689
|
+
}
|
|
690
|
+
// search_count reflects the record-rule/active filter too; a gap vs the
|
|
691
|
+
// returned rows means truncation (--limit or a server-side cap)
|
|
692
|
+
const truncatedGroups = GROUP_ORDER.filter((g) => !groupErrors.some((e) => e.group === g) &&
|
|
693
|
+
groupTotals[g] > (summary[g] ?? 0));
|
|
694
|
+
if (globals.json) {
|
|
695
|
+
printJson(toJsonReport({
|
|
696
|
+
email,
|
|
697
|
+
generatedAt: new Date().toISOString(),
|
|
698
|
+
summary,
|
|
699
|
+
groupTotals,
|
|
700
|
+
truncatedGroups,
|
|
701
|
+
records,
|
|
702
|
+
groupErrors,
|
|
703
|
+
}));
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
printFootprintTable({
|
|
707
|
+
email,
|
|
708
|
+
generatedAt: new Date().toISOString(),
|
|
709
|
+
summary,
|
|
710
|
+
groupTotals,
|
|
711
|
+
truncatedGroups,
|
|
712
|
+
records,
|
|
713
|
+
groupErrors,
|
|
714
|
+
}, globals);
|
|
715
|
+
}
|
|
716
|
+
function refPartnerIds(groupRows, fields) {
|
|
717
|
+
const out = new Set();
|
|
718
|
+
for (const row of groupRows) {
|
|
719
|
+
for (const field of fields) {
|
|
720
|
+
const id = m2oId(row[field]);
|
|
721
|
+
if (id !== false)
|
|
722
|
+
out.add(id);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
/** Normalize one Odoo-native value for the structured JSON payload. */
|
|
728
|
+
function normalizeFieldValue(value) {
|
|
729
|
+
if (Array.isArray(value) &&
|
|
730
|
+
value.length === 2 &&
|
|
731
|
+
typeof value[0] === "number" &&
|
|
732
|
+
typeof value[1] === "string") {
|
|
733
|
+
return { id: value[0], name: value[1] };
|
|
734
|
+
}
|
|
735
|
+
// many2many: arrays of [id, name] tuples → arrays of {id, name}
|
|
736
|
+
if (Array.isArray(value) &&
|
|
737
|
+
value.length > 0 &&
|
|
738
|
+
value.every((v) => Array.isArray(v) &&
|
|
739
|
+
v.length === 2 &&
|
|
740
|
+
typeof v[0] === "number" &&
|
|
741
|
+
typeof v[1] === "string")) {
|
|
742
|
+
return value.map((v) => ({ id: v[0], name: v[1] }));
|
|
743
|
+
}
|
|
744
|
+
return value;
|
|
745
|
+
}
|
|
746
|
+
/** Build the structured per-group JSON payload from the flat assembly. */
|
|
747
|
+
function toJsonReport(flat) {
|
|
748
|
+
const groups = {};
|
|
749
|
+
for (const group of GROUP_ORDER) {
|
|
750
|
+
const spec = specByGroupLocal(group);
|
|
751
|
+
const error = flat.groupErrors.find((e) => e.group === group);
|
|
752
|
+
const groupRecords = flat.records.filter((r) => r.group === group);
|
|
753
|
+
const total = error
|
|
754
|
+
? null
|
|
755
|
+
: Math.max(flat.groupTotals[group] ?? 0, groupRecords.length);
|
|
756
|
+
const truncated = !error && total !== null && total > groupRecords.length;
|
|
757
|
+
const dispositions = {
|
|
758
|
+
delete: 0,
|
|
759
|
+
anonymize: 0,
|
|
760
|
+
keep: 0,
|
|
761
|
+
review: 0,
|
|
762
|
+
};
|
|
763
|
+
for (const r of groupRecords)
|
|
764
|
+
dispositions[r.disposition]++;
|
|
765
|
+
groups[group] = {
|
|
766
|
+
model: spec.config.model,
|
|
767
|
+
found: groupRecords.length,
|
|
768
|
+
total,
|
|
769
|
+
truncated,
|
|
770
|
+
dispositions,
|
|
771
|
+
...(error ? { error: error.message } : {}),
|
|
772
|
+
records: groupRecords.map((r) => {
|
|
773
|
+
const fields = {};
|
|
774
|
+
for (const [k, v] of Object.entries(r.fields)) {
|
|
775
|
+
fields[k] = normalizeFieldValue(v);
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
id: r.id,
|
|
779
|
+
disposition: r.disposition,
|
|
780
|
+
reason: r.reason,
|
|
781
|
+
...(r.commandHint ? { commandHint: r.commandHint } : {}),
|
|
782
|
+
...(r.inspectHint ? { inspectHint: r.inspectHint } : {}),
|
|
783
|
+
fields,
|
|
784
|
+
};
|
|
785
|
+
}),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
email: flat.email,
|
|
790
|
+
generatedAt: flat.generatedAt,
|
|
791
|
+
totalRecords: flat.records.length,
|
|
792
|
+
truncatedGroups: GROUP_ORDER.filter((g) => groups[g].truncated),
|
|
793
|
+
runOrder: [...RUN_ORDER],
|
|
794
|
+
groups,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function printFootprintTable(report, globals) {
|
|
798
|
+
const out = process.stdout;
|
|
799
|
+
out.write(`Footprint for ${report.email} — ${report.summary.total} record(s)\n`);
|
|
800
|
+
out.write(`Suggested order: ${RUN_ORDER.join(" → ")} (keep-groups: verified, not run)\n`);
|
|
801
|
+
if (report.summary.total === 0 && report.groupErrors.length === 0) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
for (const group of GROUP_ORDER) {
|
|
805
|
+
const spec = specByGroupLocal(group);
|
|
806
|
+
const groupRecords = report.records.filter((r) => r.group === group);
|
|
807
|
+
const groupError = report.groupErrors.find((g) => g.group === group);
|
|
808
|
+
out.write(`\n${group} (${spec.config.model})`);
|
|
809
|
+
if (groupError) {
|
|
810
|
+
out.write(` — ERROR: ${groupError.message}\n`);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (report.truncatedGroups.includes(group)) {
|
|
814
|
+
out.write(` — TRUNCATED: showing ${groupRecords.length} of ${report.groupTotals[group]} (raise --limit)`);
|
|
815
|
+
}
|
|
816
|
+
out.write(`\n`);
|
|
817
|
+
if (groupRecords.length === 0)
|
|
818
|
+
continue;
|
|
819
|
+
const meta = groupRecords[0].fields;
|
|
820
|
+
const keys = Object.keys(meta).filter((k) => !(spec.tableExcludeFields ?? []).includes(k));
|
|
821
|
+
const headers = [...keys, "Disposition"];
|
|
822
|
+
const body = groupRecords.map((r) => [
|
|
823
|
+
...keys.map((k) => humanCell(r.fields[k])),
|
|
824
|
+
r.disposition,
|
|
825
|
+
]);
|
|
826
|
+
out.write(formatTable(headers, body, colorEnabled(globals.noColor)) + "\n");
|
|
827
|
+
for (const r of groupRecords) {
|
|
828
|
+
out.write(` #${r.id} ${r.disposition}: ${r.reason}\n`);
|
|
829
|
+
if (r.commandHint) {
|
|
830
|
+
out.write(` → ${r.commandHint}\n`);
|
|
831
|
+
}
|
|
832
|
+
if (r.inspectHint) {
|
|
833
|
+
out.write(` → (inspect) ${r.inspectHint}\n`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function specByGroupLocal(group) {
|
|
839
|
+
const spec = GROUPS.find((g) => g.group === group);
|
|
840
|
+
if (!spec)
|
|
841
|
+
throw new Error(`unknown group ${group}`);
|
|
842
|
+
return spec;
|
|
843
|
+
}
|