thinkwork-cli 0.12.15 → 0.12.17

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 (152) hide show
  1. package/dist/{api-client-4VT3FWOH.js → api-client-JUBCQQDF.js} +1 -1
  2. package/dist/{chunk-H7AB42ES.js → chunk-STY44FQO.js} +13 -0
  3. package/dist/cli.js +1107 -10225
  4. package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +3 -8
  5. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +1 -184
  6. package/dist/drizzle/0050_brain_v0_entity_subtype.sql +2 -2
  7. package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +6 -6
  8. package/dist/drizzle/0052_brain_v0_external_refs.sql +2 -2
  9. package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +2 -2
  10. package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +14 -14
  11. package/dist/drizzle/0146_knowledge_graph_source_scope.sql +1 -1
  12. package/dist/drizzle/0157_knowledge_graph_observations_source.sql +2 -2
  13. package/dist/drizzle/0158_wiki_tenant_scope.sql +1 -1
  14. package/dist/drizzle/0165_plugin_premium_entitlements.sql +2 -2
  15. package/dist/drizzle/{0166_company_brain_substrate_contract.sql → 0166_brain_substrate_contract.sql} +9 -9
  16. package/dist/drizzle/{0167_company_brain_artifact_manifest_runtime.sql → 0167_brain_artifact_manifest_runtime.sql} +5 -5
  17. package/dist/drizzle/0168_eval_skill_gate.sql +1 -1
  18. package/dist/drizzle/0183_okf_artifact_manifests.sql +1 -1
  19. package/dist/drizzle/0197_rcm_context_columns.sql +28 -0
  20. package/dist/drizzle/0200_eval_results_execution_tier.sql +40 -0
  21. package/dist/drizzle/0201_brain_dream_runs.sql +72 -0
  22. package/dist/drizzle/0202_backfill_thread_participants_from_senders.sql +110 -0
  23. package/dist/drizzle/0203_threads_mode_override.sql +61 -0
  24. package/dist/drizzle/0204_thread_turns_triggering_message_id.sql +48 -0
  25. package/dist/drizzle/0205_routines_git_python.sql +185 -0
  26. package/dist/drizzle/0206_agent_loop_versions_routine_actions.sql +40 -0
  27. package/dist/drizzle/0207_tenant_credentials_github_repo_kind.sql +52 -0
  28. package/dist/drizzle/0208_knowledge_graph_neutral_identifiers.sql +253 -0
  29. package/dist/drizzle/0209_living_artifacts_schema.sql +109 -0
  30. package/dist/drizzle/0210_think137_automations_additive_schema.sql +113 -0
  31. package/dist/drizzle/0211_backfill_agent_loop_versions_target_spec.sql +120 -0
  32. package/dist/drizzle/0212_fold_webhooks_into_automations.sql +312 -0
  33. package/dist/drizzle/0213_agent_loop_versions_judge_evidence_nullable.sql +63 -0
  34. package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +99 -0
  35. package/dist/drizzle/0215_agent_loop_versions_goal_worker_policy_nullable.sql +70 -0
  36. package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +81 -0
  37. package/dist/drizzle/0217_agents_capability_folder_dispatch.sql +12 -0
  38. package/dist/drizzle/0218_document_plates.sql +30 -0
  39. package/dist/drizzle/0219_document_section_waivers.sql +29 -0
  40. package/dist/drizzle/0220_artifacts_created_by_user.sql +21 -0
  41. package/dist/drizzle/0221_artifact_shares.sql +29 -0
  42. package/dist/drizzle/0221_workflow_interpreter_thin_slice.sql +71 -0
  43. package/dist/drizzle/0222_compliance_drainer_select_audit_events.sql +21 -0
  44. package/dist/drizzle/0223_document_conformance_reports.sql +38 -0
  45. package/dist/drizzle/0224_artifact_refresh_state.sql +11 -0
  46. package/dist/drizzle/0225_think216_agent_loop_convergence.sql +21 -0
  47. package/dist/drizzle/0226_drop_n8n_agent_step_runs.sql +6 -0
  48. package/dist/drizzle/0227_analyst_reader_role.sql +1055 -0
  49. package/dist/drizzle/0228_tenant_credentials_rds_iam_kind.sql +62 -0
  50. package/dist/drizzle/0229_analyst_reader_rds_iam_grant.sql +79 -0
  51. package/dist/drizzle/0230_analyst_rls.sql +1613 -0
  52. package/dist/drizzle/0231_cost_events_cache_write_enforcement_exempt.sql +11 -0
  53. package/dist/plugins/catalog/package.json +0 -1
  54. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
  55. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +16 -8
  56. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +56 -0
  57. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
  58. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
  59. package/dist/plugins/catalog/src/contracts.ts +112 -0
  60. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
  61. package/dist/plugins/n8n/README.md +4 -36
  62. package/dist/plugins/n8n/src/deployment/managed-app.ts +0 -17
  63. package/dist/plugins/n8n/src/manifest.ts +0 -5
  64. package/dist/plugins/n8n/terraform/n8n/main.tf +0 -26
  65. package/dist/plugins/n8n/terraform/n8n/outputs.tf +0 -5
  66. package/dist/plugins/n8n/terraform/n8n/variables.tf +0 -6
  67. package/dist/plugins/n8n/test/manifest.test.ts +0 -1
  68. package/dist/plugins/twenty/package.json +5 -0
  69. package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +232 -0
  70. package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +489 -0
  71. package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +199 -0
  72. package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +43 -0
  73. package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +230 -0
  74. package/dist/plugins/twenty/scripts/lib/__tests__/twenty-client.test.ts +207 -0
  75. package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +282 -0
  76. package/dist/plugins/twenty/scripts/lib/load-attachments.ts +220 -0
  77. package/dist/plugins/twenty/scripts/lib/load-records.ts +680 -0
  78. package/dist/plugins/twenty/scripts/lib/mappers.ts +559 -0
  79. package/dist/plugins/twenty/scripts/lib/members-ensure.ts +381 -0
  80. package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +191 -0
  81. package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +425 -0
  82. package/dist/plugins/twenty/scripts/lib/twenty-client.ts +246 -0
  83. package/dist/plugins/twenty/scripts/migrate-lastmile.ts +610 -0
  84. package/dist/plugins/twenty/scripts/provision-twenty-members.ts +214 -0
  85. package/dist/plugins/twenty/src/manifest.ts +99 -0
  86. package/dist/plugins/twenty/terraform/twenty/README.md +1 -1
  87. package/dist/plugins/twenty/test/manifest.test.ts +12 -2
  88. package/dist/terraform/examples/greenfield/main.tf +90 -380
  89. package/dist/terraform/examples/greenfield/terraform.tfvars.example +0 -1
  90. package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
  91. package/dist/terraform/modules/app/agentcore-pi/main.tf +36 -32
  92. package/dist/terraform/modules/app/agentcore-pi/variables.tf +23 -15
  93. package/dist/terraform/modules/app/agentcore-runtime/main.tf +3 -3
  94. package/dist/terraform/modules/app/customer-domain/main.tf +16 -2
  95. package/dist/terraform/modules/app/deployment-control-plane/main.tf +5 -0
  96. package/dist/terraform/modules/app/deployment-control-plane/runner.py +85 -207
  97. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +56 -2
  98. package/dist/terraform/modules/app/hindsight-memory/main.tf +52 -4
  99. package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -0
  100. package/dist/terraform/modules/app/lambda-api/handlers.tf +493 -89
  101. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +215 -96
  102. package/dist/terraform/modules/app/lambda-api/main.tf +24 -5
  103. package/dist/terraform/modules/app/lambda-api/outputs.tf +19 -11
  104. package/dist/terraform/modules/app/lambda-api/variables.tf +94 -67
  105. package/dist/terraform/modules/app/ses-email/main.tf +14 -2
  106. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +493 -0
  107. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/outputs.tf +34 -0
  108. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/variables.tf +32 -0
  109. package/dist/terraform/modules/data/aurora-postgres/main.tf +55 -0
  110. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +15 -0
  111. package/dist/terraform/modules/data/s3-buckets/main.tf +26 -0
  112. package/dist/terraform/modules/thinkwork/README.md +0 -1
  113. package/dist/terraform/modules/thinkwork/main.tf +167 -446
  114. package/dist/terraform/modules/thinkwork/outputs.tf +2 -142
  115. package/dist/terraform/modules/thinkwork/variables.tf +55 -309
  116. package/dist/terraform/schema.graphql +4 -0
  117. package/dist/workspace-defaults/files/AGENTS.md +45 -31
  118. package/dist/workspace-defaults/files/CONTEXT.md +48 -4
  119. package/dist/workspace-defaults/files/GUARDRAILS.md +18 -0
  120. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +32 -4
  121. package/dist/workspace-defaults/files/SPACE.md +34 -19
  122. package/dist/workspace-defaults/files/TOOLS.md +30 -0
  123. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +154 -0
  124. package/dist/workspace-defaults/files/skills/document-composer/references/authoring-rules.md +101 -0
  125. package/package.json +2 -1
  126. package/dist/plugins/company-brain/README.md +0 -37
  127. package/dist/plugins/company-brain/package.json +0 -33
  128. package/dist/plugins/company-brain/runtime/cognee/Dockerfile +0 -11
  129. package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +0 -458
  130. package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +0 -539
  131. package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +0 -312
  132. package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +0 -449
  133. package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +0 -655
  134. package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +0 -474
  135. package/dist/plugins/company-brain/src/api/cognee-client.ts +0 -896
  136. package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +0 -49
  137. package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +0 -84
  138. package/dist/plugins/company-brain/src/api/context-engine-provider.ts +0 -888
  139. package/dist/plugins/company-brain/src/api/migration.ts +0 -665
  140. package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +0 -396
  141. package/dist/plugins/company-brain/src/index.ts +0 -62
  142. package/dist/plugins/company-brain/src/manifest.ts +0 -112
  143. package/dist/plugins/company-brain/terraform/cognee/README.md +0 -263
  144. package/dist/plugins/company-brain/terraform/cognee/main.tf +0 -766
  145. package/dist/plugins/company-brain/terraform/cognee/outputs.tf +0 -144
  146. package/dist/plugins/company-brain/terraform/cognee/variables.tf +0 -402
  147. package/dist/plugins/company-brain/test/api/cognee-client.test.ts +0 -562
  148. package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +0 -74
  149. package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +0 -447
  150. package/dist/plugins/company-brain/test/api/migration.test.ts +0 -465
  151. package/dist/plugins/company-brain/test/manifest.test.ts +0 -95
  152. package/dist/plugins/company-brain/tsconfig.json +0 -8
