@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 CHANGED
@@ -65,6 +65,37 @@ odoo crm teams create --name "Partnerships" --team-leader alice@acme.com
65
65
  odoo crm teams update 5 --name "Direct Sales (renamed)" --dry-run
66
66
  odoo crm teams delete 5 --dry-run
67
67
 
68
+ # Record attachments (ir.attachment) — explicit scope required
69
+ odoo attachments list --res-model helpdesk.ticket --res-id 398 --json
70
+ odoo attachments list --res-model mail.message --res-id 253924
71
+ odoo attachments list --search "spec" --limit 100
72
+ odoo attachments show 31337
73
+ odoo attachments download 31337 --out ./spec.pdf
74
+ odoo attachments delete 31337 --dry-run
75
+ odoo attachments delete 31337
76
+
77
+ # Marketing (mailing.*) — email is the key; trace deletion is always explicit
78
+ odoo marketing lists list
79
+ odoo marketing lists show 11
80
+ odoo marketing contacts list --email person@example.com --json
81
+ odoo marketing contacts list --list "Berget Code waitlist"
82
+ odoo marketing contacts delete 441 --dry-run
83
+ odoo marketing contacts delete 441
84
+ odoo marketing traces list --email person@example.com --json
85
+ odoo marketing traces delete 7001 --dry-run
86
+ odoo marketing traces delete 7001
87
+
88
+ # Sign-in identities (res.users) — show + guarded delete only
89
+ odoo users show 9
90
+ odoo users delete 9 --dry-run
91
+ odoo users delete 9
92
+ odoo users delete 9 3782 --json
93
+
94
+ # Footprint (read-only) — everything tied to an email, with SOP dispositions
95
+ odoo footprint person@example.com --json
96
+ odoo footprint person@example.com # grouped human report
97
+ odoo footprint person@example.com --active-only # what the UI sees
98
+
68
99
  # Internal notes (NEVER sends email to customer)
69
100
  odoo helpdesk tickets add-note 398 --body "Internal note text"
70
101
  odoo helpdesk tickets add-note 398 --body-file note.md
@@ -83,6 +114,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
83
114
 
84
115
  ## Features
85
116
 
117
+ - Footprint overview (`footprint <email>`) — one read-only invocation that enumerates everything tied to an email across the erasure-relevant modules (`res.partner` incl. archived, `res.users`, `crm.lead`, `mailing.*`, `helpdesk.ticket`, `mail.message`, `ir.attachment`), correlates the found partners into the RESTRICT accounting surfaces (`account.move`/`sale.order` incl. invoice & shipping addresses, `account.payment`), and classifies every record by erasure disposition: `delete`, `anonymize` (referenced — shell, never unlink), `keep` (bokföringslagen 7-year retention), `review` (account roots, legal-claim branch, internal users, unverifiable attachment parents). `--json` emits a structured per-group payload (fixed 11-group key order, `totalRecords`, per-group `found`/`total` server count/`truncated`/`dispositions` tally, many2one tuples normalized to `{id, name}`) with a `commandHint` on each actionable record, a top-level `runOrder` (SOP execution order), and decision-grade fields on partner records (`child_ids`/`user_ids`/`vat`). Review records with out-of-footprint parents are resolved inline — the parent's name is embedded in the reason (bounded by a per-model fetch cap; beyond it records keep an `inspectHint`). Every record tells you what to run next — or already answers the question. Read-only by design: verification (art. 12.6) is a human step and the argument must already be the verified email; record-rule hidden rows (e.g. `user_notification` messages) mean the raw SQL pre-flight stays the authoritative evidence. Never mutates anything
86
118
  - Read-only CRM surface (`crm leads list/show`) — default columns, `--fields`/`--all-fields`, `--limit`/`--offset`/`--order`, `--domain`/`--filter` with `fields_get` type checking, reference resolution (`--stage`/`--team`/`--assignee`/`--tag`), table/JSON output with `search_count` envelope
