@wirecat/cli-core 0.596.0 → 0.598.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 (62) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/background/deliver.d.ts +7 -1
  3. package/dist/background/deliver.js +66 -10
  4. package/dist/background/telegram-html.d.ts +2 -0
  5. package/dist/background/telegram-html.js +66 -0
  6. package/dist/calendar/cli/index.js +4 -11
  7. package/dist/calendar/mcp/index.d.ts +12 -12
  8. package/dist/cli/context.d.ts +12 -0
  9. package/dist/cli/context.js +17 -3
  10. package/dist/cli/contract-templates.js +1 -0
  11. package/dist/cli/early-refusal.js +7 -5
  12. package/dist/cli/index.d.ts +2 -2
  13. package/dist/cli/index.js +1 -1
  14. package/dist/cli/permission-keys.d.ts +10 -2
  15. package/dist/cli/preview.d.ts +3 -0
  16. package/dist/cli/preview.js +9 -5
  17. package/dist/cli/program.js +3 -0
  18. package/dist/crm/cli/index.js +76 -17
  19. package/dist/crm/index.d.ts +1 -1
  20. package/dist/crm/index.js +1 -1
  21. package/dist/crm/source.d.ts +6 -1
  22. package/dist/crm/sync.d.ts +5 -4
  23. package/dist/crm/sync.js +22 -4
  24. package/dist/crm/testing/index.d.ts +1 -1
  25. package/dist/crm/testing/index.js +1 -1
  26. package/dist/crm/testing/source-contract.js +28 -0
  27. package/dist/crm/testing/source.d.ts +8 -1
  28. package/dist/crm/testing/source.js +53 -9
  29. package/dist/crm/testing/sync-contract.js +14 -3
  30. package/dist/crm/writer.d.ts +11 -2
  31. package/dist/crm/writer.js +31 -0
  32. package/dist/crm/writes.d.ts +12 -2
  33. package/dist/crm/writes.js +93 -8
  34. package/dist/domain/crm.d.ts +29 -1
  35. package/dist/entities/deal.d.ts +16 -0
  36. package/dist/entities/deal.js +8 -0
  37. package/dist/entities/index.d.ts +12 -0
  38. package/dist/errors/reasons.js +4 -0
  39. package/dist/index.d.ts +1 -1
  40. package/dist/kit/index.d.ts +1 -0
  41. package/dist/kit/index.js +1 -0
  42. package/dist/kit/run-scope.d.ts +34 -0
  43. package/dist/kit/run-scope.js +112 -0
  44. package/dist/mcp/app-server.js +7 -2
  45. package/dist/meetings/cli/index.d.ts +5 -0
  46. package/dist/meetings/cli/index.js +4 -3
  47. package/dist/meetings/mcp/index.d.ts +15 -15
  48. package/dist/store/driver.d.ts +2 -0
  49. package/dist/store/driver.js +2 -0
  50. package/dist/store/search-index.js +6 -1
  51. package/dist/store/sqlite/accounts.js +1 -0
  52. package/dist/store/sqlite/crm.d.ts +5 -1
  53. package/dist/store/sqlite/crm.js +52 -0
  54. package/dist/store/sqlite/deals.d.ts +5 -0
  55. package/dist/store/sqlite/deals.js +13 -0
  56. package/dist/store/sqlite/manifest.js +1 -0
  57. package/dist/store/sqlite/migrations.generated.js +6 -0
  58. package/dist/store/sqlite/note-index.d.ts +1 -1
  59. package/dist/store/sqlite/note-index.js +3 -0
  60. package/dist/store/sqlite/schema.d.ts +217 -0
  61. package/dist/store/sqlite/schema.js +18 -0
  62. package/package.json +2 -2
@@ -13,6 +13,7 @@ export declare const preview: (command: Command, settings: Pick<Settings, "permi
13
13
  also?: {
14
14
  key: PermissionKey;
15
15
  level: string;
16
+ when?: string;
16
17
  }[];
17
18
  } | undefined;
