thinkwork-cli 0.13.2 → 1.0.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.
Files changed (231) hide show
  1. package/README.md +3 -14
  2. package/dist/{api-client-JYZXYQJR.js → api-client-4P4JZOBX.js} +1 -1
  3. package/dist/{chunk-Z2F6RJFR.js → chunk-4OMDWYZQ.js} +4 -5
  4. package/dist/{chunk-ILZKG5WV.js → chunk-AD2NIWB5.js} +434 -69
  5. package/dist/chunk-MWQ7RO76.js +137 -0
  6. package/dist/{chunk-DVPBI5HM.js → chunk-OLUQ4U2Z.js} +26 -26
  7. package/dist/{chunk-QOERT44A.js → chunk-YQCYD6NE.js} +57 -44
  8. package/dist/cli.js +4400 -16431
  9. package/dist/commands/enterprise/templates/deploy-repo/scripts/apply-release.mjs +0 -1
  10. package/dist/commands/enterprise/templates/deploy-repo/scripts/smoke.mjs +0 -1
  11. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +0 -14
  12. package/dist/{dist-es-CRV46D5B.js → dist-es-2OTNTL4N.js} +4 -4
  13. package/dist/{dist-es-ENBUC3JP.js → dist-es-2XVAH4BQ.js} +2 -2
  14. package/dist/{dist-es-DCY4IC3Y.js → dist-es-3DPDUJAH.js} +2 -2
  15. package/dist/{dist-es-G7PTG75M.js → dist-es-BNZTBBVC.js} +1 -1
  16. package/dist/{dist-es-GKDBE7E6.js → dist-es-CD2RBDAC.js} +1 -1
  17. package/dist/{dist-es-R7FGL2IB.js → dist-es-I4FW57PS.js} +1 -1
  18. package/dist/{dist-es-USORBXZ5.js → dist-es-KVFWJNDY.js} +4 -4
  19. package/dist/{dist-es-HY3535OI.js → dist-es-PS73DWNL.js} +2 -2
  20. package/dist/{dist-es-LDE437AM.js → dist-es-S3NZQFGT.js} +1 -1
  21. package/dist/{dist-es-G54LBMRN.js → dist-es-SGCA4P32.js} +1 -1
  22. package/dist/{dist-es-Q57SZXZ7.js → dist-es-WBM6J4RA.js} +2 -2
  23. package/dist/{dist-es-P26FWFLT.js → dist-es-ZGVADYIF.js} +8 -8
  24. package/dist/drizzle/0227_analyst_reader_role.sql +17 -26
  25. package/dist/drizzle/0230_analyst_rls.sql +28 -45
  26. package/dist/drizzle/0261_native_auth_control_plane.sql +7 -3
  27. package/dist/drizzle/0270_tool_execution_ledger.sql +115 -0
  28. package/dist/drizzle/0271_ontology_twin_declarations.sql +47 -0
  29. package/dist/drizzle/0272_identity_graph_projection_cursor.sql +23 -0
  30. package/dist/drizzle/0273_twin_materialization_suggestions.sql +29 -0
  31. package/dist/drizzle/0274_tenant_mcp_twin_keys.sql +59 -0
  32. package/dist/drizzle/0275_identity_bulk_rebuild_fence.sql +30 -0
  33. package/dist/drizzle/0276_tool_execution_ledger_allow_cascade_delete.sql +18 -0
  34. package/dist/drizzle/0277_knowledge_base_sources.sql +107 -0
  35. package/dist/drizzle/0278_kb_page_transcription.sql +55 -0
  36. package/dist/drizzle/0279_mcp_servers_off_plugin_provenance.sql +93 -0
  37. package/dist/drizzle/0280_work_item_external_refs_drop_plane_provider.sql +46 -0
  38. package/dist/drizzle/0281_tenant_mcp_twin_keys_expiry_suffix.sql +9 -0
  39. package/dist/drizzle/0282_tenant_mcp_twin_keys_grants.sql +24 -0
  40. package/dist/drizzle/0283_agents_agentcore_runtime_dispatch.sql +13 -0
  41. package/dist/drizzle/0284_user_brain_claims.sql +69 -0
  42. package/dist/drizzle/0285_tenant_mcp_twin_keys_trusted_subsystem.sql +30 -0
  43. package/dist/drizzle/0286_brain_analytics_key.sql +26 -0
  44. package/dist/drizzle/0287_brain_subject.sql +25 -0
  45. package/dist/drizzle/0288_multi_lane_identities.sql +25 -0
  46. package/dist/{loadSso-DJVB7BXE.js → loadSso-ETCXTR4I.js} +2 -2
  47. package/dist/{loadSso-JAMNA3WY.js → loadSso-UBD7KUHE.js} +2 -2
  48. package/dist/plugins/README.md +2 -2
  49. package/dist/plugins/catalog/package.json +0 -3
  50. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +6 -3
  51. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +21 -21
  52. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +102 -25
  53. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +7 -6
  54. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -15
  55. package/dist/plugins/catalog/src/contracts.ts +15 -0
  56. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -18
  57. package/dist/plugins/n8n/test/integrated-app-contract.test.ts +1 -1
  58. package/dist/plugins/twenty/package.json +2 -1
  59. package/dist/plugins/twenty/scripts/LAMBDA_DEPLOY.md +114 -0
  60. package/dist/plugins/twenty/scripts/build-lambda.sh +29 -0
  61. package/dist/plugins/twenty/scripts/configure-product-views.ts +195 -0
  62. package/dist/plugins/twenty/scripts/lambda-handler.ts +49 -0
  63. package/dist/plugins/twenty/scripts/lib/__tests__/load-attachments.test.ts +38 -0
  64. package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +145 -2
  65. package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +397 -151
  66. package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +28 -129
  67. package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +10 -0
  68. package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +13 -8
  69. package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +222 -2
  70. package/dist/plugins/twenty/scripts/lib/load-attachments.ts +29 -0
  71. package/dist/plugins/twenty/scripts/lib/load-records.ts +160 -43
  72. package/dist/plugins/twenty/scripts/lib/mappers.ts +368 -152
  73. package/dist/plugins/twenty/scripts/lib/members-ensure.ts +43 -268
  74. package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +57 -0
  75. package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +136 -31
  76. package/dist/plugins/twenty/scripts/migrate-lastmile.ts +254 -152
  77. package/dist/plugins/twenty/scripts/provision-twenty-members.ts +44 -1
  78. package/dist/plugins/twenty/scripts/purge-lastmile-import.ts +178 -0
  79. package/dist/plugins/twenty/scripts/restructure-products.ts +312 -0
  80. package/dist/plugins/twenty/scripts/retag-stage-values.ts +362 -0
  81. package/dist/plugins/twenty/test/manifest.test.ts +2 -4
  82. package/dist/scripts/post-deploy.sh +79 -1
  83. package/dist/{signin-UGDWTGS4.js → signin-5QZNRSOO.js} +2 -2
  84. package/dist/{sso-oidc-Q4VXANXY.js → sso-oidc-XK673P54.js} +2 -2
  85. package/dist/{sts-CWLSE3GH.js → sts-OXI2UALF.js} +6 -134
  86. package/dist/{sts-DNZLNVOV.js → sts-SQW4NBK3.js} +6 -134
  87. package/dist/terraform/examples/greenfield/main.tf +233 -175
  88. package/dist/terraform/examples/greenfield/terraform.tfvars.example +12 -8
  89. package/dist/terraform/modules/app/agentcore-admin/main.tf +47 -0
  90. package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +39 -47
  91. package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +13 -66
  92. package/dist/terraform/modules/app/agentcore-identity/main.tf +106 -26
  93. package/dist/terraform/modules/app/agentcore-identity/outputs.tf +6 -6
  94. package/dist/terraform/modules/app/agentcore-identity/scripts/bootstrap_twenty_oauth_client.sh +216 -99
  95. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_identity.sh +6 -9
  96. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_workload_identity.sh +19 -0
  97. package/dist/terraform/modules/app/agentcore-identity/scripts/ensure_workload_identity.sh +29 -0
  98. package/dist/terraform/modules/app/agentcore-identity/scripts/read_identity.sh +22 -7
  99. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_identity.sh +17 -46
  100. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_provider.mjs +65 -3
  101. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/after.tf +15 -0
  102. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/before.tf +7 -0
  103. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-after.tf +8 -0
  104. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-before.tf +9 -0
  105. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-after.tf +3 -0
  106. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-before.tf +3 -0
  107. package/dist/terraform/modules/app/agentcore-identity/variables.tf +6 -0
  108. package/dist/terraform/modules/app/agentcore-memory/README.md +46 -9
  109. package/dist/terraform/modules/app/agentcore-memory/main.tf +27 -10
  110. package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +156 -24
  111. package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
  112. package/dist/terraform/modules/app/agentcore-pi/main.tf +42 -52
  113. package/dist/terraform/modules/app/agentcore-pi/outputs.tf +3 -3
  114. package/dist/terraform/modules/app/agentcore-pi/scripts/reconcile_pi_runtime.mjs +175 -0
  115. package/dist/terraform/modules/app/agentcore-pi/variables.tf +35 -28
  116. package/dist/terraform/modules/app/agentcore-platform/main.tf +68 -0
  117. package/dist/terraform/modules/app/agentcore-runtime/main.tf +4 -20
  118. package/dist/terraform/modules/app/capability-broker/main.tf +2 -2
  119. package/dist/terraform/modules/app/capability-broker/variables.tf +11 -0
  120. package/dist/terraform/modules/app/deployment-control-plane/README.md +264 -0
  121. package/dist/terraform/modules/app/deployment-control-plane/buildspec.yml +4 -1
  122. package/dist/terraform/modules/app/deployment-control-plane/main.tf +94 -0
  123. package/dist/terraform/modules/app/deployment-control-plane/outputs.tf +5 -0
  124. package/dist/terraform/modules/app/deployment-control-plane/runner.py +2641 -124
  125. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +2034 -154
  126. package/dist/terraform/modules/app/deployment-control-plane/variables.tf +33 -0
  127. package/dist/terraform/modules/app/lambda-api/auth-state.tf +72 -0
  128. package/dist/terraform/modules/app/lambda-api/chat-latency-observability.tf +466 -0
  129. package/dist/terraform/modules/app/lambda-api/dispatch-dlq.tf +55 -0
  130. package/dist/terraform/modules/app/lambda-api/handlers.tf +411 -723
  131. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +262 -198
  132. package/dist/terraform/modules/app/lambda-api/main.tf +51 -13
  133. package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +4 -181
  134. package/dist/terraform/modules/app/lambda-api/memory-alarms.tf +3 -28
  135. package/dist/terraform/modules/app/lambda-api/oauth-secrets.tf +1 -2
  136. package/dist/terraform/modules/app/lambda-api/outputs.tf +17 -45
  137. package/dist/terraform/modules/app/lambda-api/turn-assertion.tf +45 -0
  138. package/dist/terraform/modules/app/lambda-api/variables.tf +219 -126
  139. package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/README.md +1 -1
  140. package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/main.tf +18 -18
  141. package/dist/terraform/modules/app/sandbox-log-scrubber/README.md +11 -10
  142. package/dist/terraform/modules/app/sandbox-log-scrubber/main.tf +5 -5
  143. package/dist/terraform/modules/app/static-site/main.tf +1 -1
  144. package/dist/terraform/modules/app/www-dns/main.tf +9 -27
  145. package/dist/terraform/modules/app/www-dns/variables.tf +4 -10
  146. package/dist/terraform/modules/data/aurora-postgres/README.md +34 -0
  147. package/dist/terraform/modules/data/aurora-postgres/alarms.tf +82 -0
  148. package/dist/terraform/modules/data/aurora-postgres/main.tf +27 -57
  149. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +0 -9
  150. package/dist/terraform/modules/data/aurora-postgres/variables.tf +41 -0
  151. package/dist/terraform/modules/data/s3-buckets/main.tf +5 -6
  152. package/dist/terraform/modules/foundation/cognito/main.tf +81 -8
  153. package/dist/terraform/modules/foundation/cognito/outputs.tf +5 -0
  154. package/dist/terraform/modules/foundation/cognito/variables.tf +24 -12
  155. package/dist/terraform/modules/thinkwork/README.md +5 -1
  156. package/dist/terraform/modules/thinkwork/main.tf +376 -374
  157. package/dist/terraform/modules/thinkwork/outputs.tf +12 -158
  158. package/dist/terraform/modules/thinkwork/variables.tf +246 -89
  159. package/dist/workspace-defaults/files/INSTRUCTIONS.md +9 -11
  160. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +38 -35
  161. package/dist/workspace-defaults/files/ROUTER.md +1 -1
  162. package/dist/workspace-defaults/files/TOOLS.md +9 -39
  163. package/dist/workspace-defaults/files/agents/analyst/INSTRUCTIONS.md +6 -3
  164. package/dist/workspace-defaults/files/catalog-skills/brain-brief-builder/SKILL.md +165 -0
  165. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/SKILL.md +75 -0
  166. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/mcp-tooling.md +40 -0
  167. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/validation-and-handoff.md +40 -0
  168. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/workflow-authoring.md +46 -0
  169. package/dist/workspace-defaults/files/skills/artifact-builder/references/crm-dashboard.md +1 -1
  170. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +22 -3
  171. package/package.json +1 -1
  172. package/dist/drizzle/0261_managed_harness_multiplayer.sql +0 -313
  173. package/dist/drizzle/0262_parallel_harness_threads.sql +0 -9
  174. package/dist/drizzle/0264_harness_tool_execution_ledger.sql +0 -113
  175. package/dist/plugins/company-data/README.md +0 -49
  176. package/dist/plugins/company-data/package.json +0 -21
  177. package/dist/plugins/company-data/src/index.ts +0 -30
  178. package/dist/plugins/company-data/src/manifest.ts +0 -26
  179. package/dist/plugins/company-data/test/manifest.test.ts +0 -116
  180. package/dist/plugins/company-data/tsconfig.json +0 -8
  181. package/dist/plugins/company-etl/README.md +0 -46
  182. package/dist/plugins/company-etl/package.json +0 -21
  183. package/dist/plugins/company-etl/src/index.ts +0 -29
  184. package/dist/plugins/company-etl/src/manifest.ts +0 -26
  185. package/dist/plugins/company-etl/test/manifest.test.ts +0 -108
  186. package/dist/plugins/company-etl/tsconfig.json +0 -8
  187. package/dist/plugins/lastmile/README.md +0 -50
  188. package/dist/plugins/lastmile/package.json +0 -23
  189. package/dist/plugins/lastmile/smoke/lastmile-plugin-smoke.mjs +0 -661
  190. package/dist/plugins/lastmile/src/api/tasks-adapter.ts +0 -572
  191. package/dist/plugins/lastmile/src/discovery.fixture.ts +0 -64
  192. package/dist/plugins/lastmile/src/index.ts +0 -46
  193. package/dist/plugins/lastmile/src/manifest.ts +0 -139
  194. package/dist/plugins/lastmile/test/api/tasks-adapter.test.ts +0 -266
  195. package/dist/plugins/lastmile/test/discovery.test.ts +0 -88
  196. package/dist/plugins/lastmile/tsconfig.json +0 -8
  197. package/dist/plugins/n8n/smoke/n8n-managed-app-smoke.mjs +0 -409
  198. package/dist/plugins/twenty/smoke/twenty-managed-app-smoke.mjs +0 -359
  199. package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +0 -61
  200. package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/sitecustomize.py +0 -227
  201. package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/test_sitecustomize.py +0 -277
  202. package/dist/terraform/modules/app/agentcore-code-interpreter/scripts/build_and_push_sandbox_base.sh +0 -70
  203. package/dist/terraform/modules/app/agentcore-gateway/main.tf +0 -305
  204. package/dist/terraform/modules/app/agentcore-gateway/outputs.tf +0 -44
  205. package/dist/terraform/modules/app/agentcore-gateway/scripts/delete_gateway.sh +0 -93
  206. package/dist/terraform/modules/app/agentcore-gateway/scripts/read_gateway.sh +0 -33
  207. package/dist/terraform/modules/app/agentcore-gateway/scripts/reconcile_gateway.sh +0 -543
  208. package/dist/terraform/modules/app/agentcore-gateway/variables.tf +0 -70
  209. package/dist/terraform/modules/app/agentcore-harness/main.tf +0 -330
  210. package/dist/terraform/modules/app/agentcore-harness/outputs.tf +0 -72
  211. package/dist/terraform/modules/app/agentcore-harness/scripts/delete_harness.sh +0 -5
  212. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-lifecycle.mjs +0 -430
  213. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-readback.mjs +0 -180
  214. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-tool-contract.mjs +0 -193
  215. package/dist/terraform/modules/app/agentcore-harness/scripts/prune_harness.sh +0 -5
  216. package/dist/terraform/modules/app/agentcore-harness/scripts/read_harness.sh +0 -5
  217. package/dist/terraform/modules/app/agentcore-harness/scripts/reconcile_harness.sh +0 -5
  218. package/dist/terraform/modules/app/agentcore-harness/variables.tf +0 -96
  219. package/dist/terraform/modules/app/hindsight-memory/README.md +0 -89
  220. package/dist/terraform/modules/app/hindsight-memory/main.tf +0 -411
  221. package/dist/terraform/modules/app/lambda-api/agentcore-harness-alarms.tf +0 -88
  222. package/dist/terraform/modules/app/lambda-api/agentcore-identity-alarms.tf +0 -91
  223. package/dist/terraform/modules/data/bedrock-knowledge-base/main.tf +0 -102
  224. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/outputs.tf +0 -0
  225. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/sync-database.py +0 -0
  226. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/test_sync_database.py +0 -0
  227. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/variables.tf +0 -0
  228. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/README.md +0 -0
  229. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/main.tf +0 -0
  230. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/outputs.tf +0 -0
  231. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/variables.tf +0 -0