87
119
  - Partners surface (`partners list/show/tags/create/update/delete`) — `res.partner` CRUD with `--company`/`--individual` filters, `--search` (case-insensitive name/email match), `--tag` (repeatable, resolves `res.partner.category` by exact name then ilike), `--archived` (include inactive), curated list columns (ID, Name, Company Type, City, Email, Is a Company, Tags, Salesperson), curated `show` view with tag names, salesperson, customer/supplier ranks plus every populated `x_studio*` custom field, `partners tags` (usage counts via `read_group`, sorted by usage), first-class mutation flags for `--name`, `--email`, `--phone`, `--website`, `--parent` (m2o), `--is-company`, `--street`, `--city`, `--zip`, `--country` (m2o), `--comment`, `--add-tag`/`--remove-tag` on update (unknown tags fail on add; removal silently skips tags the record lacks; protected fields `company_type` and `x_studio_invoice_approved` refuse writes), full `--dry-run` support, `--fields`/`--all-fields`, table/JSON output
88
120
  - Sales teams surface (`crm teams list/show/create/update/delete`) — `--archived` (include inactive teams), `--team-leader` (team leader, login/email/name), `--active`/`--use-leads`/`--use-opportunities`/`--invoiced-target`/`--color`/`--sequence`, full `--dry-run` support, `--fields`/`--all-fields`, table/JSON output
package/dist/cli.js CHANGED
@@ -10,7 +10,12 @@ import { createTaskCommand, projectListCommand, projectShowCommand, tasksListCom
10
10
  import { documentsDocuments, documentsListCommand, foldersListCommand, documentsShowCommand, documentsCreateCommand, documentsUpdateCommand, documentsDeleteCommand, treeCommand, uploadCommand, downloadCommand, } from "./commands/documents.js";
11
11
  import { versionCommand } from "./commands/version.js";
12
12
  import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, partnersShowCommand, partnersTagsCommand, partnersUpdateCommand, resPartner, } from "./commands/partners.js";
13
+ import { usersDeleteCommand, usersShowCommand, } from "./commands/users.js";
14
+ import { attachmentsDeleteCommand, attachmentsDownloadCommand, attachmentsListCommand, attachmentsShowCommand, } from "./commands/attachments.js";
15
+ import { messagesDeleteCommand, messagesListCommand, messagesScrubCommand, messagesShowCommand, } from "./commands/messages.js";
16
+ import { contactsDeleteCommand, contactsListCommand, contactsShowCommand, listsListCommand, listsShowCommand, tracesDeleteCommand, tracesListCommand, tracesShowCommand, } from "./commands/marketing.js";
13
17
  import { modelsFieldsCommand, modelsReadCommand, } from "./commands/models.js";
18
+ import { footprintCommand, } from "./commands/footprint.js";
14
19
  import { CliError } from "./errors.js";
15
20
  import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
16
21
  import { printError } from "./output.js";
@@ -721,6 +726,320 @@ Examples:
721
726
  .action(async () => {
722
727
  await run((g) => partnersTagsCommand(g), program);
723
728
  });
