@dench.com/cli 3.8.0-staging.389 → 3.8.2-staging.391

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.
@@ -6,7 +6,10 @@ export const MAX_CONNECTION_NOTE_CHARS = 300;
6
6
  export const MAX_INMAIL_SUBJECT_CHARS = 200;
7
7
  export const MAX_INMAIL_BODY_CHARS = 1900;
8
8
  export const MAX_LIKE_POST_AGE_DAYS = 365;
9
+ export const MAX_LIKE_POST_COUNT = 3;
9
10
  export const MIN_WITHDRAW_AFTER_HOURS = 7 * 24;
11
+ export const MIN_ACCEPTANCE_WAIT_HOURS = 24;
12
+ export const MAX_ACCEPTANCE_WAIT_HOURS = 90 * 24;
10
13
  export const LINKEDIN_REACTIONS = [
11
14
  "like",
12
15
  "celebrate",
@@ -24,10 +27,36 @@ export const STEP_TYPES = [
24
27
  "linkedin_visit",
25
28
  "linkedin_like_post",
26
29
  "linkedin_inmail",
30
+ "condition",
31
+ "wait_for_connection",
27
32
  ] as const;
28
33
 
29
34
  export type StepType = (typeof STEP_TYPES)[number];
30
35
 
36
+ /** Workflow steps that send nothing and continue on a Yes or a No path. */
37
+ export const BRANCH_STEP_TYPES = ["condition", "wait_for_connection"] as const;
38
+
39
+ export const WORKFLOW_END = "end";
40
+
41
+ export const CONDITION_CHECKS = [
42
+ "linkedin_connected",
43
+ "has_email",
44
+ "opened_last_email",
45
+ "field_value",
46
+ ] as const;
47
+
48
+ export const CONDITION_OPERATORS = [
49
+ "is",
50
+ "is_not",
51
+ "is_empty",
52
+ "is_not_empty",
53
+ "contains",
54
+ ] as const;
55
+
56
+ export function isBranchStep(type: unknown): boolean {
57
+ return (BRANCH_STEP_TYPES as readonly unknown[]).includes(type);
58
+ }
59
+
31
60
  export const STEP_KEYS = [
32
61
  "type",
33
62
  "waitHours",
@@ -43,11 +72,41 @@ export const STEP_KEYS = [
43
72
  "connectionTimeoutHours",
44
73
  "likeReaction",
45
74
  "likeMaxPostAgeDays",
75
+ "likeSkipPreviousWait",
46
76
  "likeSkipNextWait",
77
+ "likeCount",
47
78
  "inmailSubject",
48
79
  "withdrawAfterHours",
80
+ "key",
81
+ "next",
82
+ "yesNext",
83
+ "noNext",
84
+ "check",
85
+ "conditionField",
86
+ "conditionOperator",
87
+ "conditionValue",
49
88
  ] as const;
50
89
 
90
+ /** A workflow step's id and where each of its exits leads. */
91
+ export const PATH_KEYS = ["key", "next", "yesNext", "noNext"] as const;
92
+
93
+ /** Fixed once a campaign launches: its paths and what each If / else checks. */
94
+ export const FROZEN_AFTER_LAUNCH = [
95
+ ...PATH_KEYS,
96
+ "check",
97
+ "conditionField",
98
+ "conditionOperator",
99
+ ] as const;
100
+
101
+ /** Steps with an id, a path or an If / else / acceptance wait make a workflow. */
102
+ export function hasWorkflowShape(step: unknown): boolean {
103
+ if (!step || typeof step !== "object") return false;
104
+ const row = step as Record<string, unknown>;
105
+ return (
106
+ PATH_KEYS.some((key) => row[key] !== undefined) || isBranchStep(row.type)
107
+ );
108
+ }
109
+
51
110
  const FIELDS_BY_TYPE: Record<StepType, readonly string[]> = {
52
111
  wait: ["waitHours"],
53
112
  email: [
@@ -75,13 +134,23 @@ const FIELDS_BY_TYPE: Record<StepType, readonly string[]> = {
75
134
  "onlyIf",
76
135
  "likeReaction",
77
136
  "likeMaxPostAgeDays",
137
+ "likeSkipPreviousWait",
78
138
  "likeSkipNextWait",
139
+ "likeCount",
79
140
  ],
80
141
  linkedin_inmail: [
81
142
  "linkedinAccountId",
82
143
  "linkedinMessageBody",
83
144
  "inmailSubject",
84
145
  ],
146
+ condition: [
147
+ "linkedinAccountId",
148
+ "check",
149
+ "conditionField",
150
+ "conditionOperator",
151
+ "conditionValue",
152
+ ],
153
+ wait_for_connection: ["linkedinAccountId", "connectionTimeoutHours"],
85
154
  };
86
155
 
87
156
  const TEXT_FIELDS = [
@@ -92,6 +161,8 @@ const TEXT_FIELDS = [
92
161
  "linkedinAccountId",
93
162
  "linkedinMessageBody",
94
163
  "inmailSubject",
164
+ "conditionField",
165
+ "conditionValue",
95
166
  ] as const;
96
167
 
97
168
  const TEMPLATE_FIELDS = [
@@ -108,6 +179,11 @@ const MESSAGE_MODES = ["skip_if_not_connected", "wait_until_connected"];
108
179
  export type StepCheckOptions = {
109
180
  allowSingleBraces?: boolean;
110
181
  singleBraceOverride?: string;
182
+ /**
183
+ * The steps form a workflow (paths, If / else, Wait for them to accept).
184
+ * Unset, steps with a key, a path or a logic type make one.
185
+ */
186
+ workflow?: boolean;
111
187
  };
112
188
 
113
189
  export type StepIssue = { step: number; field?: string; message: string };
@@ -124,7 +200,7 @@ function holdsNothing(field: string, value: unknown): boolean {
124
200
  }
125
201
 
126
202
  export function singleBraceTokens(text: string): string[] {
127
- const outside = text.replace(/\{\{[^{}]*\}\}/g, " ");
203
+ const outside = text.replace(/\{\{[^{}]*\}\}|\[\[[^[\]]*\]\]/g, " ");
128
204
  return [
129
205
  ...new Set(
130
206
  [...outside.matchAll(/\{\s*([A-Za-z_][A-Za-z0-9_ .-]{0,60}?)\s*\}/g)].map(
@@ -177,7 +253,7 @@ export function staleHtmlWarning(index: number): string {
177
253
  }
178
254
 
179
255
  function hasEmptyMergeToken(text: string): boolean {
180
- return /\{\{\s*\}\}/.test(text);
256
+ return /\{\{\s*\}\}|\[\[\s*\]\]/.test(text);
181
257
  }
182
258
 
183
259
  function listSteps(numbers: number[]): string {
@@ -240,6 +316,25 @@ function checkStepFieldShapes(
240
316
  `${label}: "likeMaxPostAgeDays" must be a whole number from 1 to ${MAX_LIKE_POST_AGE_DAYS}.`,
241
317
  "likeMaxPostAgeDays",
242
318
  );
319
+ if (
320
+ row.likeCount !== undefined &&
321
+ (typeof row.likeCount !== "number" ||
322
+ !Number.isInteger(row.likeCount) ||
323
+ row.likeCount < 1 ||
324
+ row.likeCount > MAX_LIKE_POST_COUNT)
325
+ )
326
+ fail(
327
+ `${label}: "likeCount" must be a whole number from 1 to ${MAX_LIKE_POST_COUNT}.`,
328
+ "likeCount",
329
+ );
330
+ if (
331
+ row.likeSkipPreviousWait !== undefined &&
332
+ typeof row.likeSkipPreviousWait !== "boolean"
333
+ )
334
+ fail(
335
+ `${label}: "likeSkipPreviousWait" must be true or false.`,
336
+ "likeSkipPreviousWait",
337
+ );
243
338
  if (
244
339
  row.likeSkipNextWait !== undefined &&
245
340
  typeof row.likeSkipNextWait !== "boolean"
@@ -261,14 +356,22 @@ function checkStepFieldShapes(
261
356
  "withdrawAfterHours",
262
357
  );
263
358
 
359
+ checkLogicFieldShapes(row, label, fail);
360
+
264
361
  const kind = stepType === "email" ? "an email" : `a ${stepType}`;
265
362
  const allowed = FIELDS_BY_TYPE[stepType];
266
363
  const dropped = (STEP_KEYS as readonly string[]).filter(
267
364
  (key) =>
268
- key !== "type" && !allowed.includes(key) && !holdsNothing(key, row[key]),
365
+ key !== "type" &&
366
+ !(PATH_KEYS as readonly string[]).includes(key) &&
367
+ !allowed.includes(key) &&
368
+ !holdsNothing(key, row[key]),
269
369
  );
370
+ if (stepType === "condition") dropped.push(...conditionDrops(row));
270
371
  for (const field of dropped) {
271
- if (field === "onlyIf")
372
+ if (stepType === "condition" && allowed.includes(field))
373
+ fail(conditionDropMessage(row, field, label), field);
374
+ else if (field === "onlyIf")
272
375
  fail(
273
376
  `${label}: "onlyIf" only works on email or linkedin_like_post steps; ${kind} step would drop it. InMail already skips connected people.`,
274
377
  field,
@@ -286,6 +389,86 @@ function checkStepFieldShapes(
286
389
  }
287
390
  }
288
391
 
392
+ const CHECK_NAMES: Record<string, string> = {
393
+ linkedin_connected: "whether they are connected on LinkedIn",
394
+ has_email: "whether they have an email address",
395
+ opened_last_email: "whether they opened the last email",
396
+ };
397
+
398
+ function checkLogicFieldShapes(
399
+ row: Record<string, unknown>,
400
+ label: string,
401
+ fail: Fail,
402
+ ): void {
403
+ for (const field of PATH_KEYS) {
404
+ const value = row[field];
405
+ if (value !== undefined && (typeof value !== "string" || !value.trim()))
406
+ fail(
407
+ field === "key"
408
+ ? `${label}: "key" must be a short text id, like "invite".`
409
+ : `${label}: "${field}" must be another step's "key", or "${WORKFLOW_END}" to stop there.`,
410
+ field,
411
+ );
412
+ }
413
+ if (
414
+ row.check !== undefined &&
415
+ !(CONDITION_CHECKS as readonly unknown[]).includes(row.check)
416
+ )
417
+ fail(
418
+ `${label}: "check" must be one of: ${CONDITION_CHECKS.join(", ")}.`,
419
+ "check",
420
+ );
421
+ if (
422
+ row.conditionOperator !== undefined &&
423
+ !(CONDITION_OPERATORS as readonly unknown[]).includes(row.conditionOperator)
424
+ )
425
+ fail(
426
+ `${label}: "conditionOperator" must be one of: ${CONDITION_OPERATORS.join(", ")}.`,
427
+ "conditionOperator",
428
+ );
429
+ }
430
+
431
+ function operatorTakesValue(operator: unknown): boolean {
432
+ return operator !== "is_empty" && operator !== "is_not_empty";
433
+ }
434
+
435
+ /** Condition fields the If / else's own check would drop. */
436
+ function conditionDrops(row: Record<string, unknown>): string[] {
437
+ const drops: string[] = [];
438
+ // An edit that leaves "check" out keeps the stored one, unknown here.
439
+ if (row.check === undefined) return drops;
440
+ if (
441
+ row.check !== "linkedin_connected" &&
442
+ !holdsNothing("linkedinAccountId", row.linkedinAccountId)
443
+ )
444
+ drops.push("linkedinAccountId");
445
+ for (const field of ["conditionField", "conditionOperator"] as const)
446
+ if (row.check !== "field_value" && !holdsNothing(field, row[field]))
447
+ drops.push(field);
448
+ if (
449
+ !holdsNothing("conditionValue", row.conditionValue) &&
450
+ (row.check !== "field_value" || !operatorTakesValue(row.conditionOperator))
451
+ )
452
+ drops.push("conditionValue");
453
+ return drops;
454
+ }
455
+
456
+ function conditionDropMessage(
457
+ row: Record<string, unknown>,
458
+ field: string,
459
+ label: string,
460
+ ): string {
461
+ if (field === "linkedinAccountId")
462
+ return `${label}: "linkedinAccountId" is only kept on an If / else that checks "linkedin_connected", so it would be dropped.`;
463
+ if (field === "conditionValue" && row.check === "field_value")
464
+ return `${label}: "conditionValue" would be dropped, because "${String(row.conditionOperator)}" compares against nothing.`;
465
+ const checked =
466
+ typeof row.check === "string" && CHECK_NAMES[row.check]
467
+ ? ` This one checks ${CHECK_NAMES[row.check]}.`
468
+ : "";
469
+ return `${label}: "${field}" is only kept when "check" is "field_value", so it would be dropped.${checked}`;
470
+ }
471
+
289
472
  function checkTemplateTokens(
290
473
  row: Record<string, unknown>,
291
474
  label: string,
@@ -297,7 +480,10 @@ function checkTemplateTokens(
297
480
  const text = row[field];
298
481
  if (typeof text !== "string") continue;
299
482
  if (hasEmptyMergeToken(text))
300
- fail(`${label}: "${field}" has an empty {{ }} merge token.`, field);
483
+ fail(
484
+ `${label}: "${field}" has an empty merge token ({{ }} or [[ ]]).`,
485
+ field,
486
+ );
301
487
  if (options.allowSingleBraces) continue;
302
488
  const tokens = singleBraceTokens(text);
303
489
  if (tokens.length)
@@ -373,6 +559,146 @@ function checkTimeoutRange(timeout: unknown, label: string, fail: Fail): void {
373
559
  );
374
560
  }
375
561
 
562
+ function checkCondition(
563
+ row: Record<string, unknown>,
564
+ label: string,
565
+ fail: Fail,
566
+ ): void {
567
+ if (row.check === undefined) {
568
+ fail(
569
+ `${label} is an If / else (condition) step and needs "check": ${CONDITION_CHECKS.join(", ")}.`,
570
+ "check",
571
+ );
572
+ return;
573
+ }
574
+ if (row.check !== "field_value") return;
575
+ if (!trimmedText(row.conditionField))
576
+ fail(
577
+ `${label} checks a lead field and needs "conditionField", the field's name (like "Job Title").`,
578
+ "conditionField",
579
+ );
580
+ if (row.conditionOperator === undefined)
581
+ fail(
582
+ `${label} checks a lead field and needs "conditionOperator": ${CONDITION_OPERATORS.join(", ")}.`,
583
+ "conditionOperator",
584
+ );
585
+ else if (
586
+ operatorTakesValue(row.conditionOperator) &&
587
+ !trimmedText(row.conditionValue)
588
+ )
589
+ fail(
590
+ `${label}: "conditionOperator": "${String(row.conditionOperator)}" needs "conditionValue", the text to compare the field with.`,
591
+ "conditionValue",
592
+ );
593
+ }
594
+
595
+ function checkAcceptanceWait(
596
+ timeout: unknown,
597
+ label: string,
598
+ fail: Fail,
599
+ ): void {
600
+ if (timeout === undefined) return;
601
+ if (
602
+ typeof timeout !== "number" ||
603
+ !Number.isFinite(timeout) ||
604
+ timeout < MIN_ACCEPTANCE_WAIT_HOURS ||
605
+ timeout > MAX_ACCEPTANCE_WAIT_HOURS
606
+ )
607
+ fail(
608
+ `${label}: "connectionTimeoutHours" must be ${MIN_ACCEPTANCE_WAIT_HOURS}-${MAX_ACCEPTANCE_WAIT_HOURS} hours (1-90 days) of waiting for them to accept.`,
609
+ "connectionTimeoutHours",
610
+ );
611
+ }
612
+
613
+ const BRANCH_NAMES: Record<string, { name: string; yes: string; no: string }> =
614
+ {
615
+ condition: { name: "If / else", yes: "Yes", no: "No" },
616
+ wait_for_connection: {
617
+ name: "Wait for them to accept",
618
+ yes: "Accepted",
619
+ no: "Not accepted",
620
+ },
621
+ };
622
+
623
+ /**
624
+ * The paths a workflow's steps declare, step by step: unique keys, targets
625
+ * that exist, and the right exits for each kind of step. Whether every step
626
+ * is reachable and every path moves forward is checked when the steps save.
627
+ */
628
+ function checkWorkflowPaths(
629
+ steps: readonly unknown[],
630
+ errors: StepIssue[],
631
+ ): void {
632
+ const rows = steps.map((value) =>
633
+ value && typeof value === "object" && !Array.isArray(value)
634
+ ? (value as Record<string, unknown>)
635
+ : null,
636
+ );
637
+ const firstWithKey = new Map<string, number>();
638
+ rows.forEach((row, index) => {
639
+ const key = row?.key;
640
+ if (typeof key === "string" && key && !firstWithKey.has(key))
641
+ firstWithKey.set(key, index);
642
+ });
643
+ rows.forEach((row, index) => {
644
+ if (!row) return;
645
+ const step = index + 1;
646
+ const fail: Fail = (message, field) =>
647
+ errors.push({ step, ...(field ? { field } : {}), message });
648
+ const branch = BRANCH_NAMES[String(row.type)];
649
+ const label = branch ? `Step ${step} (${branch.name})` : `Step ${step}`;
650
+ const key = typeof row.key === "string" ? row.key : undefined;
651
+ if (key === WORKFLOW_END)
652
+ fail(
653
+ `${label}: "key" can't be "${WORKFLOW_END}", because "${WORKFLOW_END}" marks where a path stops.`,
654
+ "key",
655
+ );
656
+ else if (key && firstWithKey.get(key) !== index)
657
+ fail(
658
+ `Steps ${(firstWithKey.get(key) ?? 0) + 1} and ${step} share the key "${key}". Give each step its own key.`,
659
+ "key",
660
+ );
661
+ if (branch) {
662
+ for (const [field, exit] of [
663
+ ["yesNext", branch.yes],
664
+ ["noNext", branch.no],
665
+ ] as const)
666
+ if (row[field] === undefined)
667
+ fail(
668
+ `${label} needs "${field}", where the "${exit}" path goes: another step's "key", or "${WORKFLOW_END}".`,
669
+ field,
670
+ );
671
+ if (row.next !== undefined)
672
+ fail(
673
+ `${label} continues on "yesNext" and "noNext", so it can't also have "next".`,
674
+ "next",
675
+ );
676
+ } else {
677
+ for (const field of ["yesNext", "noNext"] as const)
678
+ if (row[field] !== undefined)
679
+ fail(
680
+ `${label}: only condition and wait_for_connection steps branch, so "${field}" doesn't apply. Give it a single "next".`,
681
+ field,
682
+ );
683
+ }
684
+ const exits = branch
685
+ ? (["yesNext", "noNext"] as const)
686
+ : (["next"] as const);
687
+ for (const field of exits) {
688
+ const target = row[field];
689
+ if (typeof target !== "string" || !target || target === WORKFLOW_END)
690
+ continue;
691
+ if (target === key)
692
+ fail(`${label}: "${field}" points back at the step itself.`, field);
693
+ else if (!firstWithKey.has(target))
694
+ fail(
695
+ `${label}: "${field}" points at "${target}", which is not the "key" of any step. Use another step's "key", or "${WORKFLOW_END}" to stop there.`,
696
+ field,
697
+ );
698
+ }
699
+ });
700
+ }
701
+
376
702
  function stepRow(
377
703
  value: unknown,
378
704
  label: string,
@@ -408,9 +734,12 @@ export function checkCampaignSteps(
408
734
  step: 0,
409
735
  message: `A campaign can have at most ${MAX_CAMPAIGN_STEPS} steps (got ${steps.length}).`,
410
736
  });
737
+ const shaped = steps.some(hasWorkflowShape);
738
+ const workflow = options.workflow ?? shaped;
411
739
  let emailSeen = false;
412
740
  let connectSeen = false;
413
741
  const waitingMessages: number[] = [];
742
+ const unwaitedMessages: number[] = [];
414
743
  steps.forEach((value, index) => {
415
744
  const step = index + 1;
416
745
  const label = `Step ${step}`;
@@ -432,7 +761,19 @@ export function checkCampaignSteps(
432
761
 
433
762
  if (stepType === "email") {
434
763
  const reply = row.emailReplyToPrevious === true;
435
- if (reply && !emailSeen)
764
+ if (
765
+ reply &&
766
+ workflow &&
767
+ !steps.some(
768
+ (other, at) =>
769
+ at !== index && (other as { type?: unknown })?.type === "email",
770
+ )
771
+ )
772
+ fail(
773
+ `${label}: "emailReplyToPrevious" needs an email step before it on every path, and this workflow has no other email. Give this email its own "emailSubject".`,
774
+ "emailReplyToPrevious",
775
+ );
776
+ else if (reply && !emailSeen && !workflow)
436
777
  fail(
437
778
  `${label}: "emailReplyToPrevious" needs an earlier email step to reply to, so the first email would lose it. Give this email its own "emailSubject".`,
438
779
  "emailReplyToPrevious",
@@ -468,29 +809,47 @@ export function checkCampaignSteps(
468
809
 
469
810
  if (stepType === "linkedin_message") {
470
811
  checkMessageBody(trimmedText(row.linkedinMessageBody), label, fail);
812
+ // A workflow waits for the accept with its own wait_for_connection step.
813
+ const waitsByDefault = connectSeen && !workflow;
471
814
  const mode =
472
815
  row.linkedinMessageMode ??
473
- (connectSeen ? "wait_until_connected" : "skip_if_not_connected");
816
+ (waitsByDefault ? "wait_until_connected" : "skip_if_not_connected");
474
817
  const timeout = row.connectionTimeoutHours;
475
818
  if (timeout !== undefined) {
476
819
  if (mode !== "wait_until_connected")
477
820
  fail(
478
- `${label}: "connectionTimeoutHours" only applies with "linkedinMessageMode": "wait_until_connected"${connectSeen ? "" : " (the default only after a linkedin_connection step)"}, so it would be dropped.`,
821
+ `${label}: "connectionTimeoutHours" only applies with "linkedinMessageMode": "wait_until_connected"${waitsByDefault ? "" : workflow ? " (in a workflow, a wait_for_connection step waits for the accept instead)" : " (the default only after a linkedin_connection step)"}, so it would be dropped.`,
479
822
  "connectionTimeoutHours",
480
823
  );
481
824
  else checkTimeoutRange(timeout, label, fail);
482
825
  }
483
826
  if (
484
- connectSeen &&
827
+ waitsByDefault &&
485
828
  mode === "wait_until_connected" &&
486
829
  (timeout ?? DEFAULT_CONNECTION_WAIT_HOURS) ===
487
830
  DEFAULT_CONNECTION_WAIT_HOURS
488
831
  )
489
832
  waitingMessages.push(step);
833
+ if (
834
+ workflow &&
835
+ !shaped &&
836
+ connectSeen &&
837
+ row.linkedinMessageMode === undefined
838
+ )
839
+ unwaitedMessages.push(step);
490
840
  }
491
841
 
842
+ if (stepType === "condition") checkCondition(row, label, fail);
843
+ if (stepType === "wait_for_connection")
844
+ checkAcceptanceWait(row.connectionTimeoutHours, label, fail);
845
+
492
846
  checkTemplateTokens(row, label, fail, options);
493
847
  });
848
+ if (workflow) checkWorkflowPaths(steps, errors);
849
+ if (unwaitedMessages.length > 0)
850
+ warnings.push(
851
+ `${listSteps(unwaitedMessages)} ${unwaitedMessages.length === 1 ? "is a LinkedIn message" : "are LinkedIn messages"} after the connection request, and this campaign is a workflow, so ${unwaitedMessages.length === 1 ? "it doesn't" : "they don't"} wait for the accept: anyone who hasn't accepted by then is skipped. To wait, edit the workflow dench campaign steps get <campaign> prints, or add a wait_for_connection step after the request with "yesNext" leading to the message. To skip on purpose, set "linkedinMessageMode": "skip_if_not_connected".`,
852
+ );
494
853
  if (waitingMessages.length >= 2)
495
854
  warnings.push(
496
855
  `${listSteps(waitingMessages)} are LinkedIn messages after the connection request. Each waits up to ${DEFAULT_CONNECTION_WAIT_HOURS / 24} days for the accept by default, so someone who never accepts holds the sequence for up to ${(waitingMessages.length * DEFAULT_CONNECTION_WAIT_HOURS) / 24} days. Give the later ones "linkedinMessageMode": "skip_if_not_connected" or a shorter "connectionTimeoutHours".`,
@@ -552,6 +911,8 @@ export function checkStepEdits(
552
911
  );
553
912
  }
554
913
  }
914
+ if (stepType === "wait_for_connection")
915
+ checkAcceptanceWait(row.connectionTimeoutHours, label, fail);
555
916
  checkTemplateTokens(row, label, fail, options);
556
917
  });
557
918
  return { errors, warnings: [] };
@@ -564,8 +925,12 @@ const KEEP_WHEN_OMITTED = [
564
925
  "connectionTimeoutHours",
565
926
  "likeReaction",
566
927
  "likeMaxPostAgeDays",
928
+ "likeSkipPreviousWait",
567
929
  "likeSkipNextWait",
930
+ "likeCount",
568
931
  "withdrawAfterHours",
932
+ ...FROZEN_AFTER_LAUNCH,
933
+ "conditionValue",
569
934
  ] as const;
570
935
 
571
936
  const CLEARED_VALUES: Record<string, unknown> = {
@@ -587,8 +952,63 @@ const TYPE_NAMES: Record<StepType, string> = {
587
952
  linkedin_message: "a LinkedIn message",
588
953
  linkedin_like_post: "a post reaction",
589
954
  linkedin_inmail: "an InMail",
955
+ condition: "an If / else",
956
+ wait_for_connection: "a Wait for them to accept",
957
+ };
958
+
959
+ const STEP_NAMES: Record<string, string> = {
960
+ condition: "If / else",
961
+ wait_for_connection: "Wait for them to accept",
590
962
  };
591
963
 
964
+ /** Why a launched campaign's file can't change these paths and checks. */
965
+ function frozenFieldProblems(
966
+ stored: readonly StoredStep[],
967
+ edits: ReadonlyMap<number, Record<string, unknown>>,
968
+ campaign: string,
969
+ ): string[] {
970
+ const problems: string[] = [];
971
+ let paths = false;
972
+ let checks = false;
973
+ for (const [index, fields] of [...edits.entries()].sort(
974
+ ([a], [b]) => a - b,
975
+ )) {
976
+ const before = stepToInput(stored[index]);
977
+ const changed = FROZEN_AFTER_LAUNCH.filter(
978
+ (field) => fields[field] !== undefined && fields[field] !== before[field],
979
+ );
980
+ if (!changed.length) continue;
981
+ const name = STEP_NAMES[stored[index].type];
982
+ const label = `Step ${index + 1}${name ? ` (${name})` : ""}`;
983
+ const list = changed.map((field) => `"${field}"`).join(", ");
984
+ const isPath = (field: string) =>
985
+ (PATH_KEYS as readonly string[]).includes(field);
986
+ if (changed.some(isPath)) paths = true;
987
+ if (!changed.every(isPath)) checks = true;
988
+ problems.push(
989
+ `${label}: ${list} can't change after launch (the file has ${changed
990
+ .map(
991
+ (field) =>
992
+ `${field} ${JSON.stringify(fields[field])} instead of ${JSON.stringify(before[field] ?? null)}`,
993
+ )
994
+ .join(", ")}).`,
995
+ );
996
+ }
997
+ if (!problems.length) return problems;
998
+ const why = [
999
+ paths
1000
+ ? "Where a launched campaign's steps lead stays fixed, because people may already be on those paths."
1001
+ : "",
1002
+ checks
1003
+ ? 'A launched If / else keeps what it checks; you can still change its "conditionValue".'
1004
+ : "",
1005
+ ].filter(Boolean);
1006
+ return [
1007
+ ...problems,
1008
+ `${why.join(" ")} Nothing was saved. To change it, copy the campaign with dench campaign duplicate ${campaign} and set the copy's steps.`,
1009
+ ];
1010
+ }
1011
+
592
1012
  export class StepEditError extends Error {}
593
1013
 
594
1014
  export type StoredStep = Record<string, unknown> & { type: string };
@@ -607,6 +1027,27 @@ export type StepEditOptions = StepCheckOptions & {
607
1027
  draft?: boolean;
608
1028
  };
609
1029
 
1030
+ /**
1031
+ * Where each file row's `key` sits among the stored steps: null when some row
1032
+ * has no key or two rows share one (match by order instead), undefined when
1033
+ * every row has its own key but one names no stored step.
1034
+ */
1035
+ function keyMatch(
1036
+ stored: readonly StoredStep[],
1037
+ rows: readonly Record<string, unknown>[],
1038
+ ): number[] | null | undefined {
1039
+ const keys = rows.map((row) => row.key);
1040
+ if (
1041
+ keys.some((key) => typeof key !== "string" || !key) ||
1042
+ new Set(keys).size !== keys.length
1043
+ )
1044
+ return null;
1045
+ const indexes = keys.map((key) =>
1046
+ stored.findIndex((step) => step.key === key),
1047
+ );
1048
+ return indexes.includes(-1) ? undefined : indexes;
1049
+ }
1050
+
610
1051
  export function planStepEdits(
611
1052
  storedSteps: readonly StoredStep[],
612
1053
  entries: readonly unknown[],
@@ -632,14 +1073,24 @@ export function planStepEdits(
632
1073
  const byId = withIds > 0;
633
1074
  if (!byId && rows.length !== stored.length)
634
1075
  throw new StepEditError(
635
- `The file has ${rows.length} ${rows.length === 1 ? "step" : "steps"} and the campaign has ${stored.length}. steps edit changes steps in place, matched by order, so it needs all ${stored.length}; to edit only some, include "stepId" from dench campaign steps get ${campaign} --with-ids.`,
1076
+ `The file has ${rows.length} ${rows.length === 1 ? "step" : "steps"} and the campaign has ${stored.length}. steps edit changes steps in place, matched by ${options.workflow ? '"key" or by order' : "order"}, so it needs all ${stored.length}; to edit only some, include "stepId" from dench campaign steps get ${campaign} --with-ids.`,
636
1077
  );
1078
+ // A workflow is stored in path order, which can differ from the file's,
1079
+ // so its steps match by key whenever every key names a stored step.
1080
+ const keyIndex = byId || !options.workflow ? null : keyMatch(stored, rows);
1081
+ if (keyIndex === undefined && !options.draft) {
1082
+ const keys = new Set(stored.map((step) => step.key));
1083
+ const unknown = rows.findIndex((row) => !keys.has(row.key));
1084
+ throw new StepEditError(
1085
+ `Step ${unknown + 1} in the file has "key": ${JSON.stringify(rows[unknown].key)}, which is not a step of this campaign. After launch every step keeps its "key"; use the keys dench campaign steps get ${campaign} prints.`,
1086
+ );
1087
+ }
637
1088
  const restructure = options.draft
638
1089
  ? `To add, remove, reorder or retype steps on a draft, use dench campaign steps set ${campaign} --file steps.json.`
639
1090
  : `After launch a step keeps its type and place; to change the sequence, copy it with dench campaign duplicate ${campaign} and set the copy's steps.`;
640
1091
  const edits = new Map<number, Record<string, unknown>>();
641
1092
  rows.forEach((row, position) => {
642
- let index = position;
1093
+ let index = keyIndex?.[position] ?? position;
643
1094
  if (byId) {
644
1095
  index = stored.findIndex(
645
1096
  (step) => String(step._id) === String(row.stepId),
@@ -665,6 +1116,10 @@ export function planStepEdits(
665
1116
  const { stepId: _stepId, updatedAt: _updatedAt, ...fields } = row;
666
1117
  edits.set(index, fields);
667
1118
  });
1119
+ if (!options.draft) {
1120
+ const frozen = frozenFieldProblems(stored, edits, campaign);
1121
+ if (frozen.length) throw new StepEditError(frozen.join("\n"));
1122
+ }
668
1123
 
669
1124
  const warnings: string[] = [];
670
1125
  const steps = stored.map((step, index) => {
@@ -674,9 +1129,16 @@ export function planStepEdits(
674
1129
  const after: Record<string, unknown> = {};
675
1130
  for (const [key, value] of Object.entries(fields))
676
1131
  if (value !== undefined) after[key] = value;
1132
+ const kept: string[] = [];
677
1133
  for (const key of KEEP_WHEN_OMITTED)
678
- if (after[key] === undefined && before[key] !== undefined)
1134
+ if (after[key] === undefined && before[key] !== undefined) {
679
1135
  after[key] = before[key];
1136
+ kept.push(key);
1137
+ }
1138
+ // A new check or comparison leaves behind the saved settings it ignores.
1139
+ if (step.type === "condition")
1140
+ for (const key of conditionDrops(after))
1141
+ if (kept.includes(key)) delete after[key];
680
1142
  if (
681
1143
  hasHtmlAndTextBody(before) &&
682
1144
  trimmedText(after.emailTextBody) !== "" &&