@@ -5,34 +5,54 @@
5
5
  *
6
6
  * OPERATOR RUNBOOK
7
7
  * ================
8
+ * PREREQUISITE — Twenty's stock workflow "Create company when adding a new person"
9
+ * MUST be deactivated (Settings -> Workflows) before any --apply run. It fires on
10
+ * every person insert, invents a company from the email domain, and repoints the
11
+ * person at it; it once mislinked 21,989 of 24,028 migrated people. The workspace
12
+ * API key is forbidden from toggling workflows, so this is a human step.
13
+ *
14
+ * NIGHTLY AUTOMATION — during TEI's cutover this runs unattended via
15
+ * `.github/workflows/tei-lastmile-sync.yml` (scheduled `--apply` delta re-sync).
16
+ * The run is idempotent (upsert by sourceId + deletion mirror), never provisions
17
+ * members, and needs no AWS credentials — the ~4 CRM attachments TEI's Twenty
18
+ * cannot ingest are reported as gaps, not failures. Two invariants the workflow
19
+ * cannot enforce and that a human owns: (1) the person->company workflow above
20
+ * stays deactivated, and (2) the shared TWENTY_REP_PASSWORD is rotated once the
21
+ * validation window closes (see step 5 below).
22
+ *
8
23
  * Environment (all required unless noted):