729
+ const users = program
730
+ .command("users")
731
+ .description("sign-in identities (res.users)");
732
+ users
733
+ .command("show")
734
+ .description("read one user (portal login, email, linked partner; internal accounts included)")
735
+ .argument("<id>", "record id")
736
+ .option("--fields <a,b,c>", "fields to display")
737
+ .option("--all-fields", "display every readable field")
738
+ .addHelpText("after", `
739
+ Examples:
740
+ $ odoo users show 3781
741
+ $ odoo users show 3781 --json`)
742
+ .action(async (id, opts) => {
743
+ await run((g) => usersShowCommand(id, opts, g), program);
744
+ });
745
+ users
746
+ .command("delete")
747
+ .description("delete portal users (preview with --dry-run; unknown ids are skipped)")
748
+ .argument("<id...>", "record ids")
749
+ .addHelpText("after", `
750
+ Portal users only (share=true): any internal user (share=false) in the
751
+ batch aborts the whole command — employees and service accounts are managed
752
+ in the Odoo UI, never via the CLI.
753
+
754
+ Order matters: delete the portal user BEFORE deleting their partner —
755
+ res_users.partner_id is ondelete=restrict, so a surviving user makes the
756
+ partner delete fail with an FK violation.
757
+
758
+ Examples:
759
+ $ odoo users delete 3781 --dry-run
760
+ $ odoo users delete 3781
761
+ $ odoo users delete 3781 3782 --json`)
762
+ .action(async (ids) => {
763
+ await run((g) => usersDeleteCommand(ids, g), program);
764
+ });
765
+ const attachments = program
766
+ .command("attachments")
767
+ .description("record attachments (ir.attachment)");
768
+ attachments
769
+ .command("list")
770
+ .description("list attachments; REQUIRES a scope: --res-model, --res-id, and/or --search (AND-ed)")
771
+ .option("--res-model <model>", "filter by linked record model (soft reference)")
772
+ .option("--res-id <n>", "filter by linked record id")
773
+ .option("--search <text>", "ilike match on the attachment name")
774
+ .option("--domain <json>", "extra Odoo domain as a JSON array (AND-ed with the scope flags)")
775
+ .option("--limit <n>", "max records (default: 50)", "50")
776
+ .option("--offset <n>", "skip the first n records", "0")
777
+ .option("--order <order>", 'sort order, e.g. "create_date desc"', undefined)
778
+ .addHelpText("after", `
779
+ The ir.attachment table also holds accounting report PDFs. Never browse
780
+ or delete without a scope.
781
+
782
+ Examples:
783
+ $ odoo attachments list --res-model helpdesk.ticket --res-id 398 --json
784
+ $ odoo attachments list --res-model mail.message --res-id 253924
785
+ $ odoo attachments list --search "spec" --limit 100
786
+ $ odoo attachments list --res-model crm.lead --domain '[["res_id","=",91]]'`)
787
+ .action(async (opts) => {
788
+ await run((g) => attachmentsListCommand(opts, g), program);
789
+ });
790
+ attachments
791
+ .command("show")
792
+ .description("read one attachment (metadata; the datas payload is excluded)")
793
+ .argument("<id>", "record id")
794
+ .option("--fields <a,b,c>", "fields to display")
795
+ .option("--all-fields", "display every readable field (includes datas)")
796
+ .action(async (id, opts) => {
797
+ await run((g) => attachmentsShowCommand(id, opts, g), program);
798
+ });
799
+ attachments
800
+ .command("download")
801
+ .description("decode the attachment payload to a local file (--out - for stdout)")
802
+ .argument("<id>", "record id")
803
+ .option("--out <path>", "output path ('-' for stdout; default: datas_fname/name)")
804
+ .option("--force", "overwrite an existing output file")
805
+ .action(async (id, opts) => {
806
+ await run((g) => attachmentsDownloadCommand(id, opts, g), program);
807
+ });
808
+ attachments
809
+ .command("delete")
810
+ .description("delete attachments explicitly (preview with --dry-run; unknown ids are skipped)")
811
+ .argument("<id...>", "record ids")
812
+ .addHelpText("after", `
813
+ Attachments are NEVER removed automatically when their parent record is
814
+ deleted (res_model/res_id is a soft link, no FK cascade) — deletion must be
815
+ explicit. Scope the ids via 'attachments list' first.
816
+
817
+ Examples:
818
+ $ odoo attachments delete 31337 --dry-run
819
+ $ odoo attachments delete 31337 31338`)
820
+ .action(async (ids) => {
821
+ await run((g) => attachmentsDeleteCommand(ids, g), program);
822
+ });
823
+ const messages = program
824
+ .command("messages")
825
+ .description("chatter messages (mail.message), erasure-scoped");
826
+ messages
827
+ .command("list")
828
+ .description("list chatter on a record; REQUIRES a scope: --res-model and/or --res-id (AND-ed); bodies excluded (length only)")
829
+ .option("--res-model <model>", "filter by parent record model (e.g. res.partner)")
830
+ .option("--res-id <n>", "filter by parent record id")
831
+ .option("--domain <json>", "extra Odoo domain as a JSON array (AND-ed with the scope flags)")
832
+ .option("--limit <n>", "max records (default: 50)", "50")
833
+ .option("--offset <n>", "skip the first n records", "0")
834
+ .option("--order <order>", 'sort order, e.g. "date desc"', undefined)
835
+ .addHelpText("after", `
836
+ mail.message is a global chatter table across every model. Never browse
837
+ or delete without a scope. Bodies are excluded from list output
838
+ (body_length only); use 'messages show' for the full body.
839
+
840
+ Examples:
841
+ $ odoo messages list --res-model res.partner --res-id 3201 --json
842
+ $ odoo messages show 295203`)
843
+ .action(async (opts) => {
844
+ await run((g) => messagesListCommand(opts, g), program);
845
+ });
846
+ messages
847
+ .command("show")
848
+ .description("read one message (full body and tracking value ids)")
849
+ .argument("<id>", "record id")
850
+ .option("--fields <a,b,c>", "fields to display")
851
+ .option("--all-fields", "display every readable field")
852
+ .action(async (id, opts) => {
853
+ await run((g) => messagesShowCommand(id, opts, g), program);
854
+ });
855
+ messages
856
+ .command("scrub")
857
+ .description("strip tracked-change residue (old values) and replace the body with an erasure marker (preview with --dry-run)")
858
+ .argument("<id...>", "message ids")
859
+ .requiredOption("--res-model <model>", "parent record model — every id is cross-checked against it")
860
+ .requiredOption("--res-id <n>", "parent record id — every id is cross-checked against it")
861
+ .option("--marker <text>", "replacement body text (plain text, escaped, wrapped in <p>); default is the fixed SOP marker")
862
+ .addHelpText("after", `
863
+ Erasure SOP usage: removing personal data from a record leaves a chatter
864
+ message whose tracking rows carry the PRE-change values (old email, old
865
+ name). Scrub removes those rows and replaces the body with an erasure
866
+ marker. Messages without tracking rows are skipped (never rewrite
867
+ arbitrary chatter — use 'messages delete' for verified personal-data
868
+ bodies). Dry-run shows the tracked FIELD NAMES, never the old values.
869
+ The default marker is fixed SOP text; --marker exists because messages
870
+ attach to many models — it may not contain an email-shaped token.
871
+
872
+ Examples:
873
+ $ odoo messages scrub 295203 --res-model res.partner --res-id 3201 --dry-run
874
+ $ odoo messages scrub 295203 --res-model res.partner --res-id 3201
875
+ $ odoo messages scrub 5511 --res-model helpdesk.ticket --res-id 398 --marker "Ticket anonymized (erasure flow)"`)
876
+ .action(async (ids, opts) => {
877
+ await run((g) => messagesScrubCommand(ids, opts, g), program);
878
+ });
879
+ messages
880
+ .command("delete")
881
+ .description("delete chatter messages outright, for verified personal data in bodies (preview with --dry-run)")
882
+ .argument("<id...>", "message ids")
883
+ .requiredOption("--res-model <model>", "parent record model — every id is cross-checked against it")
884
+ .requiredOption("--res-id <n>", "parent record id — every id is cross-checked against it")
885
+ .addHelpText("after", `
886
+ This is the escalation-scope weapon: verify (SQL/probe) that the message
887
+ bodies actually contain personal data BEFORE deleting. For tracked-change
888
+ residue use 'messages scrub' instead — it keeps the accountability trail.
889
+
890
+ Examples:
891
+ $ odoo messages delete 44022 44028 --res-model res.partner --res-id 3201 --dry-run
892
+ $ odoo messages delete 44022 --res-model res.partner --res-id 3201`)
893
+ .action(async (ids, opts) => {
894
+ await run((g) => messagesDeleteCommand(ids, opts, g), program);
895
+ });
896
+ const marketing = program
897
+ .command("marketing")
898
+ .description("marketing lists, contacts, and send traces (mailing.*)");
899
+ const mlLists = marketing
900
+ .command("lists")
901
+ .description("mailing lists (mailing.list; read-only)");
902
+ mlLists
903
+ .command("list")
904
+ .description("list mailing lists")
905
+ .option("--search <text>", "ilike match on the list name")
906
+ .option("--domain <json>", "Odoo domain as a JSON array")
907
+ .option("--limit <n>", "max records (default: 50)", "50")
908
+ .option("--offset <n>", "skip the first n records", "0")
909
+ .option("--order <order>", 'sort order, e.g. "contact_count desc"', undefined)
910
+ .action(async (opts) => {
911
+ await run((g) => listsListCommand(opts, g), program);
912
+ });
913
+ mlLists
914
+ .command("show")
915
+ .description("read one mailing list (use --all-fields for subscriber ids)")
916
+ .argument("<id>", "record id")
917
+ .option("--fields <a,b,c>", "fields to display")
918
+ .option("--all-fields", "display every readable field")
919
+ .action(async (id, opts) => {
920
+ await run((g) => listsShowCommand(id, opts, g), program);
921
+ });
922
+ const mlContacts = marketing
923
+ .command("contacts")
924
+ .description("mailing contacts (mailing.contact)");
925
+ mlContacts
926
+ .command("list")
927
+ .description("list mailing contacts; --email is the identity-safe filter (email column only)")
928
+ .option("--search <text>", "ilike match on name OR email (interactive browsing)")
929
+ .option("--email <text>", "ilike match on email ONLY (identity-safe; exclusive with --search)")
930
+ .option("--list <name|id>", "filter by mailing list membership (resolved by name)")
931
+ .option("--domain <json>", "Odoo domain as a JSON array")
932
+ .option("--fields <a,b,c>", "columns to display")
933
+ .option("--limit <n>", "max records (default: 50)", "50")
934
+ .option("--offset <n>", "skip the first n records", "0")
935
+ .option("--order <order>", 'sort order, e.g. "email asc"', undefined)
936
+ .addHelpText("after", `
937
+ Email is the only reliable key for a person (name matching showed casing
938
+ variance in production): prefer --email over --search wherever the identity
939
+ of the subject matters.
940
+
941
+ Examples:
942
+ $ odoo marketing contacts list --email person@example.com --json
943
+ $ odoo marketing contacts list --list "Berget Code waitlist"
944
+ $ odoo marketing contacts delete 441 442 --dry-run`)
945
+ .action(async (opts) => {
946
+ await run((g) => contactsListCommand(opts, g), program);
947
+ });
948
+ mlContacts
949
+ .command("show")
950
+ .description("read one mailing contact (membership via --all-fields/list_ids)")
951
+ .argument("<id>", "record id")
952
+ .option("--fields <a,b,c>", "fields to display")
953
+ .option("--all-fields", "display every readable field")
954
+ .action(async (id, opts) => {
955
+ await run((g) => contactsShowCommand(id, opts, g), program);
956
+ });
957
+ mlContacts
958
+ .command("delete")
959
+ .description("delete mailing contacts (preview with --dry-run; unknown ids are skipped)")
960
+ .argument("<id...>", "record ids")
961
+ .addHelpText("after", `
962
+ Deleting a contact does NOT delete the person's send traces: mailing.trace
963
+ rows carry a denormalized email with no FK to the contact — erase both:
964
+ 1. odoo marketing traces list --email <address> → trace ids
965
+ 2. odoo marketing traces delete <ids>
966
+
967
+ Examples:
968
+ $ odoo marketing contacts delete 441 --dry-run
969
+ $ odoo marketing contacts delete 441 442`)
970
+ .action(async (ids) => {
971
+ await run((g) => contactsDeleteCommand(ids, g), program);
972
+ });
973
+ const mlTraces = marketing
974
+ .command("traces")
975
+ .description("per-recipient send traces (mailing.trace; denormalized email, no FK)");
976
+ mlTraces
977
+ .command("list")
978
+ .description("list send traces by denormalized email — independent of contact records")
979
+ .option("--email <text>", "ilike match on the trace email column")
980
+ .option("--mailing <name|id>", "filter by campaign (mailing.mailing, resolved by name)")
981
+ .option("--state <status>", 'filter by trace_status (e.g. "sent", "opened", "replied", "error")')
982
+ .option("--domain <json>", "Odoo domain as a JSON array")
983
+ .option("--fields <a,b,c>", "columns to display")
984
+ .option("--limit <n>", "max records (default: 50)", "50")
985
+ .option("--offset <n>", "skip the first n records", "0")
986
+ .option("--order <order>", 'sort order, e.g. "create_date desc"', undefined)
987
+ .action(async (opts) => {
988
+ await run((g) => tracesListCommand(opts, g), program);
989
+ });
990
+ mlTraces
991
+ .command("show")
992
+ .description("read one send trace")
993
+ .argument("<id>", "record id")
994
+ .option("--fields <a,b,c>", "fields to display")
995
+ .option("--all-fields", "display every readable field")
996
+ .action(async (id, opts) => {
997
+ await run((g) => tracesShowCommand(id, opts, g), program);
998
+ });
999
+ mlTraces
1000
+ .command("delete")
1001
+ .description("delete send traces explicitly (preview with --dry-run; unknown ids are skipped)")
1002
+ .argument("<id...>", "record ids")
1003
+ .addHelpText("after", `
1004
+ mailing.trace has NO FK to mailing.contact: deleting contacts never removes
1005
+ traces — deletion here is always explicit. Trace email is denormalized, so
1006
+ trace deletion never requires the contact record to exist.
1007
+
1008
+ Examples:
1009
+ $ odoo marketing traces list --email person@example.com --json
1010
+ $ odoo marketing traces delete 7001 7002 --dry-run`)
1011
+ .action(async (ids) => {
1012
+ await run((g) => tracesDeleteCommand(ids, g), program);
1013
+ });
1014
+ program
1015
+ .command("footprint")
1016
+ .description("everything tied to an email across erasure-relevant modules, with SOP dispositions — read-only")
1017
+ .argument("<email>", "the verified email of the data subject")
1018
+ .option("--active-only", "search only active rows (default: active + archived — archived data is still personal data)")
1019
+ .option("--limit <n>", "max records per group", "500")
1020
+ .addHelpText("after", `
1021
+ Read-only overview for erasure handling (Flow A step 1 + the §6.2.4
1022
+ pre-flight): searches res.partner, res.users, crm.lead, mailing.*,
1023
+ helpdesk.ticket, mail.message and ir.attachment by email and correlates the
1024
+ found partners into the RESTRICT accounting surfaces (account.move /
1025
+ sale.order / account.payment) that decide unlink-vs-anonymize.
1026
+
1027
+ Dispositions: delete | anonymize (referenced — shell, never unlink) | keep
1028
+ (bokföringslagen) | review (machine cannot decide: account roots,
1029
+ legal-claim branch, internal users, unverifiable attachment parents).
1030
+
1031
+ Verification is a human step (art. 12.6): the argument must already be the
1032
+ VERIFIED email. Nothing is mutated — act through the per-module commands,
1033
+ --dry-run first. Archived rows are included by default; run the §6.2.4 SQL
1034
+ pre-flight as the authoritative evidence (record rules can hide rows).
1035
+
1036
+ Examples:
1037
+ $ odoo footprint person@example.com --json
1038
+ $ odoo footprint person@example.com
1039
+ $ odoo footprint person@example.com --active-only`)
1040
+ .action(async (email, opts) => {
1041
+ await run((g) => footprintCommand(email, opts, g), program);
1042
+ });
724
1043
  const models = program