@@ -0,0 +1,610 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * LastMile CRM → TEI's Twenty CRM migration (one-off, idempotent, re-runnable).
4
+ * Plan: docs/plans/2026-07-09-002-feat-tei-lastmile-twenty-migration-plan.md
5
+ *
6
+ * OPERATOR RUNBOOK
7
+ * ================
8
+ * Environment (all required unless noted):
9
+ * 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)
11
+ * 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
19
+ * (default texasenterprises.com; <first-initial><lastname>@)
20
+ * AWS_PROFILE / AWS_REGION Needed for attachment binaries (LastMile S3 bucket)
21
+ *
22
+ * 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)
29
+ *
30
+ * 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.
36
+ *
37
+ * The parity report prints to stdout as JSON; all other logging goes to stderr.
38
+ * Secrets are never logged. One invocation at a time (KTD3).
39
+ */
40
+
41
+ import process from "node:process";
42
+
43
+ import {
44
+ createLastmileReader,
45
+ type LastmileReader,
46
+ } from "./lib/lastmile-reader";
47
+ import { loadCrmAttachments } from "./lib/load-attachments";
48
+ import {
49
+ checkNoteTargetPairing,
50
+ COMPANY,
51
+ emptyCounters,
52
+ mirrorDeletions,
53
+ NOTE,
54
+ OPPORTUNITY,
55
+ OPPORTUNITY_PRODUCT,
56
+ PERSON,
57
+ rollbackEntity,
58
+ upsertNotes,
59
+ upsertOpportunityProducts,
60
+ upsertRecords,
61
+ type EntityCounters,
62
+ } from "./lib/load-records";
63
+ import {
64
+ buildOwnerIndex,
65
+ deriveRepEmail,
66
+ mapAccount,
67
+ mapContact,
68
+ mapCrmComment,
69
+ mapCustomerNote,
70
+ mapLead,
71
+ mapOpportunity,
72
+ mapOpportunityProduct,
73
+ sourceId,
74
+ } from "./lib/mappers";
75
+ import {
76
+ adminSignIn,
77
+ ensureMembers,
78
+ type RepToProvision,
79
+ } from "./lib/members-ensure";
80
+ import {
81
+ applySchemaEnsure,
82
+ ensureOpportunityProductObject,
83
+ fetchObjectMetadata,
84
+ planSchemaEnsure,
85
+ } from "./lib/schema-ensure";
86
+ import { TwentyClient, normalizeBaseUrl } from "./lib/twenty-client";
87
+
88
+ interface CliArgs {
89
+ apply: boolean;
90
+ 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
+ }
96
+
97
+ function parseArgs(argv: string[]): CliArgs {
98
+ const args: CliArgs = { apply: false, rollback: false, skipInvites: false };
99
+ for (const arg of argv) {
100
+ if (arg === "--apply") args.apply = true;
101
+ else if (arg === "--dry-run") args.apply = false;
102
+ else if (arg === "--rollback") args.rollback = true;
103
+ else if (arg === "--skip-invites") args.skipInvites = true;
104
+ else throw new Error(`Unknown argument: ${arg}`);
105
+ }
106
+ return args;
107
+ }
108
+
109
+ function requireEnv(name: string): string {
110
+ const value = process.env[name];
111
+ if (!value) {
112
+ throw new Error(`Missing required environment variable: ${name}`);
113
+ }
114
+ return value;
115
+ }
116
+
117
+ function log(message: string): void {
118
+ process.stderr.write(`${message}\n`);
119
+ }
120
+
121
+ async function main(): Promise<void> {
122
+ const args = parseArgs(process.argv.slice(2));
123
+ const dryRun = !args.apply;
124
+
125
+ const baseUrl = normalizeBaseUrl(requireEnv("TWENTY_PUBLIC_URL"));
126
+ const apiKey = requireEnv("TWENTY_API_KEY");
127
+ const lastmileUrl = requireEnv("LASTMILE_DATABASE_URL");
128
+ const repPassword = process.env.TWENTY_REP_PASSWORD ?? "";
129
+
130
+ const client = new TwentyClient({ baseUrl, authToken: apiKey });
131
+ const reader = createLastmileReader(lastmileUrl);
132
+
133
+ const report: Record<string, unknown> = {
134
+ mode: args.rollback
135
+ ? dryRun
136
+ ? "rollback-dry-run"
137
+ : "rollback"
138
+ : dryRun
139
+ ? "dry-run"
140
+ : "apply",
141
+ startedAt: new Date().toISOString(),
142
+ };
143
+
144
+ try {
145
+ if (args.rollback) {
146
+ await runRollback(client, report, dryRun);
147
+ return;
148
+ }
149
+ await runMigration({
150
+ client,
151
+ reader,
152
+ baseUrl,
153
+ repPassword,
154
+ report,
155
+ dryRun,
156
+ skipInvites: args.skipInvites,
157
+ });
158
+ } finally {
159
+ await reader.close();
160
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
161
+ }
162
+ }
163
+
164
+ async function runRollback(
165
+ client: TwentyClient,
166
+ report: Record<string, unknown>,
167
+ dryRun: boolean,
168
+ ): Promise<void> {
169
+ const entities = [
170
+ { entity: NOTE, prefixes: ["task_comment:", "note:"] },
171
+ { entity: OPPORTUNITY_PRODUCT, prefixes: ["opportunity_item:"] },
172
+ { entity: OPPORTUNITY, prefixes: ["lead:", "opportunity:"] },
173
+ { entity: PERSON, prefixes: ["contact:"] },
174
+ { entity: COMPANY, prefixes: ["account:"] },
175
+ ];
176
+ for (const { entity, prefixes } of entities) {
177
+ const counters = emptyCounters();
178
+ log(
179
+ `rollback: ${entity.plural} (${dryRun ? "listing" : "soft-deleting"})...`,
180
+ );
181
+ await rollbackEntity({
182
+ client,
183
+ entity,
184
+ ownedPrefixes: prefixes,
185
+ dryRun,
186
+ counters,
187
+ });
188
+ report[`rollback_${entity.plural}`] = counters;
189
+ }
190
+ // Custom-field metadata is never rolled back (fields are inert; deleting
191
+ // them destroys data). Workspace members are never un-created — rotate
192
+ // passwords or deactivate instead. (Plan: Risks & Rollback.)
193
+ }
194
+
195
+ async function runMigration(options: {
196
+ client: TwentyClient;
197
+ reader: LastmileReader;
198
+ baseUrl: string;
199
+ repPassword: string;
200
+ report: Record<string, unknown>;
201
+ dryRun: boolean;
202
+ skipInvites: boolean;
203
+ }): Promise<void> {
204
+ const { client, reader, baseUrl, repPassword, report, dryRun, skipInvites } =
205
+ options;
206
+
207
+ // Phase A: preflight ------------------------------------------------------
208
+ log("preflight: connecting to LastMile and Twenty...");
209
+ const [reps, accounts] = await Promise.all([
210
+ reader.readReps(),
211
+ reader.readAccounts(),
212
+ ]);
213
+ await fetchObjectMetadata(client); // proves Twenty metadata access early
214
+ log(`preflight ok: ${reps.length} reps, ${accounts.length} accounts visible`);
215
+
216
+ // Phase B: schema ensure --------------------------------------------------
217
+ log("schema: ensuring custom fields and stage options...");
218
+ let objects = await fetchObjectMetadata(client);
219
+ const objectEnsure = await ensureOpportunityProductObject(
220
+ client,
221
+ objects,
222
+ dryRun,
223
+ );
224
+ if (!dryRun && (objectEnsure.created || objectEnsure.relationCreated)) {
225
+ // Re-read metadata so the field plan addresses the real object id.
226
+ objects = await fetchObjectMetadata(client);
227
+ }
228
+ report.opportunityProductObject = objectEnsure;
229
+ const schemaPlan = planSchemaEnsure(objects);
230
+ if (dryRun) {
231
+ report.schema = {
232
+ plannedFields: schemaPlan.createFields.map(
233
+ (f) => `${f.object}.${f.name}`,
234
+ ),
235
+ plannedStageOptions: schemaPlan.stageOptionsToAdd,
236
+ };
237
+ } else {
238
+ report.schema = await applySchemaEnsure(client, schemaPlan);
239
+ }
240
+
241
+ // Phase C: members --------------------------------------------------------
242
+ log("members: ensuring workspace members...");
243
+ const repEmailDomain =
244
+ process.env.TWENTY_REP_EMAIL_DOMAIN ?? "texasenterprises.com";
245
+ const derivedEmails: string[] = [];
246
+ const repsToProvision: RepToProvision[] = reps
247
+ .filter((rep) => !rep.archived)
248
+ .map((rep) => {
249
+ let email = rep.email;
250
+ if (!email) {
251
+ // Reps with no LastMile email get <first-initial><lastname>@<domain>;
252
+ // house/intercompany/placeholder rows derive to null and stay
253
+ // unprovisionable.
254
+ email = deriveRepEmail(rep.firstName, rep.lastName, repEmailDomain);
255
+ if (email) derivedEmails.push(`${rep.id} -> ${email}`);
256
+ }
257
+ return {
258
+ repId: rep.id,
259
+ email,
260
+ firstName: rep.firstName,
261
+ lastName: rep.lastName,
262
+ archived: rep.archived,
263
+ };
264
+ });
265
+ report.derivedRepEmails = {
266
+ domain: repEmailDomain,
267
+ count: derivedEmails.length,
268
+ sample: derivedEmails.slice(0, 20),
269
+ };
270
+
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
+ const members = await ensureMembers({
294
+ dataClient: client,
295
+ adminClient,
296
+ reps: repsToProvision,
297
+ repPassword,
298
+ workspaceId,
299
+ // --skip-invites: match existing members only, invite nobody this run.
300
+ dryRun: dryRun || skipInvites,
301
+ });
302
+ if (skipInvites) {
303
+ report.membersMode =
304
+ "skip-invites (existing members matched; no invitations sent)";
305
+ }
306
+ report.members = {
307
+ report: members.report,
308
+ provisionable: members.ownerMap.size,
309
+ hadFailures: members.hadFailures,
310
+ };
311
+ // Archived reps still resolve for historical ownership when a member with a
312
+ // matching email already exists; unprovisioned owners map to null and are
313
+ // 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
+ }
339
+
340
+ // Owner refs in LastMile mix rep ids, aliases, and display names; the index
341
+ // adds unique alias/name keys on top of the provisioned rep→member map.
342
+ const ownerIndex = buildOwnerIndex(reps, ownerMap);
343
+
344
+ // Phase D: records — companies → people → opportunities -------------------
345
+ log("records: loading companies...");
346
+ const companyCounters = emptyCounters();
347
+ const mappedCompanies = accounts.map((account) =>
348
+ mapAccount(account, ownerIndex),
349
+ );
350
+ const companyIdBySourceId = await upsertRecords({
351
+ client,
352
+ entity: COMPANY,
353
+ mapped: mappedCompanies,
354
+ dryRun,
355
+ counters: companyCounters,
356
+ });
357
+ if (dryRun) {
358
+ for (const record of mappedCompanies) {
359
+ if (!companyIdBySourceId.has(record.sourceId)) {
360
+ companyIdBySourceId.set(record.sourceId, `planned:${record.sourceId}`);
361
+ }
362
+ }
363
+ }
364
+ report.companies = "pending";
365
+
366
+ log("records: loading people...");
367
+ const contacts = await reader.readContacts();
368
+ const personCounters = emptyCounters();
369
+ await upsertRecords({
370
+ client,
371
+ entity: PERSON,
372
+ mapped: contacts.map((contact) => mapContact(contact, companyIdBySourceId)),
373
+ dryRun,
374
+ counters: personCounters,
375
+ });
376
+ report.people = "pending";
377
+
378
+ log("records: loading opportunities (leads + opportunities)...");
379
+ const [leads, opportunities] = await Promise.all([
380
+ reader.readLeads(),
381
+ reader.readOpportunities(),
382
+ ]);
383
+ const opportunityCounters = emptyCounters();
384
+ const mappedOpportunities = [
385
+ ...leads.map((lead) => mapLead(lead, ownerIndex)),
386
+ ...opportunities.map((opportunity) =>
387
+ mapOpportunity(opportunity, ownerIndex, companyIdBySourceId),
388
+ ),
389
+ ];
390
+ const opportunityIdBySourceId = await upsertRecords({
391
+ client,
392
+ entity: OPPORTUNITY,
393
+ mapped: mappedOpportunities,
394
+ dryRun,
395
+ counters: opportunityCounters,
396
+ });
397
+ if (dryRun) {
398
+ for (const record of mappedOpportunities) {
399
+ if (!opportunityIdBySourceId.has(record.sourceId)) {
400
+ opportunityIdBySourceId.set(
401
+ record.sourceId,
402
+ `planned:${record.sourceId}`,
403
+ );
404
+ }
405
+ }
406
+ }
407
+ report.opportunities = "pending";
408
+
409
+ log("records: loading opportunity product lines...");
410
+ const items = await reader.readOpportunityItems();
411
+ const productCounters = emptyCounters();
412
+ await upsertOpportunityProducts({
413
+ client,
414
+ products: items.map(mapOpportunityProduct),
415
+ opportunityIdBySourceId,
416
+ dryRun,
417
+ counters: productCounters,
418
+ });
419
+ report.opportunityProducts = summarizeCounters(productCounters);
420
+
421
+ // Phase E: notes + attachments -------------------------------------------
422
+ log("annexes: loading notes...");
423
+ const [comments, customerNotes] = await Promise.all([
424
+ reader.readCrmComments(),
425
+ reader.readCustomerNotes(),
426
+ ]);
427
+ const noteCounters = emptyCounters();
428
+ const noteTargets = new Map<string, string>([
429
+ ...opportunityIdBySourceId,
430
+ ...companyIdBySourceId,
431
+ ]);
432
+ const mappedNotes = [
433
+ ...comments.map(mapCrmComment),
434
+ ...customerNotes.flatMap((note) => {
435
+ const mapped = mapCustomerNote(note);
436
+ if (!mapped) {
437
+ noteCounters.sourceTotal += 1;
438
+ noteCounters.skipped += 1;
439
+ noteCounters.gaps.push(
440
+ `customer note note:${note.id} skipped: customer "${note.customerName ?? note.customerId}" has no unique account match`,
441
+ );
442
+ return [];
443
+ }
444
+ return [mapped];
445
+ }),
446
+ ];
447
+ await upsertNotes({
448
+ client,
449
+ notes: mappedNotes,
450
+ targetIdBySourceId: noteTargets,
451
+ dryRun,
452
+ counters: noteCounters,
453
+ });
454
+ report.notes = summarizeCounters(noteCounters);
455
+
456
+ log("annexes: loading attachments...");
457
+ const attachments = await reader.readCrmAttachments();
458
+ const attachmentCounters = emptyCounters();
459
+ await loadCrmAttachments({
460
+ client,
461
+ authToken: process.env.TWENTY_API_KEY as string,
462
+ baseUrl,
463
+ attachments,
464
+ targetIdBySourceId: opportunityIdBySourceId,
465
+ dryRun,
466
+ counters: attachmentCounters,
467
+ });
468
+ report.attachments = summarizeCounters(attachmentCounters);
469
+
470
+ // Phase F: deletion mirror -------------------------------------------------
471
+ log("deletion mirror: diffing live source ids...");
472
+ await mirrorDeletions({
473
+ client,
474
+ entity: COMPANY,
475
+ liveSourceIds: new Set(
476
+ accounts.map((account) => sourceId("account", account.id)),
477
+ ),
478
+ ownedPrefixes: ["account:"],
479
+ dryRun,
480
+ counters: companyCounters,
481
+ });
482
+ await mirrorDeletions({
483
+ client,
484
+ entity: PERSON,
485
+ liveSourceIds: new Set(
486
+ contacts.map((contact) => sourceId("contact", contact.id)),
487
+ ),
488
+ ownedPrefixes: ["contact:"],
489
+ dryRun,
490
+ counters: personCounters,
491
+ });
492
+ await mirrorDeletions({
493
+ client,
494
+ 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
+ ]),
501
+ ownedPrefixes: ["lead:", "opportunity:"],
502
+ dryRun,
503
+ counters: opportunityCounters,
504
+ });
505
+
506
+ report.companies = summarizeCounters(companyCounters);
507
+ report.people = summarizeCounters(personCounters);
508
+ report.opportunities = summarizeCounters(opportunityCounters);
509
+
510
+ await mirrorDeletions({
511
+ client,
512
+ entity: OPPORTUNITY_PRODUCT,
513
+ liveSourceIds: new Set(
514
+ items.map(
515
+ (item) =>
516
+ `${sourceId("opportunity_item", item.opportunityId)}#${item.index}`,
517
+ ),
518
+ ),
519
+ ownedPrefixes: ["opportunity_item:"],
520
+ dryRun,
521
+ counters: productCounters,
522
+ });
523
+ report.opportunityProducts = summarizeCounters(productCounters);
524
+
525
+ // Phase G: parity + invariants ---------------------------------------------
526
+ log("parity: checking invariants...");
527
+ const invariants = dryRun ? [] : await checkNoteTargetPairing(client);
528
+ report.invariants = {
529
+ noteTargetPairing: invariants,
530
+ // Duplicate live sourceIds abort mid-run inside fetchExistingBySourceIds;
531
+ // reaching this point means zero duplicates were observed.
532
+ duplicateSourceIds: [],
533
+ };
534
+ report.reconciliation = buildReconciliation({
535
+ companies: companyCounters,
536
+ people: personCounters,
537
+ opportunities: opportunityCounters,
538
+ });
539
+
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);
550
+ report.ok = !anyFailures;
551
+ if (anyFailures) process.exitCode = 2;
552
+ }
553
+
554
+ /** Keep the report readable: long lists collapse to count + grouped summary +
555
+ * a bounded sample. The full detail is reproducible by re-running dry-run with
556
+ * MIGRATE_LASTMILE_FULL_REPORT=1. */
557
+ function summarizeList(items: string[]): unknown {
558
+ const full = process.env.MIGRATE_LASTMILE_FULL_REPORT === "1";
559
+ if (full || items.length <= 50) return items;
560
+ const groups = new Map<string, number>();
561
+ for (const item of items) {
562
+ const key = item
563
+ .replace(
564
+ /(account|contact|lead|opportunity|task_comment|note|task_attachment):[\w]+/g,
565
+ "$1:*",
566
+ )
567
+ .replace(/"[^"]*"/g, '"*"')
568
+ .replace(/rep_\w+/g, "rep_*");
569
+ groups.set(key, (groups.get(key) ?? 0) + 1);
570
+ }
571
+ return {
572
+ count: items.length,
573
+ grouped: Object.fromEntries(
574
+ [...groups.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30),
575
+ ),
576
+ sample: items.slice(0, 20),
577
+ };
578
+ }
579
+
580
+ function summarizeCounters(counters: EntityCounters): Record<string, unknown> {
581
+ return {
582
+ ...counters,
583
+ plannedMutations: summarizeList(counters.plannedMutations),
584
+ gaps: summarizeList(counters.gaps),
585
+ warnings: summarizeList(counters.warnings),
586
+ };
587
+ }
588
+
589
+ function buildReconciliation(
590
+ entries: Record<string, EntityCounters>,
591
+ ): Record<string, string> {
592
+ const reconciliation: Record<string, string> = {};
593
+ for (const [name, counters] of Object.entries(entries)) {
594
+ const accounted =
595
+ counters.created +
596
+ counters.updated +
597
+ counters.restored +
598
+ counters.skipped;
599
+ reconciliation[name] =
600
+ `created(${counters.created}) + updated(${counters.updated}) + restored(${counters.restored}) + skipped(${counters.skipped}) = ${accounted} of sourceTotal(${counters.sourceTotal}); failed=${counters.failed}, deleted=${counters.deleted}`;
601
+ }
602
+ return reconciliation;
603
+ }
604
+
605
+ main().catch((error) => {
606
+ process.stderr.write(
607
+ `${error instanceof Error ? error.message : String(error)}\n`,
608
+ );
609
+ process.exit(1);
610
+ });