@bergetai/odoo-cli 0.9.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,10 +16,17 @@ Requires Node.js 20 or later.
16
16
 
17
17
  ```bash
18
18
  # Authenticate (API key via stdin — never as a flag)
19
- pass odoo/key | odoo auth login --url https://acme.odoo.com --db acme --username automation
19
+ pass odoo/key | odoo auth login --profile production --url https://acme.odoo.com --db acme --username automation
20
+
21
+ # Authenticate a second environment (kept side by side in one config)
22
+ pass odoo/stage-key | odoo auth login --profile stage --url https://odoo.stage.berget.ai --db berget-stage --username automation
23
+
24
+ # Always select the profile explicitly — commands never guess credentials
25
+ odoo --profile production partners list --company --json
26
+ ODOO_PROFILE=stage odoo partners list --company --json
20
27
 
21
28
  # Check credentials and their configuration sources
22
- odoo auth status --json
29
+ odoo auth status --profile production --json
23
30
 
24
31
  # Unauthenticated connectivity check
25
32
  odoo version
@@ -65,6 +72,37 @@ odoo crm teams create --name "Partnerships" --team-leader alice@acme.com
65
72
  odoo crm teams update 5 --name "Direct Sales (renamed)" --dry-run
66
73
  odoo crm teams delete 5 --dry-run
67
74
 
75
+ # Record attachments (ir.attachment) — explicit scope required
76
+ odoo attachments list --res-model helpdesk.ticket --res-id 398 --json
77
+ odoo attachments list --res-model mail.message --res-id 253924
78
+ odoo attachments list --search "spec" --limit 100
79
+ odoo attachments show 31337
80
+ odoo attachments download 31337 --out ./spec.pdf
81
+ odoo attachments delete 31337 --dry-run
82
+ odoo attachments delete 31337
83
+
84
+ # Marketing (mailing.*) — email is the key; trace deletion is always explicit
85
+ odoo marketing lists list
86
+ odoo marketing lists show 11
87
+ odoo marketing contacts list --email person@example.com --json
88
+ odoo marketing contacts list --list "Berget Code waitlist"
89
+ odoo marketing contacts delete 441 --dry-run
90
+ odoo marketing contacts delete 441
91
+ odoo marketing traces list --email person@example.com --json
92
+ odoo marketing traces delete 7001 --dry-run
93
+ odoo marketing traces delete 7001
94
+
95
+ # Sign-in identities (res.users) — show + guarded delete only
96
+ odoo users show 9
97
+ odoo users delete 9 --dry-run
98
+ odoo users delete 9
99
+ odoo users delete 9 3782 --json
100
+
101
+ # Footprint (read-only) — everything tied to an email, with SOP dispositions
102
+ odoo footprint person@example.com --json
103
+ odoo footprint person@example.com # grouped human report
104
+ odoo footprint person@example.com --active-only # what the UI sees
105
+
68
106
  # Internal notes (NEVER sends email to customer)
69
107
  odoo helpdesk tickets add-note 398 --body "Internal note text"
70
108
  odoo helpdesk tickets add-note 398 --body-file note.md
@@ -77,12 +115,36 @@ odoo crm records message-delete 253924 258332 --dry-run
77
115
  odoo crm records message-delete 253924 258332
78
116
  ```
79
117
 
80
- Configuration precedence: flags > environment (`ODOO_URL`, `ODOO_DB`, `ODOO_USERNAME`, `ODOO_API_KEY`) > `$XDG_CONFIG_HOME/odoo/config.json`. The API key is never accepted as a flag value.
118
+ Configuration precedence: flags > environment (`ODOO_URL`, `ODOO_DB`, `ODOO_USERNAME`, `ODOO_API_KEY`) > the selected profile in `$XDG_CONFIG_HOME/odoo/config.toml`. The API key is never accepted as a flag value.
119
+
120
+ ### Profiles
121
+
122
+ Credentials are stored per **profile** — one TOML table per environment:
123
+
124
+ ```toml
125
+ [profiles.production]
126
+ url = "https://odoo.berget.ai"
127
+ db = "berget"
128
+ username = "automation"
129
+ api_key = "…"
130
+ server_version = "18.0+e"
131
+
132
+ [profiles.stage]
133
+ url = "https://odoo.stage.berget.ai"
134
+ db = "berget-stage"
135
+ username = "automation"
136
+ api_key = "…"
137
+ ```
138
+
139
+ Every command requires an explicit profile: pass `--profile <name>` or set `ODOO_PROFILE`. Without one the command exits 2 — credentials are never guessed, so a command can never hit the wrong environment by accident. `auth login` and the flags-only `odoo version` check tolerate a profile that does not exist yet (bootstrap), while any other command reports an unknown profile.
140
+
141
+ Older configs (`config.json`, including top-level credential keys) keep working: they read as the `default` profile and migrate to `config.toml` on the next `auth login` — the legacy file is removed once the TOML config holds everything.
81
142
 