9
24
  * TWENTY_PUBLIC_URL TEI's Twenty base URL, e.g. https://crm.tei.thinkwork.ai
10
- * TWENTY_API_KEY Workspace API key (Settings API & Webhooks)
25
+ * TWENTY_API_KEY Workspace API key (Settings -> API & Webhooks)
11
26
  * LASTMILE_DATABASE_URL LastMile dispatch Postgres connection string (read access)
12
- * TWENTY_ADMIN_EMAIL Admin user email required only when --apply provisions members
13
- * TWENTY_ADMIN_PASSWORD Admin user password — same
14
- * TWENTY_REP_PASSWORD Shared test password for provisioned reps (R5; value lives
15
- * outside the repo). ROTATE OR DEACTIVATE every rep account at
16
- * cutover, on abort, or if the validation window drags on.
17
- * TWENTY_WORKSPACE_ID Optional override; otherwise resolved from the admin session
18
- * TWENTY_REP_EMAIL_DOMAIN Domain for reps with no LastMile email
27
+ * TWENTY_REP_EMAIL_DOMAIN Optional; domain for reps with no LastMile email
19
28
  * (default texasenterprises.com; <first-initial><lastname>@)
20
29
  * AWS_PROFILE / AWS_REGION Needed for attachment binaries (LastMile S3 bucket)