725
1044
  .command("models")
726
1045
  .description("generic model introspection (any model with an XML-RPC API)");
@@ -0,0 +1,158 @@
1
+ import { stat, writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { Buffer } from "node:buffer";
4
+ import { ModelFields } from "../model.js";
5
+ import { authenticatedClient, parseId, showCommand, } from "../resource.js";
6
+ import { deleteCommand } from "../mutation.js";
7
+ import { notFoundError, usageError } from "../errors.js";
8
+ import { colorEnabled, printJson } from "../output.js";
9
+ import { parseDomain } from "../filter.js";
10
+ import { humanCell } from "../table.js";
11
+ /**
12
+ * ir.attachment surface (generic binary store behind soft res_model/res_id
13
+ * links). Read + download + delete only; no create/update. `list` REQUIRES an
14
+ * explicit scope (--res-model / --res-id / --search): the table also holds
15
+ * accounting report PDFs (bokföringsunderlag), and an unbounded dump would
16
+ * invite filter-less bulk deletion. Nothing in Odoo cascades attachment
17
+ * deletion when a parent record is removed, so erasure must call
18
+ * `attachments delete` explicitly (invariant 4).
19
+ */
20
+ export const irAttachment = {
21
+ model: "ir.attachment",
22
+ showCommand: "odoo attachments show",
23
+ defaultListFields: [
24
+ "id",
25
+ "name",
26
+ "res_model",
27
+ "res_id",
28
+ "mimetype",
29
+ "file_size",
30
+ "create_date",
31
+ ],
32
+ showExtraFields: ["checksum", "create_uid", "write_date", "url", "write_uid"],
33
+ resolutionHints: {},
34
+ priorityLabels: {},
35
+ pinnedDomain: [],
36
+ references: {},
37
+ };
38
+ function parseCount(raw, flag) {
39
+ if (!/^\d+$/.test(raw)) {
40
+ throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
41
+ }
42
+ return Number(raw);
43
+ }
44
+ export async function attachmentsListCommand(opts, globals) {
45
+ const client = await authenticatedClient(globals);
46
+ const resId = opts.resId !== undefined ? parseCount(opts.resId, "--res-id") : undefined;
47
+ // Scope guard: never browse the whole ir.attachment table. It holds
48
+ // accounting PDFs alongside erasure-relevant files, so an unbounded dump
49
+ // is a footgun both to read and to delete from.
50
+ if (opts.resModel === undefined && resId === undefined && !opts.search) {
51
+ throw usageError("attachments list requires an explicit scope", "pass --res-model (e.g. `--res-model helpdesk.ticket`), --res-id, and/or --search; combine freely, they are AND-ed");
52
+ }
53
+ const limit = parseCount(opts.limit, "--limit");
54
+ const offset = parseCount(opts.offset, "--offset");
55
+ const fieldsMeta = await ModelFields.load(client, "ir.attachment");
56
+ // No `active` field on ir.attachment (no archive machinery): plain
57
+ // search_read/search_count without an active_test context.
58
+ const domain = [];
59
+ if (opts.resModel !== undefined)
60
+ domain.push(["res_model", "=", opts.resModel]);
61
+ if (resId !== undefined)
62
+ domain.push(["res_id", "=", resId]);
63
+ if (opts.search)
64
+ domain.push(["name", "ilike", opts.search]);
65
+ if (opts.domain)
66
+ domain.push(...parseDomain(opts.domain));
67
+ const order = opts.order ?? "id asc";
68
+ const [records, count] = await Promise.all([
69
+ client.executeKw("ir.attachment", "search_read", [domain], {
70
+ fields: irAttachment.defaultListFields,
71
+ limit,
72
+ offset,
73
+ order,
74
+ }),
75
+ client.executeKw("ir.attachment", "search_count", [
76
+ domain,
77
+ ]),
78
+ ]);
79
+ if (globals.json) {
80
+ printJson({ model: "ir.attachment", records, count, limit, offset });
81
+ return;
82
+ }
83
+ const headers = irAttachment.defaultListFields.map((f) => fieldsMeta.get(f)?.string ?? f);
84
+ const rows = records.map((rec) => headers.map((_, i) => humanCell(rec[irAttachment.defaultListFields[i]])));
85
+ process.stdout.write(formatPlainTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
86
+ }
87
+ /** Aligned key-value-free table; TTY coloring matches other list commands. */
88
+ function formatPlainTable(headers, rows, _color) {
89
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
90
+ const fmt = (cells) => cells
91
+ .map((c, i) => c.padEnd(widths[i]))
92
+ .join(" ")
93
+ .trimEnd();
94
+ const lines = [fmt(headers)];
95
+ for (const r of rows)
96
+ lines.push(fmt(r));
97
+ return lines.join("\n");
98
+ }
99
+ export function attachmentsShowCommand(idArg, opts, globals) {
100
+ return showCommand(irAttachment, idArg, opts, globals);
101
+ }
102
+ export function attachmentsDeleteCommand(idArgs, globals) {
103
+ return deleteCommand(irAttachment, idArgs, globals);
104
+ }
105
+ /**
106
+ * Read `datas` (base64 of the filestore payload) and write decoded bytes to
107
+ * --out (or "-" for stdout, or a default derived from datas_fname/name).
108
+ * Modeled on the `documents download` implementation.
109
+ */
110
+ export async function attachmentsDownloadCommand(idArg, opts, globals) {
111
+ const id = parseId(idArg);
112
+ const client = await authenticatedClient(globals);
113
+ await ModelFields.load(client, "ir.attachment");
114
+ const rows = (await client.executeKw("ir.attachment", "read", [[id]], {
115
+ fields: ["name", "datas", "mimetype", "url"],
116
+ }));
117
+ if (rows.length === 0) {
118
+ throw notFoundError("record_not_found", `no ir.attachment record with id ${id}`);
119
+ }
120
+ const rec = rows[0];
121
+ if (rec.datas === false || rec.datas === "") {
122
+ throw usageError(`attachment ${id} "${rec.name}" has no file content`, "url-style attachments store a link instead of bytes" +
123
+ (rec.url ? ` (\u0022${rec.url}\u0022)` : " (check the url field)"));
124
+ }
125
+ const buffer = Buffer.from(rec.datas, "base64");
126
+ if (opts.out === "-") {
127
+ process.stdout.write(buffer);
128
+ return;
129
+ }
130
+ const defaultName = rec.name || `ir.attachment.${id}.bin`;
131
+ const outPath = resolve(opts.out ?? defaultName);
132
+ try {
133
+ await stat(outPath);
134
+ if (!opts.force) {
135
+ throw usageError(`file already exists: ${outPath}`, "pass --force to overwrite");
136
+ }
137
+ }
138
+ catch (err) {
139
+ if (err.code !== "ENOENT")
140
+ throw err;
141
+ }
142
+ await writeFile(outPath, buffer);
143
+ if (globals.json) {
144
+ printJson({
145
+ model: "ir.attachment",
146
+ action: "download",
147
+ id,
148
+ name: rec.name,
149
+ path: outPath,
150
+ file_size: buffer.length,
151
+ mimetype: rec.mimetype,
152
+ });
153
+ return;
154
+ }
155
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
156
+ const line = `Downloaded ir.attachment ${id} → ${outPath} (${buffer.length} bytes)`;
157
+ process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
158
+ }