82
143
  Note: `odoo --version` prints the CLI's own version; `odoo version` queries the server.
83
144
 
84
145
  ## Features
85
146
 
147
+ - 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
148
  - 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
149
  - 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
150
  - 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
@@ -95,7 +157,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
95
157
  - `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
96
158
  - Internal notes (`add-note`) — creates internal Log Notes only (subtype_id=2, message_type=comment), **NEVER sends email to customer**, by design prevents accidental customer communication. Optional `--author <email|name|id>` attributes the note to the partner who actually sent the logged message (resolved via `res.partner`; ambiguous matches list candidates and never guess)
97
159
  - Log note correction (`message-update`, `message-delete`) — repair formatting or remove duplicate log notes; strictly limited to comment-type messages (emails/notifications refused by design), full `--dry-run` support, unknown delete ids skipped
98
- - Config sources and precedence with atomic `0600` config writes, XML-RPC transport with timeouts and HTTPS enforcement, and an auth failure taxonomy (db missing / bad credentials / unreachable)
160
+ - Config profiles (named environments in one `0600` config file, explicit `--profile`/`ODOO_PROFILE` selection), atomic config writes with legacy migration, XML-RPC transport with timeouts and HTTPS enforcement, and an auth failure taxonomy (db missing / bad credentials / unreachable)
99
161
 
100
162
  ## Compatibility
101
163
 
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";
@@ -55,9 +60,10 @@ CLI's own version, while \`odoo version\` queries the server's version.`)
55
60
  .configureHelp({ sortSubcommands: true });
56
61
  program.addHelpText("after", `
57
62
  Examples:
58
- $ pass odoo/key | odoo auth login --url https://acme.odoo.com --db acme --username automation
59
- $ odoo auth status --json
60
- $ odoo version
63
+ $ pass odoo/key | odoo auth login --profile production --url https://acme.odoo.com --db acme --username automation
64
+ $ odoo auth login --profile stage --url https://stage.odoo.com --db stage --username automation
65
+ $ odoo auth status --profile production --json
66
+ $ odoo --profile production crm leads list
61
67
 
62
68
  Docs: https://www.odoo.com/documentation/18.0/developer/reference/external_api.html`);
63
69
  applyGlobalOptions(program);
@@ -73,8 +79,8 @@ Docs: https://www.odoo.com/documentation/18.0/developer/reference/external_api.h
73
79
  .description("verify credentials and write them to the config file (key via stdin/env/file)")
74
80
  .addHelpText("after", `
75
81
  Examples:
76
- $ pass odoo/key | odoo auth login --url https://acme.odoo.com --db acme --username automation
77
- $ ODOO_API_KEY=... odoo auth login --url https://acme.odoo.com --db acme --username automation`)
82
+ $ pass odoo/key | odoo auth login --profile production --url https://acme.odoo.com --db acme --username automation
83
+ $ ODOO_API_KEY=... odoo auth login --profile stage --url https://stage.odoo.com --db stage --username automation`)
78
84
  .action(async () => {
79
85
  await run(authLoginCommand, program);
80
86
  });
@@ -721,6 +727,320 @@ Examples:
721
727
  .action(async () => {
722
728
  await run((g) => partnersTagsCommand(g), program);
723
729
  });