30
+ * NODE_EXTRA_CA_CERTS RDS CA bundle, when the DB rejects the system trust store
31
+ *
32
+ * Members are NOT provisioned by this script: TEI's Twenty does not serve the auth
33
+ * GraphQL schema, so `scripts/provision-twenty-members.ts` writes them directly to
34
+ * Twenty's Postgres (see that file's header). Run it FIRST, then this script, so
35
+ * every record resolves its owner. Reps without a member load ownerless and heal
36
+ * on the next run once their member exists.
21
37
  *
22
38
  * Invocations (dry-run is the default; nothing is written without --apply):
23
- * npx tsx scripts/migrate-lastmile.ts # full dry-run + planned mutations
24
- * npx tsx scripts/migrate-lastmile.ts --apply # seed (or delta re-sync — same command)
25
- * npx tsx scripts/migrate-lastmile.ts --rollback # list the sourceId-owned record set
26
- * npx tsx scripts/migrate-lastmile.ts --rollback --apply # soft-delete that set (restorable)
27
- * --skip-invites with --apply: load schema+records but provision no new
28
- * members (owners heal on a later re-run once members exist)
39
+ * npx tsx scripts/migrate-lastmile.ts # dry-run + planned mutations
40
+ * npx tsx scripts/migrate-lastmile.ts --apply # seed / delta re-sync
41
+ * npx tsx scripts/migrate-lastmile.ts --rollback # list the sourceId-owned set
42
+ * npx tsx scripts/migrate-lastmile.ts --rollback --apply # soft-delete it (restorable)
43
+ *
44
+ * To start over from an empty workspace: `scripts/purge-lastmile-import.ts --apply`
45
+ * hard-deletes every sourceId-owned record plus workflow-created domain companies.
29
46
  *
30
47
  * Cutover day, in order:
31
- * 1. Freeze LastMile CRM edits.
32
- * 2. Re-run with --apply (delta re-sync; upserts by sourceId, mirrors deletions).
33
- * 3. Check the parity report + spot checks (this script's stdout JSON).
34
- * 4. Rotate every rep password / enforce resets the shared test password must not
35
- * outlive the validation phase.
48
+ * 1. Confirm the person-to-company workflow is still deactivated.
49
+ * 2. Freeze LastMile CRM edits.
50
+ * 3. Re-run with --apply (delta re-sync; upserts by sourceId,
51
+ * mirrors deletions via an id-set diff -- LastMile has no dead-mark columns).
52
+ * 4. Check the parity report + spot checks (this script's stdout JSON).
53
+ * 5. ROTATE every rep password. The shared TWENTY_REP_PASSWORD is set on 89
54
+ * accounts and must not outlive the validation phase -- rotate on cutover, on
55
+ * abort, and on an over-long validation window alike (R5).
36
56
  *
37
57
  * The parity report prints to stdout as JSON; all other logging goes to stderr.
38
58
  * Secrets are never logged. One invocation at a time (KTD3).
@@ -53,7 +73,9 @@ import {
53
73
  NOTE,
54
74
  OPPORTUNITY,
55
75
  OPPORTUNITY_PRODUCT,
76
+ ORGANIZATION,
56
77
  PERSON,
78
+ PRODUCT,
57
79
  rollbackEntity,
58
80
  upsertNotes,
59
81
  upsertOpportunityProducts,
@@ -62,24 +84,27 @@ import {
62
84
  } from "./lib/load-records";
63
85
  import {
64
86
  buildOwnerIndex,
87
+ dedupeContactEmails,
65
88
  deriveRepEmail,
66
89
  mapAccount,
90
+ mapDispatchCustomer,
67
91
  mapContact,
68
92
  mapCrmComment,
69
93
  mapCustomerNote,
70
- mapLead,
71
- mapOpportunity,
72
- mapOpportunityProduct,
94
+ mapCrmTask,
95
+ mapOrganization,
96
+ mapProduct,
97
+ mapTaskProducts,
98
+ mapTaskStatusActivity,
99
+ PRODUCT_CATALOG,
100
+ productSourceId,
73
101
  sourceId,
74
102
  } from "./lib/mappers";
75
- import {
76
- adminSignIn,
77
- ensureMembers,
78
- type RepToProvision,
79
- } from "./lib/members-ensure";
103
+ import { ensureMembers, type RepToProvision } from "./lib/members-ensure";
80
104
  import {
81
105
  applySchemaEnsure,
82
106
  ensureOpportunityProductObject,
107
+ ensureOrganizationObject,
83
108
  fetchObjectMetadata,
84
109
  planSchemaEnsure,
85
110
  } from "./lib/schema-ensure";
@@ -88,19 +113,17 @@ import { TwentyClient, normalizeBaseUrl } from "./lib/twenty-client";
88
113
  interface CliArgs {
89
114
  apply: boolean;
90
115
  rollback: boolean;
91
- /** Provision no new members this run: match existing members only. Owner
92
- * refs for unprovisioned reps stay null and heal on a later re-run once the
93
- * members exist (content hash changes when ownerId resolves). */
94
- skipInvites: boolean;
95
116
  }
96
117
 
97
118
  function parseArgs(argv: string[]): CliArgs {
98
- const args: CliArgs = { apply: false, rollback: false, skipInvites: false };
119
+ const args: CliArgs = { apply: false, rollback: false };
99
120
  for (const arg of argv) {
100
121
  if (arg === "--apply") args.apply = true;
101
122
  else if (arg === "--dry-run") args.apply = false;
102
123
  else if (arg === "--rollback") args.rollback = true;
103
- else if (arg === "--skip-invites") args.skipInvites = true;
124
+ // Accepted and ignored: this script never provisions members, so the flag
125
+ // that once suppressed it is a no-op. Kept so an old invocation still runs.
126
+ else if (arg === "--skip-invites") continue;
104
127
  else throw new Error(`Unknown argument: ${arg}`);
105
128
  }
106
129
  return args;
@@ -118,20 +141,28 @@ function log(message: string): void {
118
141
  process.stderr.write(`${message}\n`);
119
142
  }
120
143
 
121
- async function main(): Promise<void> {
122
- const args = parseArgs(process.argv.slice(2));
123
- const dryRun = !args.apply;
144
+ /**
145
+ * Env-driven entry point shared by the CLI (`main`) and the Lambda handler
146
+ * (`scripts/lambda-handler.ts`). Reads the three required env vars, runs the
147
+ * migration or rollback, and RETURNS the parity report rather than printing it
148
+ * — the caller decides where the report goes (stdout for the CLI, the Lambda
149
+ * response for n8n). Never provisions members; safe to invoke unattended.
150
+ */
151
+ export async function executeMigration(
152
+ opts: { apply: boolean; rollback?: boolean } = { apply: false },
153
+ ): Promise<Record<string, unknown>> {
154
+ const dryRun = !opts.apply;
155
+ const rollback = Boolean(opts.rollback);
124
156
 
125
157
  const baseUrl = normalizeBaseUrl(requireEnv("TWENTY_PUBLIC_URL"));
126
158
  const apiKey = requireEnv("TWENTY_API_KEY");
127
159
  const lastmileUrl = requireEnv("LASTMILE_DATABASE_URL");
128
- const repPassword = process.env.TWENTY_REP_PASSWORD ?? "";
129
160
 
130
161
  const client = new TwentyClient({ baseUrl, authToken: apiKey });
131
162
  const reader = createLastmileReader(lastmileUrl);
132
163
 
133
164
  const report: Record<string, unknown> = {
134
- mode: args.rollback
165
+ mode: rollback
135
166
  ? dryRun
136
167
  ? "rollback-dry-run"
137
168
  : "rollback"
@@ -142,23 +173,25 @@ async function main(): Promise<void> {
142
173
  };
143
174
 
144
175
  try {
145
- if (args.rollback) {
176
+ if (rollback) {
146
177
  await runRollback(client, report, dryRun);
147
- return;
178
+ } else {
179
+ await runMigration({ client, reader, baseUrl, report, dryRun });
148
180
  }
149
- await runMigration({
150
- client,
151
- reader,
152
- baseUrl,
153
- repPassword,
154
- report,
155
- dryRun,
156
- skipInvites: args.skipInvites,
157
- });
158
181
  } finally {
159
182
  await reader.close();
160
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
183
+ report.finishedAt = new Date().toISOString();
161
184
  }
185
+ return report;
186
+ }
187
+
188
+ async function main(): Promise<void> {
189
+ const args = parseArgs(process.argv.slice(2));
190
+ const report = await executeMigration({
191
+ apply: args.apply,
192
+ rollback: args.rollback,
193
+ });
194
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
162
195
  }
163
196
 
164
197
  async function runRollback(
@@ -172,6 +205,7 @@ async function runRollback(
172
205
  { entity: OPPORTUNITY, prefixes: ["lead:", "opportunity:"] },
173
206
  { entity: PERSON, prefixes: ["contact:"] },
174
207
  { entity: COMPANY, prefixes: ["account:"] },
208
+ { entity: ORGANIZATION, prefixes: ["organization:"] },
175
209
  ];
176
210
  for (const { entity, prefixes } of entities) {
177
211
  const counters = emptyCounters();
@@ -196,13 +230,10 @@ async function runMigration(options: {
196
230
  client: TwentyClient;
197
231
  reader: LastmileReader;
198
232
  baseUrl: string;
199
- repPassword: string;
200
233
  report: Record<string, unknown>;
201
234
  dryRun: boolean;
202
- skipInvites: boolean;
203
235
  }): Promise<void> {
204
- const { client, reader, baseUrl, repPassword, report, dryRun, skipInvites } =
205
- options;
236
+ const { client, reader, baseUrl, report, dryRun } = options;
206
237
 
207
238
  // Phase A: preflight ------------------------------------------------------
208
239
  log("preflight: connecting to LastMile and Twenty...");
@@ -216,16 +247,34 @@ async function runMigration(options: {
216
247
  // Phase B: schema ensure --------------------------------------------------
217
248
  log("schema: ensuring custom fields and stage options...");
218
249
  let objects = await fetchObjectMetadata(client);
219
- const objectEnsure = await ensureOpportunityProductObject(
250
+ const productObjectEnsure = await ensureOpportunityProductObject(
220
251
  client,
221
252
  objects,
222
253
  dryRun,
223
254
  );
224
- if (!dryRun && (objectEnsure.created || objectEnsure.relationCreated)) {
225
- // Re-read metadata so the field plan addresses the real object id.
255
+ if (
256
+ !dryRun &&
257
+ (productObjectEnsure.created || productObjectEnsure.relationCreated)
258
+ ) {
226
259
  objects = await fetchObjectMetadata(client);
227
260
  }
228
- report.opportunityProductObject = objectEnsure;
261
+ const organizationObjectEnsure = await ensureOrganizationObject(
262
+ client,
263
+ objects,
264
+ dryRun,
265
+ );
266
+ if (
267
+ !dryRun &&
268
+ (organizationObjectEnsure.created ||
269
+ organizationObjectEnsure.relationCreated)
270
+ ) {
271
+ // Re-read metadata so the field plan addresses the real object ids.
272
+ objects = await fetchObjectMetadata(client);
273
+ }
274
+ report.customObjects = {
275
+ opportunityProduct: productObjectEnsure,
276
+ organization: organizationObjectEnsure,
277
+ };
229
278
  const schemaPlan = planSchemaEnsure(objects);
230
279
  if (dryRun) {
231
280
  report.schema = {
@@ -268,74 +317,25 @@ async function runMigration(options: {
268
317
  sample: derivedEmails.slice(0, 20),
269
318
  };
270
319
 
271
- let adminClient: TwentyClient | null = null;
272
- let workspaceId = process.env.TWENTY_WORKSPACE_ID ?? null;
273
- if (!dryRun) {
274
- const adminEmail = process.env.TWENTY_ADMIN_EMAIL;
275
- const adminPassword = process.env.TWENTY_ADMIN_PASSWORD;
276
- if (adminEmail && adminPassword) {
277
- if (!repPassword)
278
- throw new Error(
279
- "Missing required environment variable: TWENTY_REP_PASSWORD",
280
- );
281
- const session = await adminSignIn({
282
- baseUrl,
283
- email: adminEmail,
284
- password: adminPassword,
285
- });
286
- adminClient = new TwentyClient({
287
- baseUrl,
288
- authToken: session.accessToken,
289
- });
290
- workspaceId = workspaceId ?? session.workspaceId;
291
- }
292
- }
293
320
  const members = await ensureMembers({
294
321
  dataClient: client,
295
- adminClient,
296
322
  reps: repsToProvision,
297
- repPassword,
298
- workspaceId,
299
- // --skip-invites: match existing members only, invite nobody this run.
300
- dryRun: dryRun || skipInvites,
301
323
  });
302
- if (skipInvites) {
303
- report.membersMode =
304
- "skip-invites (existing members matched; no invitations sent)";
324
+ if (members.missingMembers > 0) {
325
+ log(
326
+ `members: ${members.missingMembers} rep(s) have no Twenty member — run ` +
327
+ `scripts/provision-twenty-members.ts, then re-run to attach their owners`,
328
+ );
305
329
  }
306
330
  report.members = {
307
331
  report: members.report,
308
- provisionable: members.ownerMap.size,
309
- hadFailures: members.hadFailures,
332
+ resolved: members.ownerMap.size,
333
+ missingMembers: members.missingMembers,
310
334
  };
311
335
  // Archived reps still resolve for historical ownership when a member with a
312
336
  // matching email already exists; unprovisioned owners map to null and are
313
337
  // flagged per record (U5 edge case).
314
- const ownerMap = new Map(members.ownerMap);
315
- if (dryRun) {
316
- // Planned members don't have ids yet; placeholders keep the dry-run's
317
- // owner resolution (and its planned-mutation list) representative. Apply
318
- // runs always resolve real member ids.
319
- for (const row of members.report) {
320
- if (row.action === "planned" && row.email && !ownerMap.has(row.repId)) {
321
- ownerMap.set(row.repId, `planned-member:${row.email}`);
322
- }
323
- }
324
- for (const row of members.report) {
325
- if (
326
- row.action === "merged-duplicate-email" &&
327
- row.email &&
328
- !ownerMap.has(row.repId)
329
- ) {
330
- const primary = ownerMap.get(
331
- members.report.find(
332
- (r) => r.email === row.email && r.repId !== row.repId,
333
- )?.repId ?? "",
334
- );
335
- if (primary) ownerMap.set(row.repId, primary);
336
- }
337
- }
338
- }
338
+ const ownerMap = members.ownerMap;
339
339
 
340
340
  // Owner refs in LastMile mix rep ids, aliases, and display names; the index
341
341
  // adds unique alias/name keys on top of the provisioned rep→member map.
@@ -363,9 +363,72 @@ async function runMigration(options: {
363
363
  }
364
364
  report.companies = "pending";
365
365
 
366
+ // Phase D': dispatch customers as companies (opt-in) ---------------------
367
+ // TEI wants the CRM to carry real, active customers — not just the ~800
368
+ // accounts with opportunity history. Gated by env
369
+ // TWENTY_DISPATCH_CUSTOMER_COHORT_DAYS (e.g. "5"): dispatch customers with
370
+ // an order inside that window become companies keyed customer:<id>.
371
+ // Customers whose name already has an account-sourced company are skipped
372
+ // (the unique-name crosswalk) so no duplicate companies appear.
373
+ const dispatchCustomerCounters = emptyCounters();
374
+ const cohortDaysRaw = process.env.TWENTY_DISPATCH_CUSTOMER_COHORT_DAYS;
375
+ if (cohortDaysRaw) {
376
+ const cohortDays = Number(cohortDaysRaw);
377
+ // TWENTY_DISPATCH_CUSTOMER_OPEN_INVOICES=1 widens the cohort to customers
378
+ // holding an open invoice (any age) — the twin carries ALL open invoices,
379
+ // so their customers must have CRM records even without a recent order.
380
+ const includeOpenInvoices = ["1", "true"].includes(
381
+ (process.env.TWENTY_DISPATCH_CUSTOMER_OPEN_INVOICES ?? "").toLowerCase(),
382
+ );
383
+ log(
384
+ `records: loading dispatch customer cohort (${cohortDays}d` +
385
+ `${includeOpenInvoices ? " + open invoices" : ""})...`,
386
+ );
387
+ const cohort = await reader.readOrderCohortCustomers(
388
+ cohortDays,
389
+ includeOpenInvoices,
390
+ );
391
+ const fresh = cohort.filter((customer) => !customer.hasAccountNameMatch);
392
+ log(
393
+ `records: dispatch cohort ${cohort.length} customers, ` +
394
+ `${cohort.length - fresh.length} already covered by account companies`,
395
+ );
396
+ const mappedCustomers = fresh.map((customer) =>
397
+ mapDispatchCustomer(customer, ownerIndex),
398
+ );
399
+ const customerIdBySourceId = await upsertRecords({
400
+ client,
401
+ entity: COMPANY,
402
+ mapped: mappedCustomers,
403
+ dryRun,
404
+ counters: dispatchCustomerCounters,
405
+ });
406
+ for (const [key, value] of customerIdBySourceId) {
407
+ companyIdBySourceId.set(key, value);
408
+ }
409
+ report.dispatchCustomers = {
410
+ cohort: cohort.length,
411
+ skippedAccountCovered: cohort.length - fresh.length,
412
+ ...summarizeCounters(dispatchCustomerCounters),
413
+ };
414
+ }
415
+
366
416
  log("records: loading people...");
367
- const contacts = await reader.readContacts();
417
+ const rawContacts = await reader.readContacts();
418
+ // Twenty requires a unique person email; LastMile does not. Give the address
419
+ // to the first contact by id and migrate the rest without one, rather than
420
+ // losing them to a duplicate-key error (29 people failed this way on the
421
+ // first seed).
422
+ const contacts = dedupeContactEmails(rawContacts);
368
423
  const personCounters = emptyCounters();
424
+ const keptById = new Map(contacts.map((contact) => [contact.id, contact]));
425
+ for (const contact of rawContacts) {
426
+ if (contact.email && !keptById.get(contact.id)?.email) {
427
+ personCounters.warnings.push(
428
+ `duplicate email ${contact.email} dropped from contact ${contact.id}`,
429
+ );
430
+ }
431
+ }
369
432
  await upsertRecords({
370
433
  client,
371
434
  entity: PERSON,
@@ -375,18 +438,40 @@ async function runMigration(options: {
375
438
  });
376
439
  report.people = "pending";
377
440
 
378
- log("records: loading opportunities (leads + opportunities)...");
379
- const [leads, opportunities] = await Promise.all([
380
- reader.readLeads(),
381
- reader.readOpportunities(),
382
- ]);
441
+ // Organizations (LastMile branches, e.g. "GWO 300") opportunities link to
442
+ // them, so they load before the CRM tasks.
443
+ log("records: loading organizations...");
444
+ const organizations = await reader.readOrganizations();
445
+ const organizationCounters = emptyCounters();
446
+ const mappedOrganizations = organizations.map(mapOrganization);
447
+ const organizationIdBySourceId = await upsertRecords({
448
+ client,
449
+ entity: ORGANIZATION,
450
+ mapped: mappedOrganizations,
451
+ dryRun,
452
+ counters: organizationCounters,
453
+ });
454
+ if (dryRun) {
455
+ for (const record of mappedOrganizations) {
456
+ if (!organizationIdBySourceId.has(record.sourceId)) {
457
+ organizationIdBySourceId.set(
458
+ record.sourceId,
459
+ `planned:${record.sourceId}`,
460
+ );
461
+ }
462
+ }
463
+ }
464
+ report.organizations = summarizeCounters(organizationCounters);
465
+
466
+ // The `task` table is the CRM: status, owner, organization, and products all
467
+ // come from it. The `opportunity`/`lead` tables' own stage columns are stale
468
+ // (they disagree with the task status on 889 of 950 opportunities).
469
+ log("records: loading CRM tasks (leads + opportunities)...");
470
+ const crmTasks = await reader.readCrmTasks();
383
471
  const opportunityCounters = emptyCounters();
384
- const mappedOpportunities = [
385
- ...leads.map((lead) => mapLead(lead, ownerIndex)),
386
- ...opportunities.map((opportunity) =>
387
- mapOpportunity(opportunity, ownerIndex, companyIdBySourceId),
388
- ),
389
- ];
472
+ const mappedOpportunities = crmTasks.map((task) =>
473
+ mapCrmTask(task, ownerIndex, companyIdBySourceId, organizationIdBySourceId),
474
+ );
390
475
  const opportunityIdBySourceId = await upsertRecords({
391
476
  client,
392
477
  entity: OPPORTUNITY,
@@ -406,13 +491,34 @@ async function runMigration(options: {
406
491
  }
407
492
  report.opportunities = "pending";
408
493
 
494
+ // The product catalog (7 rows) must exist before the lines that point at it.
495
+ log("records: ensuring the product catalog...");
496
+ const catalogCounters = emptyCounters();
497
+ const productIdBySourceId = await upsertRecords({
498
+ client,
499
+ entity: PRODUCT,
500
+ mapped: PRODUCT_CATALOG.map(mapProduct),
501
+ dryRun,
502
+ counters: catalogCounters,
503
+ });
504
+ if (dryRun) {
505
+ for (const name of PRODUCT_CATALOG) {
506
+ const id = productSourceId(name);
507
+ if (!productIdBySourceId.has(id)) {
508
+ productIdBySourceId.set(id, `planned:${id}`);
509
+ }
510
+ }
511
+ }
512
+ report.productCatalog = summarizeCounters(catalogCounters);
513
+
409
514
  log("records: loading opportunity product lines...");
410
- const items = await reader.readOpportunityItems();
411
515
  const productCounters = emptyCounters();
516
+ const mappedProducts = crmTasks.flatMap(mapTaskProducts);
412
517
  await upsertOpportunityProducts({
413
518
  client,
414
- products: items.map(mapOpportunityProduct),
519
+ products: mappedProducts,
415
520
  opportunityIdBySourceId,
521
+ productIdBySourceId,
416
522
  dryRun,
417
523
  counters: productCounters,
418
524
  });
@@ -420,9 +526,10 @@ async function runMigration(options: {
420
526
 
421
527
  // Phase E: notes + attachments -------------------------------------------
422
528
  log("annexes: loading notes...");
423
- const [comments, customerNotes] = await Promise.all([
529
+ const [comments, customerNotes, statusChanges] = await Promise.all([
424
530
  reader.readCrmComments(),
425
531
  reader.readCustomerNotes(),
532
+ reader.readTaskStatusChanges(),
426
533
  ]);
427
534
  const noteCounters = emptyCounters();
428
535
  const noteTargets = new Map<string, string>([
@@ -431,6 +538,10 @@ async function runMigration(options: {
431
538
  ]);
432
539
  const mappedNotes = [
433
540
  ...comments.map(mapCrmComment),
541
+ ...statusChanges.flatMap((change) => {
542
+ const mapped = mapTaskStatusActivity(change);
543
+ return mapped ? [mapped] : [];
544
+ }),
434
545
  ...customerNotes.flatMap((note) => {
435
546
  const mapped = mapCustomerNote(note);
436
547
  if (!mapped) {
@@ -492,12 +603,9 @@ async function runMigration(options: {
492
603
  await mirrorDeletions({
493
604
  client,
494
605
  entity: OPPORTUNITY,
495
- liveSourceIds: new Set([
496
- ...leads.map((lead) => sourceId("lead", lead.id)),
497
- ...opportunities.map((opportunity) =>
498
- sourceId("opportunity", opportunity.id),
499
- ),
500
- ]),
606
+ liveSourceIds: new Set(
607
+ crmTasks.map((task) => sourceId(task.entityType, task.entityId)),
608
+ ),
501
609
  ownedPrefixes: ["lead:", "opportunity:"],
502
610
  dryRun,
503
611
  counters: opportunityCounters,
@@ -510,12 +618,7 @@ async function runMigration(options: {
510
618
  await mirrorDeletions({
511
619
  client,
512
620
  entity: OPPORTUNITY_PRODUCT,
513
- liveSourceIds: new Set(
514
- items.map(
515
- (item) =>
516
- `${sourceId("opportunity_item", item.opportunityId)}#${item.index}`,
517
- ),
518
- ),
621
+ liveSourceIds: new Set(mappedProducts.map((product) => product.sourceId)),
519
622
  ownedPrefixes: ["opportunity_item:"],
520
623
  dryRun,
521
624
  counters: productCounters,
@@ -537,16 +640,15 @@ async function runMigration(options: {
537
640
  opportunities: opportunityCounters,
538
641
  });
539
642
 
540
- const anyFailures =
541
- members.hadFailures ||
542
- [
543
- companyCounters,
544
- personCounters,
545
- opportunityCounters,
546
- productCounters,
547
- noteCounters,
548
- attachmentCounters,
549
- ].some((counters) => counters.failed > 0);
643
+ const anyFailures = [
644
+ organizationCounters,
645
+ companyCounters,
646
+ personCounters,
647
+ opportunityCounters,
648
+ productCounters,
649
+ noteCounters,
650
+ attachmentCounters,
651
+ ].some((counters) => counters.failed > 0);
550
652
  report.ok = !anyFailures;
551
653
  if (anyFailures) process.exitCode = 2;
552
654
  }
@@ -18,10 +18,19 @@
18
18
  * npx tsx scripts/provision-twenty-members.ts --apply # create them
19
19
  * npx tsx scripts/provision-twenty-members.ts --apply --only me@x.com # one rep, for proof
20
20
  *
21
+ * ROTATION (plan R5) — required at cutover, on abort, and on an over-long
22
+ * validation window. The shared TWENTY_REP_PASSWORD must not outlive the phase
23
+ * that needed it:
24
+ * npx tsx scripts/provision-twenty-members.ts --rotate # dry-run: who rotates
25
+ * npx tsx scripts/provision-twenty-members.ts --rotate --apply # unique random password each
26
+ * Each rep gets an independent random password, printed once to stdout. Hand
27
+ * them out, or have reps use Twenty's password reset.
28
+ *
21
29
  * After provisioning, re-run migrate-lastmile.ts --apply so every company and
22
30
  * opportunity picks up its owner.
23
31
  */
24
32
 
33
+ import { randomBytes } from "node:crypto";
25
34
  import process from "node:process";
26
35
 
27
36
  import pg from "pg";
@@ -30,6 +39,7 @@ import { createLastmileReader } from "./lib/lastmile-reader";
30
39
  import { deriveRepEmail } from "./lib/mappers";
31
40
  import {
32
41
  provisionMembers,
42
+ rotateMemberPasswords,
33
43
  type ProvisionRep,
34
44
  } from "./lib/provision-members-db";
35
45
 
@@ -114,10 +124,12 @@ async function main(): Promise<void> {
114
124
  const argv = process.argv.slice(2);
115
125
  let apply = false;
116
126
  let only: string | null = null;
127
+ let rotate = false;
117
128
  for (let index = 0; index < argv.length; index += 1) {
118
129
  const arg = argv[index];
119
130
  if (arg === "--apply") apply = true;
120
131
  else if (arg === "--dry-run") apply = false;
132
+ else if (arg === "--rotate") rotate = true;
121
133
  else if (arg === "--only") {
122
134
  only = argv[++index] ?? null;
123
135
  if (!only) throw new Error("--only requires an email");
@@ -127,7 +139,8 @@ async function main(): Promise<void> {
127
139
 
128
140
  const twentyDbUrl = requireEnv("TWENTY_DATABASE_URL");
129
141
  const lastmileUrl = requireEnv("LASTMILE_DATABASE_URL");
130
- const password = requireEnv("TWENTY_REP_PASSWORD");
142
+ // Rotation mints its own passwords; provisioning uses the shared one.
143
+ const password = rotate ? "" : requireEnv("TWENTY_REP_PASSWORD");
131
144
  const emailDomain =
132
145
  process.env.TWENTY_REP_EMAIL_DOMAIN ?? "texasenterprises.com";
133
146
 
@@ -177,6 +190,36 @@ async function main(): Promise<void> {
177
190
  if (reps.length === 0) throw new Error(`--only ${only} matched no rep`);
178
191
  }
179
192
 
193
+ if (rotate) {
194
+ const minted = new Map<string, string>();
195
+ const result = await rotateMemberPasswords({
196
+ databaseUrl: twentyDbUrl,
197
+ emails: reps.map((rep) => rep.email),
198
+ passwordFor: (email: string) => {
199
+ // 18 bytes of base64url ~= 24 chars, well inside bcrypt's 72-byte limit.
200
+ const secret = randomBytes(18).toString("base64url");
201
+ minted.set(email, secret);
202
+ return secret;
203
+ },
204
+ dryRun,
205
+ log,
206
+ });
207
+ process.stdout.write(
208
+ `${JSON.stringify(
209
+ {
210
+ mode: dryRun ? "rotate-dry-run" : "rotate",
211
+ rotated: result.rotated,
212
+ notFound: result.notFound,
213
+ // Printed once, never persisted. Hand these out, then discard.
214
+ passwords: dryRun ? undefined : Object.fromEntries(minted),
215
+ },
216
+ null,
217
+ 2,
218
+ )}\n`,
219
+ );
220
+ return;
221
+ }
222
+
180
223
  const result = await provisionMembers({
181
224
  databaseUrl: twentyDbUrl,
182
225
  workspaceId: context.workspaceId,