18
19
  validation: {
@@ -49,6 +50,7 @@ export declare const previewBody: (command: Command, permission: {
49
50
  also?: {
50
51
  key: PermissionKey;
51
52
  level: string;
53
+ when?: string;
52
54
  }[];
53
55
  } | undefined, messenger: boolean) => {
54
56
  preview: boolean;
@@ -59,6 +61,7 @@ export declare const previewBody: (command: Command, permission: {
59
61
  also?: {
60
62
  key: PermissionKey;
61
63
  level: string;
64
+ when?: string;
62
65
  }[];
63
66
  } | undefined;
64
67
  validation: {
@@ -23,11 +23,15 @@ export const preview = (command, settings, tool, keys, messenger = true) => {
23
23
  (!declared && key !== null && key !== undefined && WRITE_KEYS.includes(key));
24
24
  if (level === "deny" || (writes && level === "readonly"))
25
25
  throw new CliError("permission_error", `this profile does not permit ${path.join(" ")}`, { permission: key });
26
- const also = (declared?.alsoChecks?.(path) ?? []).map((one) => ({
27
- key: one,
28
- level: levelFor(permissions, one, declared?.defaults).level,
29
- }));
30
- const stopped = also.find((one) => one.level === "deny" || (writes && one.level === "readonly"));
26
+ const also = (declared?.alsoChecks?.(path) ?? []).map((one) => {
27
+ const key = typeof one === "string" ? one : one.key;
28
+ return {
29
+ key,
30
+ level: levelFor(permissions, key, declared?.defaults).level,
31
+ ...(typeof one === "string" ? {} : { when: one.when }),
32
+ };
33
+ });
34
+ const stopped = also.find((one) => one.when === undefined && (one.level === "deny" || (writes && one.level === "readonly")));
31
35
  if (stopped)
32
36
  throw new CliError("permission_error", `this profile does not permit ${stopped.key}`, { permission: stopped.key });
33
37
  return previewBody(command, { key: key ?? null, level, ...(also.length ? { also } : {}) }, messenger);
@@ -228,6 +228,9 @@ const runOnce = async (argv, definition, options) => {
228
228
  commandSignal: control.signal,
229
229
  trackCloseable: control.trackCloseable,
230
230
  app: definition.app,
231
+ // `settingsFor`'s, which every CLI passes; the definition's type is looser only so a test can stub it.
232
+ resolveSettings: (definition.configuration ?? settingsFor(definition.app))
233
+ .resolveSettings,
231
234
  ...(definition.rows ? { rows: definition.rows } : {}),
232
235
  platform: options.platform ?? process.platform,
233
236
  exit,
@@ -3,11 +3,11 @@ import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
3
3
  import { Argument, InvalidArgumentError } from "commander";
4
4
  import * as v from "valibot";
5
5
  import { answerEntity } from "../../cli/answer-entity.js";
6
- import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
6
+ import { environmentOf, inCommandRun, outputFor, runBudgetOf } from "../../cli/context.js";
7
7
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
8
8
  import { FETCH, LOCAL_READ, NO_EFFECTS, REMOTE_WRITE } from "../../cli/effects.js";
9
- import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
10
- import { queryTerms, termField, withQuery } from "../../cli/list-query.js";
9
+ import { itemOf, listOf, renderAnswer, snakeKeys, snakeKeysKeeping } from "../../cli/envelope.js";
10
+ import { queryTerms, termField, unfilteredQuery, withQuery } from "../../cli/list-query.js";
11
11
  import { terminalAsker } from "../../cli/messenger/ask.js";
12
12
  import { lineOf } from "../../cli/steps.js";
13
13
  import { CRM_KINDS } from "../../domain/crm.js";
@@ -41,7 +41,15 @@ export const STAGES_QUERY = {
41
41
  ],
42
42
  };
43
43
  /** A CRM's users typed these; a list item names its own, as `events list` does. */
44
- const DEAL_UNTRUSTED = ["title", "summary", "stage", "pipeline_name", "organization.name", "people.name"];
44
+ const DEAL_UNTRUSTED = [
45
+ "title",
46
+ "summary",
47
+ "stage",
48
+ "pipeline_name",
49
+ "organization.name",
50
+ "people.name",
51
+ "provider_metadata",
52
+ ];
45
53
  const PIPELINE_UNTRUSTED = ["name", "stages.name"];
46
54
  const STAGE_UNTRUSTED = ["name", "pipeline_name"];
47
55
  const crmSync = v.strictObject({
@@ -92,18 +100,36 @@ const crmWrite = v.strictObject({
92
100
  lead: v.optional(dealEntity),
93
101
  untrusted: v.array(text),
94
102
  });
103
+ /** b24's `fields list` answer, so a CRM CLI's own command can go; `untrusted` names what the CRM's users typed. */
104
+ const crmField = v.strictObject({
105
+ code: v.string(),
106
+ label: v.nullable(v.string()),
107
+ type: v.string(),
108
+ multiple: v.boolean(),
109
+ filterable: v.boolean(),
110
+ options: v.optional(v.array(v.strictObject({ id: v.string(), label: v.string() }))),
111
+ untrusted: v.array(text),
112
+ });
113
+ const FIELDS_QUERY = unfilteredQuery("crm_field", crmField);
114
+ const FIELD_UNTRUSTED = ["label", "options.label"];
115
+ /** The words `fields list` takes: its sibling commands' nouns. */
116
+ const FIELD_KINDS = {
117
+ deals: "deal",
118
+ leads: "lead",
119
+ people: "person",
120
+ organizations: "organization",
121
+ };
95
122
  const crmRemoval = v.strictObject({ operation_id: text, ref: text, removed: count });
96
123
  const collect = (value, previous = []) => {
97
124
  if (value.indexOf("=") < 1)
98
- throw new InvalidArgumentError("expected code=value");
125
+ throw new InvalidArgumentError("expected key=value");
99
126
  return [...previous, value];
100
127
  };
101
- /** `--field code=value` pairs; an empty value clears the field. */
102
- const customOf = (pairs) => Object.fromEntries(pairs.map((pair) => {
128
+ /** `--field` as typed, split at the first `=`. */
129
+ const pairsOf = (typed) => typed.map((pair) => {
103
130
  const at = pair.indexOf("=");
104
- const given = pair.slice(at + 1);
105
- return [pair.slice(0, at), given === "" ? null : given];
106
- }));
131
+ return [pair.slice(0, at), pair.slice(at + 1)];
132
+ });
107
133
  const integer = (most) => (value) => {
108
134
  if (!/^[1-9]\d*$/.test(value) || Number(value) > most)
109
135
  throw new InvalidArgumentError(`expected 1 to ${most}`);
@@ -111,10 +137,12 @@ const integer = (most) => (value) => {
111
137
  };
112
138
  /** The words below the program, as `page.next` and `sync`'s next step run it again. */
113
139
  const pathOf = (command) => command.parent?.parent ? [...pathOf(command.parent), command.name()] : [command.name()];
114
- const marked = (items, untrusted) => items.map((item) => ({ ...snakeKeys(item), untrusted }));
140
+ /** A CRM's own field codes are its keys, never ours to rename. */
141
+ const dealKeys = snakeKeysKeeping(["providerMetadata"]);
142
+ const marked = (items, untrusted) => items.map((item) => ({ ...dealKeys(item), untrusted }));
115
143
  const addCrmCommandsDefinition = (program, options) => {
116
144
  const now = () => options.now?.() ?? Date.now();
117
- const within = async (command, work) => {
145
+ const within = (command, work) => inCommandRun(command, async () => {
118
146
  const run = await options.open(command, { readOnly: metaOf(command).effects?.local !== "write" });
119
147
  let body;
120
148
  try {
@@ -124,7 +152,7 @@ const addCrmCommandsDefinition = (program, options) => {
124
152
  await run.close?.();
125
153
  }
126
154
  renderAnswer(command, outputFor(command), body);
127
- };
155
+ });
128
156
  const example = (command, ...lines) => annotate(command, { examples: lines.map((line) => `${options.app.command} work ${line}`) });
129
157
  example(answerEntity(annotate(program.command("sync"), { effects: FETCH, offline: false, cooperative: true, partial: true }), "crm_sync", crmSync).description("bring the CRM's copy in the local store up to date: pipelines, companies, contacts, deals and leads that " +
130
158
  "changed since the last sync, or everything with --rescan; a run stops before its deadline or --max-pages " +
@@ -237,7 +265,7 @@ const addCrmCommandsDefinition = (program, options) => {
237
265
  .argument("<ref>", `${kind}:<id>, as a list shows it`), `${plural} show ${kind}:12`).action(async (reference, _opts, command) => {
238
266
  await within(command, async (run) => {
239
267
  const found = await ownCopy(run, kind, reference);
240
- return itemOf({ ...snakeKeys(found), untrusted: DEAL_UNTRUSTED });
268
+ return itemOf({ ...dealKeys(found), untrusted: DEAL_UNTRUSTED });
241
269
  });
242
270
  });
243
271
  addWrites(group, kind);
@@ -272,7 +300,7 @@ const addCrmCommandsDefinition = (program, options) => {
272
300
  };
273
301
  const answered = (operationId, item) => itemOf({
274
302
  operation_id: operationId,
275
- [kind]: { ...snakeKeys(item), untrusted: DEAL_UNTRUSTED },
303
+ [kind]: { ...dealKeys(item), untrusted: DEAL_UNTRUSTED },
276
304
  untrusted: DEAL_UNTRUSTED.map((field) => `${kind}.${field}`),
277
305
  });
278
306
  const write = (name, verb, description) => {
@@ -292,12 +320,14 @@ const addCrmCommandsDefinition = (program, options) => {
292
320
  .option("--currency <code>", "an ISO 4217 code, such as EUR")
293
321
  .option("--close-date <date>", "the day it is expected to close, YYYY-MM-DD")
294
322
  .option("--owner <user>", "the CRM user it is assigned to; anyone but the sign-in's own user checks crm.assign-others")
295
- .option("--field <key=value>", "one of the CRM's own fields, by its code; repeat for more, empty clears it", collect,
323
+ .option("--field <key=value>", "one of the CRM's own fields, by its code or label as `fields list` prints them, and its value; a list " +
324
+ "field takes its option's label or id; repeat it for more fields, or for each value of a multiple field; " +
325
+ "an empty value clears it", collect,
296
326
  // The empty default marks the flag as repeatable, so an agent's tool takes a list.
297
327
  []);
298
328
  const inputOf = ({ field, operationId: _id, ...rest }) => ({
299
329
  ...Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined)),
300
- ...(field?.length ? { custom: customOf(field) } : {}),
330
+ ...(field?.length ? { custom: pairsOf(field) } : {}),
301
331
  });
302
332
  const idOf = (opts) => opts.operationId === undefined ? {} : { operationId: opts.operationId };
303
333
  const run = (command, work) => within(command, (opened) => work(contextOf(opened, command)));
@@ -408,6 +438,35 @@ const addCrmCommandsDefinition = (program, options) => {
408
438
  });
409
439
  });
410
440
  });
441
+ const fieldKinds = Object.keys(FIELD_KINDS).filter((word) => word !== "leads" || options.leads !== false);
442
+ const fields = annotate(program.command("fields"), { effects: NO_EFFECTS }).description("the CRM's own fields on deals, leads, people and organizations, as last synced");
443
+ example(withQuery(answerEntity(annotate(fields.command("list"), { effects: LOCAL_READ }), "crm_field", crmField)
444
+ .description("the CRM's custom fields of one kind, in its order, with a list field's options")
445
+ .argument("<kind>", fieldKinds.join(", "))
446
+ .option("--limit <n>", `fields per page, 1–${MOST}`, integer(MOST), 50)
447
+ // No default: the run's settings refuse --all beside a --page, given or defaulted.
448
+ .option("--page <n>", "which page, starting at 1", integer(1_000_000))
449
+ .option("--all", "every field, no paging"), FIELDS_QUERY), "fields list deals", "fields list people --json").action(async (kind, words, opts, command) => {
450
+ if (!fieldKinds.includes(kind))
451
+ throw new CliError("validation_error", `fields are listed for ${fieldKinds.join(", ")}; got ${kind}`, {
452
+ reason: "field.unknown-kind",
453
+ allowed: fieldKinds,
454
+ });
455
+ queryTerms(words, FIELDS_QUERY, "fields list");
456
+ await within(command, async (run) => {
457
+ const found = await run.store.crm.fields(run.account, FIELD_KINDS[kind]);
458
+ const page = opts.page ?? 1;
459
+ const offset = opts.all ? 0 : (page - 1) * opts.limit;
460
+ const end = opts.all ? found.length : offset + opts.limit;
461
+ return listOf(pathOf(command).join(" "), {
462
+ items: found.slice(offset, end).map((field) => ({ ...field, untrusted: FIELD_UNTRUSTED })),
463
+ limit: opts.all ? Math.max(1, found.length) : opts.limit,
464
+ page,
465
+ has_more: found.length > end,
466
+ ...(words.length ? { args: { query: words } } : {}),
467
+ });
468
+ });
469
+ });
411
470
  const search = program.commands.find((one) => one.name() === "search") ??
412
471
  annotate(program.command("search"), { effects: NO_EFFECTS }).description("search what the local store holds");
413
472
  addCrmSearchAll(search, { within, app: options.app, example });
@@ -1,4 +1,4 @@
1
1
  export type { CrmChanges, CrmIdPage, CrmPage, CrmSource, CrmSourceTraits } from "./source.js";
2
2
  export { type CrmCursor, type CrmSyncIssue, type CrmSyncOptions, type CrmSyncReport, type CrmSyncResult, crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
3
- export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, type CrmDraft, type CrmFields, type CrmVerb, type CrmWriteCapabilities, type CrmWriteOptions, type CrmWriter, } from "./writer.js";
3
+ export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, type CrmDraft, type CrmFields, type CrmVerb, type CrmWriteCapabilities, type CrmWriteOptions, type CrmWriter, crmAlsoChecks, } from "./writer.js";
4
4
  export { type CrmFieldsInput, type CrmWriteContext, type CrmWrites, type CrmWritten, closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "./writes.js";
package/dist/crm/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
2
- export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, } from "./writer.js";
2
+ export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, crmAlsoChecks, } from "./writer.js";
3
3
  export { closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "./writes.js";
@@ -1,4 +1,4 @@
1
- import type { CrmKind, CrmPipeline, CrmRecords } from "../domain/crm.js";
1
+ import type { CrmField, CrmKind, CrmPipeline, CrmRecords } from "../domain/crm.js";
2
2
  import type { DealKind } from "../domain/deals.js";
3
3
  export interface CrmSourceTraits {
4
4
  /** What this CRM has: one without leads leaves `lead` out. Contacts and companies come before the items on them. */
@@ -38,6 +38,11 @@ export interface CrmSource {
38
38
  * reads by its own modified time, with an overlap, and gives that time back as the cursor.
39
39
  */
40
40
  changes<K extends CrmKind>(kind: K, cursor: string | null, page?: string): Promise<CrmChanges<CrmRecords[K]>>;
41
+ /**
42
+ * The CRM's own fields on one kind, in its order: what the codes in a row's `metadata` mean. Read every sync; a CRM
43
+ * without custom fields leaves it out, and one that has none on a kind answers `[]`.
44
+ */
45
+ fields?(kind: CrmKind): Promise<CrmField[]>;
41
46
  /** Every id the CRM holds of the kind, a page at a time: how deletions are found when `removedInChanges` is false. */
42
47
  ids?(kind: CrmKind, page?: string): Promise<CrmIdPage>;
43
48
  }
@@ -87,10 +87,11 @@ export interface CrmSyncReport {
87
87
  hint?: string;
88
88
  }
89
89
  /**
90
- * Brings one CRM account's copy up to date: both kinds' pipelines, then contacts, companies, deals and leads, each
91
- * from its cursor or listed in full, a page at a time, each page saved with the round's place in the cursor, so a
92
- * run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or from a list of
93
- * every id that marks only after its last page arrived, and never more than half of a kind without `--rescan`.
90
+ * Brings one CRM account's copy up to date: both kinds' pipelines and each kind's fields, then contacts, companies,
91
+ * deals and leads, each from its cursor or listed in full, a page at a time, each page saved with the round's place in
92
+ * the cursor, so a run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or
93
+ * from a list of every id that marks only after its last page arrived, and never more than half of a kind without
94
+ * `--rescan`.
94
95
  */
95
96
  export declare const syncCrm: (source: CrmSource, store: MessageStore, account: AccountKey, options?: CrmSyncOptions) => Promise<CrmSyncReport>;
96
97
  /** Each kind's sync in the WC-LIFE-14 shape: `cursor_age_ms` growing on a `partial` one means nothing moves it. */
package/dist/crm/sync.js CHANGED
@@ -57,6 +57,16 @@ const readPipelines = async (run, kind) => {
57
57
  run.report.issues.push({ kind, ...one });
58
58
  return null;
59
59
  };
60
+ const readFields = async (run, kind) => {
61
+ const why = run.budget.take();
62
+ if (why)
63
+ return why;
64
+ const fields = await run.source.fields.call(run.source, kind);
65
+ const { skipped } = await run.store.crm.saveFields(run.account, kind, fields);
66
+ for (const one of skipped)
67
+ run.report.issues.push({ kind, ...one });
68
+ return null;
69
+ };
60
70
  /** One page saved; an item whose stage is unknown gets one more chance after the kind's pipelines are read again. */
61
71
  const savePage = async (run, kind, page) => {
62
72
  const { store, account } = run;
@@ -185,10 +195,11 @@ const syncKind = async (run, kind, options, result) => {
185
195
  return null;
186
196
  };
187
197
  /**
188
- * Brings one CRM account's copy up to date: both kinds' pipelines, then contacts, companies, deals and leads, each
189
- * from its cursor or listed in full, a page at a time, each page saved with the round's place in the cursor, so a
190
- * run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or from a list of
191
- * every id that marks only after its last page arrived, and never more than half of a kind without `--rescan`.
198
+ * Brings one CRM account's copy up to date: both kinds' pipelines and each kind's fields, then contacts, companies,
199
+ * deals and leads, each from its cursor or listed in full, a page at a time, each page saved with the round's place in
200
+ * the cursor, so a run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or
201
+ * from a list of every id that marks only after its last page arrived, and never more than half of a kind without
202
+ * `--rescan`.
192
203
  */
193
204
  export const syncCrm = async (source, store, account, options = {}) => {
194
205
  const now = options.now ?? Date.now();
@@ -225,6 +236,13 @@ export const syncCrm = async (source, store, account, options = {}) => {
225
236
  if (options.signal?.aborted)
226
237
  stopped = "cancelled";
227
238
  }
239
+ if (run.source.fields)
240
+ for (const kind of kinds)
241
+ if (!stopped) {
242
+ stopped = await readFields(run, kind);
243
+ if (options.signal?.aborted)
244
+ stopped = "cancelled";
245
+ }
228
246
  for (const kind of kinds) {
229
247
  const result = {
230
248
  kind,
@@ -1,4 +1,4 @@
1
- export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, type FakeCrmSource, type FakeCrmSourceOptions, type FakeCrmWrite, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
1
+ export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, type FakeCrmSource, type FakeCrmSourceOptions, type FakeCrmWrite, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, SOURCE_FIELD, } from "./source.js";
2
2
  export { type CrmSourceFixture, crmSourceContract } from "./source-contract.js";
3
3
  export { CRM_TRAIT_COMBINATIONS, type CrmSyncFixture, crmSyncContract } from "./sync-contract.js";
4
4
  export { type CrmWriteFixture, crmWriteContract } from "./write-contract.js";
@@ -1,4 +1,4 @@
1
- export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
1
+ export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, SOURCE_FIELD, } from "./source.js";
2
2
  export { crmSourceContract } from "./source-contract.js";
3
3
  export { CRM_TRAIT_COMBINATIONS, crmSyncContract } from "./sync-contract.js";
4
4
  export { crmWriteContract } from "./write-contract.js";
@@ -51,6 +51,34 @@ export const crmSourceContract = (make) => {
51
51
  }
52
52
  },
53
53
  },
54
+ {
55
+ name: "fields: unique codes in each kind, a type, yes-or-no flags, and a list field's options with unique ids",
56
+ async run() {
57
+ const { source } = await make();
58
+ if (!source.fields)
59
+ return;
60
+ for (const kind of source.traits.kinds) {
61
+ const fields = await source.fields(kind);
62
+ assert.ok(Array.isArray(fields), `${kind}: fields() answers a list`);
63
+ const codes = new Set();
64
+ for (const field of fields) {
65
+ assert.ok(typeof field.code === "string" && field.code.trim(), `${kind}: a field has a code`);
66
+ assert.ok(!codes.has(field.code), `${kind} field ${field.code} is listed twice`);
67
+ codes.add(field.code);
68
+ assert.ok(field.label === null || typeof field.label === "string", `${field.code}: a label or null`);
69
+ assert.ok(typeof field.type === "string" && field.type.trim(), `${field.code} has a type`);
70
+ assert.equal(typeof field.multiple, "boolean", `${field.code}: multiple is true or false`);
71
+ assert.equal(typeof field.filterable, "boolean", `${field.code}: filterable is true or false`);
72
+ if (field.options === undefined)
73
+ continue;
74
+ const ids = new Set(field.options.map(({ id }) => id));
75
+ assert.equal(ids.size, field.options.length, `${field.code}: option ids are unique`);
76
+ for (const option of field.options)
77
+ assert.ok(typeof option.id === "string" && typeof option.label === "string", `${field.code}'s options`);
78
+ }
79
+ }
80
+ },
81
+ },
54
82
  {
55
83
  name: "changes: a full list pages to its end, every item stands in a listed stage, fields in the neutral shape",
56
84
  async run() {
@@ -1,4 +1,4 @@
1
- import type { CrmItem, CrmKind, CrmOrganization, CrmPerson, CrmPipeline, CrmRecords } from "../../domain/crm.js";
1
+ import type { CrmField, CrmItem, CrmKind, CrmOrganization, CrmPerson, CrmPipeline, CrmRecords } from "../../domain/crm.js";
2
2
  import type { DealKind } from "../../domain/deals.js";
3
3
  import type { CrmSource, CrmSourceTraits } from "../source.js";
4
4
  import { type CrmVerb, type CrmWriteCapabilities, type CrmWriter } from "../writer.js";
@@ -27,15 +27,20 @@ export interface FakeCrmSource extends CrmSource, CrmWriter {
27
27
  /** Deletes a row: named in `removed` when `removedInChanges`, otherwise only missing from `ids()`. */
28
28
  delete(kind: CrmKind, externalId: string): void;
29
29
  setPipelines(kind: DealKind, pipelines: CrmPipeline[]): void;
30
+ /** The kind's own fields from the next `fields()` on: deals have `SOURCE_FIELD` and the rest none until set. */
31
+ setFields(kind: CrmKind, fields: CrmField[]): void;
30
32
  /** The kind's cursors older than its latest change are refused, once, with `{ reset: true }`. */
31
33
  expireCursors(kind: CrmKind): void;
32
34
  /** Lists no id at all, as a CRM does for a sign-in that lost its rights. */
33
35
  hideAll(hidden: boolean): void;
34
36
  /** The next write is carried out and its answer lost: it throws `outcome_unknown`. */
35
37
  loseNextAnswer(): void;
38
+ /** The next write is carried out and its item cannot be read back: it throws with `sent: true`. */
39
+ failNextReadBack(): void;
36
40
  /** Requests made, by method. */
37
41
  readonly calls: {
38
42
  pipelines: number;
43
+ fields: number;
39
44
  changes: number;
40
45
  ids: number;
41
46
  };
@@ -45,6 +50,8 @@ export interface FakeCrmSource extends CrmSource, CrmWriter {
45
50
  /** Custom codes the fake refuses: each is a neutral field under another name. */
46
51
  export declare const FAKE_CRM_ALIASES: string[];
47
52
  export declare const SALES_PIPELINE: CrmPipeline;
53
+ /** A deal's list field, as a CRM lists one: what the fake's deals have. */
54
+ export declare const SOURCE_FIELD: CrmField;
48
55
  export declare const LEAD_PIPELINE: CrmPipeline;
49
56
  export declare const fakeCrmSource: (options?: FakeCrmSourceOptions) => FakeCrmSource;
50
57
  export declare const exampleDeal: (externalId: string, extra?: Partial<CrmItem>) => CrmItem;
@@ -14,6 +14,18 @@ export const SALES_PIPELINE = {
14
14
  { externalId: "lost", name: "Lost", position: 4, outcome: "lost" },
15
15
  ],
16
16
  };
17
+ /** A deal's list field, as a CRM lists one: what the fake's deals have. */
18
+ export const SOURCE_FIELD = {
19
+ code: "UF_SOURCE",
20
+ label: "Source",
21
+ type: "enumeration",
22
+ multiple: false,
23
+ filterable: true,
24
+ options: [
25
+ { id: "1", label: "Website" },
26
+ { id: "2", label: "Fair" },
27
+ ],
28
+ };
17
29
  export const LEAD_PIPELINE = {
18
30
  externalId: "lead-1",
19
31
  name: "Example leads",
@@ -32,10 +44,11 @@ export const fakeCrmSource = (options = {}) => {
32
44
  const size = options.pageSize ?? 2;
33
45
  const rows = new Map();
34
46
  const pipelines = { deal: [SALES_PIPELINE], lead: [LEAD_PIPELINE] };
47
+ const fields = { deal: [SOURCE_FIELD], lead: [], person: [], organization: [] };
35
48
  let version = 0;
36
49
  const expired = new Map();
37
50
  let hidden = false;
38
- const calls = { pipelines: 0, changes: 0, ids: 0 };
51
+ const calls = { pipelines: 0, fields: 0, changes: 0, ids: 0 };
39
52
  const capabilities = {
40
53
  kinds: ["deal", "lead"],
41
54
  verbs: CRM_VERBS,
@@ -46,6 +59,7 @@ export const fakeCrmSource = (options = {}) => {
46
59
  const answered = new Map();
47
60
  let made = 0;
48
61
  let lose = false;
62
+ let unread = false;
49
63
  const of = (kind) => {
50
64
  const held = rows.get(kind) ?? new Map();
51
65
  rows.set(kind, held);
@@ -69,18 +83,32 @@ export const fakeCrmSource = (options = {}) => {
69
83
  throw new CliError("validation_error", `the CRM has no stage ${stageId} there`);
70
84
  return { pipeline: pipeline.externalId, stage: stage.externalId, outcome: stage.outcome };
71
85
  };
72
- const applied = (item, fields) => {
73
- const { custom, owner, pipeline, stage, ...named } = fields;
74
- const metadata = {
75
- ...item.metadata,
76
- ...(owner === undefined ? {} : { owner }),
77
- ...(custom ? { custom: { ...item.metadata?.custom, ...custom } } : {}),
86
+ /** A custom value as a CRM answers it: by code, with the field's label and a list option's label. */
87
+ const customValue = (kind, code, value) => {
88
+ const field = fields[kind].find((one) => one.code === code);
89
+ const label = (id) => field?.options?.find((option) => option.id === id)?.label ?? id;
90
+ if (!field?.options)
91
+ return { label: field?.label ?? null, value };
92
+ return {
93
+ label: field.label,
94
+ value: Array.isArray(value) ? value.map(label) : label(value),
95
+ option_id: value,
78
96
  };
97
+ };
98
+ const applied = (kind, item, given) => {
99
+ const { custom, owner, pipeline, stage, ...named } = given;
100
+ const metadata = { ...item.metadata };
101
+ for (const [code, value] of Object.entries(custom ?? {}))
102
+ if (value === null)
103
+ delete metadata[code];
104
+ else
105
+ metadata[code] = customValue(kind, code, value);
79
106
  return {
80
107
  ...item,
81
108
  ...Object.fromEntries(Object.entries(named).filter(([, value]) => value !== undefined)),
82
109
  ...(pipeline === undefined ? {} : { pipeline }),
83
110
  ...(stage === undefined ? {} : { stage }),
111
+ ...(owner === undefined ? {} : { assignee: owner }),
84
112
  metadata,
85
113
  updatedAt: null,
86
114
  };
@@ -102,6 +130,12 @@ export const fakeCrmSource = (options = {}) => {
102
130
  lose = false;
103
131
  throw new CliError("outcome_unknown", "the CRM did not answer");
104
132
  }
133
+ if (unread) {
134
+ unread = false;
135
+ throw new CliError("provider_error", `the CRM made the ${verb} but its ${kind} could not be read back`, {
136
+ sent: true,
137
+ });
138
+ }
105
139
  return done;
106
140
  };
107
141
  const saved = (kind, item) => {
@@ -117,13 +151,16 @@ export const fakeCrmSource = (options = {}) => {
117
151
  loseNextAnswer() {
118
152
  lose = true;
119
153
  },
154
+ failNextReadBack() {
155
+ unread = true;
156
+ },
120
157
  async create(kind, draft, { operationId }) {
121
158
  refuseAliases(draft);
122
159
  return write("create", kind, null, operationId, () => {
123
160
  const { pipeline, stage } = placed(kind, draft.pipeline, draft.stage);
124
161
  made += 1;
125
162
  const blank = { externalId: `${kind}-new-${made}`, title: draft.title, pipeline, stage };
126
- return saved(kind, applied(blank, { ...draft, pipeline, stage }));
163
+ return saved(kind, applied(kind, blank, { ...draft, pipeline, stage }));
127
164
  });
128
165
  },
129
166
  async update(kind, externalId, patch, { operationId }) {
@@ -131,7 +168,7 @@ export const fakeCrmSource = (options = {}) => {
131
168
  return write("update", kind, externalId, operationId, () => {
132
169
  const item = held(kind, externalId);
133
170
  const at = patch.stage === undefined ? {} : placed(kind, patch.pipeline ?? item.pipeline, patch.stage);
134
- return saved(kind, applied(item, { ...patch, ...at }));
171
+ return saved(kind, applied(kind, item, { ...patch, ...at }));
135
172
  });
136
173
  },
137
174
  async move(kind, externalId, stage, { operationId }) {
@@ -179,6 +216,13 @@ export const fakeCrmSource = (options = {}) => {
179
216
  setPipelines(kind, list) {
180
217
  pipelines[kind] = list;
181
218
  },
219
+ setFields(kind, list) {
220
+ fields[kind] = list;
221
+ },
222
+ async fields(kind) {
223
+ calls.fields += 1;
224
+ return fields[kind];
225
+ },
182
226
  expireCursors(kind) {
183
227
  expired.set(kind, version);
184
228
  },
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { runBudget } from "../../background/budget.js";
3
3
  import { syncCrm } from "../sync.js";
4
- import { exampleDeal, exampleLead, exampleOrganization, examplePerson, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
4
+ import { exampleDeal, exampleLead, exampleOrganization, examplePerson, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, SOURCE_FIELD, } from "./source.js";
5
5
  /** Both ways a CRM may report deletions: the engine must handle each. */
6
6
  export const CRM_TRAIT_COMBINATIONS = [
7
7
  { removedInChanges: false },
@@ -68,7 +68,7 @@ export const crmSyncContract = (make) => CRM_TRAIT_COMBINATIONS.flatMap((traits)
68
68
  await sync();
69
69
  source.delete("deal", "d1");
70
70
  now += DAY;
71
- const stopped = await sync({ budget: runBudget({ requests: 7 }) });
71
+ const stopped = await sync({ budget: runBudget({ requests: 11 }) });
72
72
  assert.equal(stopped.state, "partial");
73
73
  assert.equal(stopped.hint, "run sync again to continue");
74
74
  assert.deepEqual(await deals(), ["d1", "d2", "d3", "d4", "d5"]);
@@ -90,6 +90,17 @@ export const crmSyncContract = (make) => CRM_TRAIT_COMBINATIONS.flatMap((traits)
90
90
  await sync({ rescan: true });
91
91
  assert.deepEqual(await deals(), []);
92
92
  }),
93
+ test("stores each kind's own fields, and marks deleted one the CRM no longer lists", async ({ source, sync, store, account, }) => {
94
+ const note = { code: "UF_NOTE", label: "Note", type: "string", multiple: true, filterable: false };
95
+ source.setFields("person", [note]);
96
+ await sync();
97
+ assert.deepEqual(await store.crm.fields(account, "deal"), [SOURCE_FIELD]);
98
+ assert.deepEqual(await store.crm.fields(account, "person"), [note]);
99
+ source.setFields("deal", []);
100
+ await sync();
101
+ assert.deepEqual(await store.crm.fields(account, "deal"), []);
102
+ assert.deepEqual(await store.crm.fields(account, "person"), [note]);
103
+ }),
93
104
  test("a dead cursor starts a full round, marked reset", async ({ source, sync, deals }) => {
94
105
  seeded(source);
95
106
  await sync();
@@ -103,7 +114,7 @@ export const crmSyncContract = (make) => CRM_TRAIT_COMBINATIONS.flatMap((traits)
103
114
  }),
104
115
  test("a run its budget stops saves its pages and the next goes on from the page it stopped at", async ({ source, sync, deals, }) => {
105
116
  seeded(source, 9);
106
- const stopped = await sync({ budget: runBudget({ requests: 6 }) });
117
+ const stopped = await sync({ budget: runBudget({ requests: 10 }) });
107
118
  assert.equal(stopped.state, "partial");
108
119
  assert.equal(stopped.stopped, "requests");
109
120
  const kept = (await deals()).length;
@@ -1,3 +1,4 @@
1
+ import type { ConditionalKey } from "../cli/permission-keys.js";
1
2
  import type { CrmItem } from "../domain/crm.js";
2
3
  import type { DealKind, DealRole } from "../domain/deals.js";
3
4
  import type { Level, PermissionKey } from "../sends/permissions.js";
@@ -35,7 +36,8 @@ export interface CrmFields {
35
36
  }[];
36
37
  /** The CRM user it is assigned to: a user's id, never a contact's. */
37
38
  owner?: string | null;
38
- custom?: Record<string, string | null>;
39
+ /** A list field's option by its id; a multiple field's values as a list; `null` clears it. */
40
+ custom?: Record<string, string | string[] | null>;
39
41
  }
40
42
  export type CrmDraft = CrmFields & {
41
43
  title: string;
@@ -47,7 +49,8 @@ export interface CrmWriteOptions {
47
49
  /**
48
50
  * Writes to one CRM account. Each answers the whole item as the CRM holds it after the write, every field and not
49
51
  * only the changed ones: the store replaces its copy with it. None of them retries: a write whose answer was lost
50
- * throws `outcome_unknown`.
52
+ * throws `outcome_unknown`. A write the CRM made whose item could then not be read back throws with `sent: true` in
53
+ * its details: it is journaled as sent, and the read's failure is answered on its own.
51
54
  */
52
55
  export interface CrmWriter {
53
56
  readonly provider: string;
@@ -69,6 +72,12 @@ export interface CrmWriter {
69
72
  export declare const CRM_ASSIGN_OTHERS_KEY: PermissionKey;
70
73
  /** The keys the CRM writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
71
74
  export declare const CRM_WRITE_KEYS: readonly PermissionKey[];
75
+ /**
76
+ * Every key a CRM write command may check beyond its own path, for `commandPermissions({ alsoChecks })`, so its
77
+ * `--dry-run` names each: a stage implies the move key and a won or lost one the close key, an owner other than the
78
+ * sign-in's `crm.assign-others`, and `also` the CLI's own (the same `CrmWrites.alsoChecks` the real run checks).
79
+ */
80
+ export declare const crmAlsoChecks: (also?: (kind: DealKind, verb: CrmVerb) => readonly PermissionKey[]) => (path: readonly string[]) => readonly (PermissionKey | ConditionalKey)[];
72
81
  /**
73
82
  * Built in, and only ever tightening (WC-PERM-7): an ordinary change goes ahead, a deletion asks, and a record is not
74
83
  * given to somebody else until the owner allows it.