730
+ const users = program
731
+ .command("users")
732
+ .description("sign-in identities (res.users)");
733
+ users
734
+ .command("show")
735
+ .description("read one user (portal login, email, linked partner; internal accounts included)")
736
+ .argument("<id>", "record id")
737
+ .option("--fields <a,b,c>", "fields to display")
738
+ .option("--all-fields", "display every readable field")
739
+ .addHelpText("after", `
740
+ Examples:
741
+ $ odoo users show 3781
742
+ $ odoo users show 3781 --json`)
743
+ .action(async (id, opts) => {
744
+ await run((g) => usersShowCommand(id, opts, g), program);
745
+ });
746
+ users
747
+ .command("delete")
748
+ .description("delete portal users (preview with --dry-run; unknown ids are skipped)")
749
+ .argument("<id...>", "record ids")
750
+ .addHelpText("after", `
751
+ Portal users only (share=true): any internal user (share=false) in the
752
+ batch aborts the whole command — employees and service accounts are managed
753
+ in the Odoo UI, never via the CLI.
754
+
755
+ Order matters: delete the portal user BEFORE deleting their partner —
756
+ res_users.partner_id is ondelete=restrict, so a surviving user makes the
757
+ partner delete fail with an FK violation.
758
+
759
+ Examples:
760
+ $ odoo users delete 3781 --dry-run
761
+ $ odoo users delete 3781
762
+ $ odoo users delete 3781 3782 --json`)
763
+ .action(async (ids) => {
764
+ await run((g) => usersDeleteCommand(ids, g), program);
765
+ });
766
+ const attachments = program
767
+ .command("attachments")
768
+ .description("record attachments (ir.attachment)");
769
+ attachments
770
+ .command("list")
771
+ .description("list attachments; REQUIRES a scope: --res-model, --res-id, and/or --search (AND-ed)")
772
+ .option("--res-model <model>", "filter by linked record model (soft reference)")
773
+ .option("--res-id <n>", "filter by linked record id")
774
+ .option("--search <text>", "ilike match on the attachment name")
775
+ .option("--domain <json>", "extra Odoo domain as a JSON array (AND-ed with the scope flags)")
776
+ .option("--limit <n>", "max records (default: 50)", "50")
777
+ .option("--offset <n>", "skip the first n records", "0")
778
+ .option("--order <order>", 'sort order, e.g. "create_date desc"', undefined)
779
+ .addHelpText("after", `
780
+ The ir.attachment table also holds accounting report PDFs. Never browse
781
+ or delete without a scope.
782
+
783
+ Examples:
784
+ $ odoo attachments list --res-model helpdesk.ticket --res-id 398 --json
785
+ $ odoo attachments list --res-model mail.message --res-id 253924
786
+ $ odoo attachments list --search "spec" --limit 100
787
+ $ odoo attachments list --res-model crm.lead --domain '[["res_id","=",91]]'`)
788
+ .action(async (opts) => {
789
+ await run((g) => attachmentsListCommand(opts, g), program);
790
+ });
791
+ attachments
792
+ .command("show")
793
+ .description("read one attachment (metadata; the datas payload is excluded)")
794
+ .argument("<id>", "record id")
795
+ .option("--fields <a,b,c>", "fields to display")
796
+ .option("--all-fields", "display every readable field (includes datas)")
797
+ .action(async (id, opts) => {
798
+ await run((g) => attachmentsShowCommand(id, opts, g), program);
799
+ });
800
+ attachments
801
+ .command("download")
802
+ .description("decode the attachment payload to a local file (--out - for stdout)")
803
+ .argument("<id>", "record id")
804
+ .option("--out <path>", "output path ('-' for stdout; default: datas_fname/name)")
805
+ .option("--force", "overwrite an existing output file")
806
+ .action(async (id, opts) => {
807
+ await run((g) => attachmentsDownloadCommand(id, opts, g), program);
808
+ });
809
+ attachments
810
+ .command("delete")
811
+ .description("delete attachments explicitly (preview with --dry-run; unknown ids are skipped)")
812
+ .argument("<id...>", "record ids")
813
+ .addHelpText("after", `
814
+ Attachments are NEVER removed automatically when their parent record is
815
+ deleted (res_model/res_id is a soft link, no FK cascade) — deletion must be
816
+ explicit. Scope the ids via 'attachments list' first.
817
+
818
+ Examples:
819
+ $ odoo attachments delete 31337 --dry-run
820
+ $ odoo attachments delete 31337 31338`)
821
+ .action(async (ids) => {
822
+ await run((g) => attachmentsDeleteCommand(ids, g), program);
823
+ });
824
+ const messages = program
825
+ .command("messages")
826
+ .description("chatter messages (mail.message), erasure-scoped");
827
+ messages
828
+ .command("list")
829
+ .description("list chatter on a record; REQUIRES a scope: --res-model and/or --res-id (AND-ed); bodies excluded (length only)")
830
+ .option("--res-model <model>", "filter by parent record model (e.g. res.partner)")
831
+ .option("--res-id <n>", "filter by parent record id")
832
+ .option("--domain <json>", "extra Odoo domain as a JSON array (AND-ed with the scope flags)")
833
+ .option("--limit <n>", "max records (default: 50)", "50")
834
+ .option("--offset <n>", "skip the first n records", "0")
835
+ .option("--order <order>", 'sort order, e.g. "date desc"', undefined)
836
+ .addHelpText("after", `
837
+ mail.message is a global chatter table across every model. Never browse
838
+ or delete without a scope. Bodies are excluded from list output
839
+ (body_length only); use 'messages show' for the full body.
840
+
841
+ Examples:
842
+ $ odoo messages list --res-model res.partner --res-id 3201 --json
843
+ $ odoo messages show 295203`)
844
+ .action(async (opts) => {
845
+ await run((g) => messagesListCommand(opts, g), program);
846
+ });
847
+ messages
848
+ .command("show")
849
+ .description("read one message (full body and tracking value ids)")
850
+ .argument("<id>", "record id")
851
+ .option("--fields <a,b,c>", "fields to display")
852
+ .option("--all-fields", "display every readable field")
853
+ .action(async (id, opts) => {
854
+ await run((g) => messagesShowCommand(id, opts, g), program);
855
+ });
856
+ messages
857
+ .command("scrub")
858
+ .description("strip tracked-change residue (old values) and replace the body with an erasure marker (preview with --dry-run)")
859
+ .argument("<id...>", "message ids")
860
+ .requiredOption("--res-model <model>", "parent record model — every id is cross-checked against it")
861
+ .requiredOption("--res-id <n>", "parent record id — every id is cross-checked against it")
862
+ .option("--marker <text>", "replacement body text (plain text, escaped, wrapped in <p>); default is the fixed SOP marker")
863
+ .addHelpText("after", `
864
+ Erasure SOP usage: removing personal data from a record leaves a chatter
865
+ message whose tracking rows carry the PRE-change values (old email, old
866
+ name). Scrub removes those rows and replaces the body with an erasure
867
+ marker. Messages without tracking rows are skipped (never rewrite
868
+ arbitrary chatter — use 'messages delete' for verified personal-data
869
+ bodies). Dry-run shows the tracked FIELD NAMES, never the old values.
870
+ The default marker is fixed SOP text; --marker exists because messages
871
+ attach to many models — it may not contain an email-shaped token.
872
+
873
+ Examples:
874
+ $ odoo messages scrub 295203 --res-model res.partner --res-id 3201 --dry-run
875
+ $ odoo messages scrub 295203 --res-model res.partner --res-id 3201
876
+ $ odoo messages scrub 5511 --res-model helpdesk.ticket --res-id 398 --marker "Ticket anonymized (erasure flow)"`)
877
+ .action(async (ids, opts) => {
878
+ await run((g) => messagesScrubCommand(ids, opts, g), program);
879
+ });
880
+ messages
881
+ .command("delete")
882
+ .description("delete chatter messages outright, for verified personal data in bodies (preview with --dry-run)")
883
+ .argument("<id...>", "message ids")
884
+ .requiredOption("--res-model <model>", "parent record model — every id is cross-checked against it")
885
+ .requiredOption("--res-id <n>", "parent record id — every id is cross-checked against it")
886
+ .addHelpText("after", `
887
+ This is the escalation-scope weapon: verify (SQL/probe) that the message
888
+ bodies actually contain personal data BEFORE deleting. For tracked-change
889
+ residue use 'messages scrub' instead — it keeps the accountability trail.
890
+
891
+ Examples:
892
+ $ odoo messages delete 44022 44028 --res-model res.partner --res-id 3201 --dry-run
893
+ $ odoo messages delete 44022 --res-model res.partner --res-id 3201`)
894
+ .action(async (ids, opts) => {
895
+ await run((g) => messagesDeleteCommand(ids, opts, g), program);
896
+ });
897
+ const marketing = program
898
+ .command("marketing")
899
+ .description("marketing lists, contacts, and send traces (mailing.*)");
900
+ const mlLists = marketing
901
+ .command("lists")
902
+ .description("mailing lists (mailing.list; read-only)");
903
+ mlLists
904
+ .command("list")
905
+ .description("list mailing lists")
906
+ .option("--search <text>", "ilike match on the list name")
907
+ .option("--domain <json>", "Odoo domain as a JSON array")
908
+ .option("--limit <n>", "max records (default: 50)", "50")
909
+ .option("--offset <n>", "skip the first n records", "0")
910
+ .option("--order <order>", 'sort order, e.g. "contact_count desc"', undefined)
911
+ .action(async (opts) => {
912
+ await run((g) => listsListCommand(opts, g), program);
913
+ });
914
+ mlLists
915
+ .command("show")
916
+ .description("read one mailing list (use --all-fields for subscriber ids)")
917
+ .argument("<id>", "record id")
918
+ .option("--fields <a,b,c>", "fields to display")
919
+ .option("--all-fields", "display every readable field")
920
+ .action(async (id, opts) => {
921
+ await run((g) => listsShowCommand(id, opts, g), program);
922
+ });
923
+ const mlContacts = marketing
924
+ .command("contacts")
925
+ .description("mailing contacts (mailing.contact)");
926
+ mlContacts
927
+ .command("list")
928
+ .description("list mailing contacts; --email is the identity-safe filter (email column only)")
929
+ .option("--search <text>", "ilike match on name OR email (interactive browsing)")
930
+ .option("--email <text>", "ilike match on email ONLY (identity-safe; exclusive with --search)")
931
+ .option("--list <name|id>", "filter by mailing list membership (resolved by name)")
932
+ .option("--domain <json>", "Odoo domain as a JSON array")
933
+ .option("--fields <a,b,c>", "columns to display")
934
+ .option("--limit <n>", "max records (default: 50)", "50")
935
+ .option("--offset <n>", "skip the first n records", "0")
936
+ .option("--order <order>", 'sort order, e.g. "email asc"', undefined)
937
+ .addHelpText("after", `
938
+ Email is the only reliable key for a person (name matching showed casing
939
+ variance in production): prefer --email over --search wherever the identity
940
+ of the subject matters.
941
+
942
+ Examples:
943
+ $ odoo marketing contacts list --email person@example.com --json
944
+ $ odoo marketing contacts list --list "Berget Code waitlist"
945
+ $ odoo marketing contacts delete 441 442 --dry-run`)
946
+ .action(async (opts) => {
947
+ await run((g) => contactsListCommand(opts, g), program);
948
+ });
949
+ mlContacts
950
+ .command("show")
951
+ .description("read one mailing contact (membership via --all-fields/list_ids)")
952
+ .argument("<id>", "record id")
953
+ .option("--fields <a,b,c>", "fields to display")
954
+ .option("--all-fields", "display every readable field")
955
+ .action(async (id, opts) => {
956
+ await run((g) => contactsShowCommand(id, opts, g), program);
957
+ });
958
+ mlContacts
959
+ .command("delete")
960
+ .description("delete mailing contacts (preview with --dry-run; unknown ids are skipped)")
961
+ .argument("<id...>", "record ids")
962
+ .addHelpText("after", `
963
+ Deleting a contact does NOT delete the person's send traces: mailing.trace
964
+ rows carry a denormalized email with no FK to the contact — erase both:
965
+ 1. odoo marketing traces list --email <address> → trace ids
966
+ 2. odoo marketing traces delete <ids>
967
+
968
+ Examples:
969
+ $ odoo marketing contacts delete 441 --dry-run
970
+ $ odoo marketing contacts delete 441 442`)
971
+ .action(async (ids) => {
972
+ await run((g) => contactsDeleteCommand(ids, g), program);
973
+ });
974
+ const mlTraces = marketing
975
+ .command("traces")
976
+ .description("per-recipient send traces (mailing.trace; denormalized email, no FK)");
977
+ mlTraces
978
+ .command("list")
979
+ .description("list send traces by denormalized email — independent of contact records")
980
+ .option("--email <text>", "ilike match on the trace email column")
981
+ .option("--mailing <name|id>", "filter by campaign (mailing.mailing, resolved by name)")
982
+ .option("--state <status>", 'filter by trace_status (e.g. "sent", "opened", "replied", "error")')
983
+ .option("--domain <json>", "Odoo domain as a JSON array")
984
+ .option("--fields <a,b,c>", "columns to display")
985
+ .option("--limit <n>", "max records (default: 50)", "50")
986
+ .option("--offset <n>", "skip the first n records", "0")
987
+ .option("--order <order>", 'sort order, e.g. "create_date desc"', undefined)
988
+ .action(async (opts) => {
989
+ await run((g) => tracesListCommand(opts, g), program);
990
+ });
991
+ mlTraces
992
+ .command("show")
993
+ .description("read one send trace")
994
+ .argument("<id>", "record id")
995
+ .option("--fields <a,b,c>", "fields to display")
996
+ .option("--all-fields", "display every readable field")
997
+ .action(async (id, opts) => {
998
+ await run((g) => tracesShowCommand(id, opts, g), program);
999
+ });
1000
+ mlTraces
1001
+ .command("delete")
1002
+ .description("delete send traces explicitly (preview with --dry-run; unknown ids are skipped)")
1003
+ .argument("<id...>", "record ids")
1004
+ .addHelpText("after", `
1005
+ mailing.trace has NO FK to mailing.contact: deleting contacts never removes
1006
+ traces — deletion here is always explicit. Trace email is denormalized, so
1007
+ trace deletion never requires the contact record to exist.
1008
+
1009
+ Examples:
1010
+ $ odoo marketing traces list --email person@example.com --json
1011
+ $ odoo marketing traces delete 7001 7002 --dry-run`)
1012
+ .action(async (ids) => {
1013
+ await run((g) => tracesDeleteCommand(ids, g), program);
1014
+ });
1015
+ program
1016
+ .command("footprint")
1017
+ .description("everything tied to an email across erasure-relevant modules, with SOP dispositions — read-only")
1018
+ .argument("<email>", "the verified email of the data subject")
1019
+ .option("--active-only", "search only active rows (default: active + archived — archived data is still personal data)")
1020
+ .option("--limit <n>", "max records per group", "500")
1021
+ .addHelpText("after", `
1022
+ Read-only overview for erasure handling (Flow A step 1 + the §6.2.4
1023
+ pre-flight): searches res.partner, res.users, crm.lead, mailing.*,
1024
+ helpdesk.ticket, mail.message and ir.attachment by email and correlates the
1025
+ found partners into the RESTRICT accounting surfaces (account.move /
1026
+ sale.order / account.payment) that decide unlink-vs-anonymize.
1027
+
1028
+ Dispositions: delete | anonymize (referenced — shell, never unlink) | keep
1029
+ (bokföringslagen) | review (machine cannot decide: account roots,
1030
+ legal-claim branch, internal users, unverifiable attachment parents).
1031
+
1032
+ Verification is a human step (art. 12.6): the argument must already be the
1033
+ VERIFIED email. Nothing is mutated — act through the per-module commands,
1034
+ --dry-run first. Archived rows are included by default; run the §6.2.4 SQL
1035
+ pre-flight as the authoritative evidence (record rules can hide rows).
1036
+
1037
+ Examples:
1038
+ $ odoo footprint person@example.com --json
1039
+ $ odoo footprint person@example.com
1040
+ $ odoo footprint person@example.com --active-only`)
1041
+ .action(async (email, opts) => {
1042
+ await run((g) => footprintCommand(email, opts, g), program);
1043
+ });
724
1044
  const models = program
725
1045
  .command("models")
726
1046
  .description("generic model introspection (any model with an XML-RPC API)");
@@ -769,6 +1089,7 @@ function collectGlobalOptions(program) {
769
1089
  return {
770
1090
  json: Boolean(o.json),
771
1091
  dryRun: Boolean(o.dryRun),
1092
+ profile: o.profile,
772
1093
  url: o.url,
773
1094
  db: o.db,
774
1095
  username: o.username,
@@ -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
+ }