@wirecat/cli-core 0.590.0 → 0.592.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 (61) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/background/deliver.d.ts +58 -0
  3. package/dist/background/deliver.js +125 -0
  4. package/dist/background/schedules.d.ts +20 -1
  5. package/dist/background/schedules.js +48 -5
  6. package/dist/background/system.d.ts +9 -2
  7. package/dist/background/system.js +10 -3
  8. package/dist/calendar/mcp/index.d.ts +12 -12
  9. package/dist/cli/messenger/flooded.d.ts +1 -1
  10. package/dist/cli/messenger/flooded.js +3 -26
  11. package/dist/cli/messenger/search-command.d.ts +3 -2
  12. package/dist/cli/messenger/search-command.js +7 -5
  13. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  14. package/dist/cli/schedule/schedule-command.js +35 -12
  15. package/dist/crm/cli/index.d.ts +57 -0
  16. package/dist/crm/cli/index.js +300 -0
  17. package/dist/crm/cli/search-all.d.ts +12 -0
  18. package/dist/crm/cli/search-all.js +63 -0
  19. package/dist/crm/index.d.ts +2 -0
  20. package/dist/crm/index.js +1 -0
  21. package/dist/crm/source.d.ts +43 -0
  22. package/dist/crm/source.js +1 -0
  23. package/dist/crm/sync.d.ts +121 -0
  24. package/dist/crm/sync.js +291 -0
  25. package/dist/crm/testing/index.d.ts +3 -0
  26. package/dist/crm/testing/index.js +3 -0
  27. package/dist/crm/testing/source-contract.d.ts +17 -0
  28. package/dist/crm/testing/source-contract.js +137 -0
  29. package/dist/crm/testing/source.d.ts +33 -0
  30. package/dist/crm/testing/source.js +126 -0
  31. package/dist/crm/testing/sync-contract.d.ts +13 -0
  32. package/dist/crm/testing/sync-contract.js +178 -0
  33. package/dist/domain/crm.d.ts +82 -0
  34. package/dist/domain/crm.js +3 -0
  35. package/dist/entities/deal.d.ts +159 -0
  36. package/dist/entities/deal.js +86 -0
  37. package/dist/entities/index.d.ts +150 -0
  38. package/dist/entities/index.js +5 -0
  39. package/dist/entities/list-results.d.ts +13 -0
  40. package/dist/entities/list-results.js +17 -1
  41. package/dist/errors/reasons.js +5 -0
  42. package/dist/index.d.ts +1 -0
  43. package/dist/index.js +1 -0
  44. package/dist/sends/index.d.ts +1 -1
  45. package/dist/sends/index.js +1 -1
  46. package/dist/sends/pace.d.ts +18 -0
  47. package/dist/sends/pace.js +40 -1
  48. package/dist/services/index.d.ts +1 -0
  49. package/dist/services/index.js +1 -0
  50. package/dist/store/index.d.ts +2 -1
  51. package/dist/store/index.js +1 -0
  52. package/dist/store/sqlite/crm.d.ts +48 -0
  53. package/dist/store/sqlite/crm.js +318 -0
  54. package/dist/store/sqlite/deals.d.ts +10 -0
  55. package/dist/store/sqlite/deals.js +33 -15
  56. package/dist/store/sqlite/email-activity.d.ts +3 -0
  57. package/dist/store/sqlite/email-activity.js +11 -0
  58. package/dist/store/store.d.ts +3 -0
  59. package/dist/store/store.js +5 -0
  60. package/package.json +13 -1
  61. package/parity.json +29 -1
@@ -2,14 +2,15 @@ import { Command } from "commander";
2
2
  import type { SearchAllIncludingMeetingsFound } from "../../services/search-all-meetings.js";
3
3
  import { type Messenger } from "./context.js";
4
4
  import { type SourceSearched } from "./search-answers.js";
5
- export declare const searchAllOnlyOf: (value: string) => SourceSearched[];
5
+ export declare const searchAllOnlyOf: (value: string, allowed?: readonly string[]) => SourceSearched[];
6
6
  /** One `search all` hit as a person reads it. */
7
7
  export declare const searchAllLine: (item: SearchAllIncludingMeetingsFound["items"][number]) => string;
8
8
  /**
9
9
  * `search all`'s scope, paging and matching options, the same in every CLI that mounts it (WC-ACCT-8): a messenger's
10
10
  * and a calendar's.
11
11
  */
12
- export declare const searchAllOptions: (command: Command) => Command;
12
+ /** `allowed`: the resources `--only` takes, a CRM CLI's with `crm`. */
13
+ export declare const searchAllOptions: (command: Command, allowed?: readonly string[]) => Command;
13
14
  /**
14
15
  * `search <resource>`: every search of the tool, one leaf per resource (STANDARD.md, "Search
15
16
  * hierarchy"). `topics` only where the CLI has forum topics.
@@ -16,13 +16,13 @@ import { messagesSearchCommand } from "./messages-search-command.js";
16
16
  import { allAnswer, meaningNote, notesAnswer, offsetOf, SOURCE_SEARCHED, shownOf, } from "./search-answers.js";
17
17
  import { backendOptions, backendRequest, noteServer } from "./search-backend-options.js";
18
18
  import { topicsSearchCommand } from "./topics-command.js";
19
- export const searchAllOnlyOf = (value) => {
19
+ export const searchAllOnlyOf = (value, allowed = SOURCE_SEARCHED) => {
20
20
  const chosen = value.split(",").map((one) => one.trim());
21
21
  if (chosen.includes("mail"))
22
22
  throw new CliError("validation_error", "mail is searched by the separate memo CLI: install @wirecat/cli-memo, then `memo search mail …`");
23
- const unknown = chosen.find((one) => !SOURCE_SEARCHED.includes(one));
23
+ const unknown = chosen.find((one) => !allowed.includes(one));
24
24
  if (unknown !== undefined || chosen.length === 0)
25
- throw new CliError("validation_error", `--only takes ${SOURCE_SEARCHED.join(", ")}, separated by commas; meetings when --source names a meeting account`);
25
+ throw new CliError("validation_error", `--only takes ${allowed.join(", ")}, separated by commas; meetings when --source names a meeting account`);
26
26
  return [...new Set(chosen)];
27
27
  };
28
28
  /** One `search all` hit as a person reads it. */
@@ -40,8 +40,10 @@ const maxMeetingsOf = (value) => {
40
40
  * `search all`'s scope, paging and matching options, the same in every CLI that mounts it (WC-ACCT-8): a messenger's
41
41
  * and a calendar's.
42
42
  */
43
- export const searchAllOptions = (command) => command
44
- .option("--only <resources>", "only these resources, separated by commas: messages, notes, events; meetings when --source names a meeting account", searchAllOnlyOf)
43
+ /** `allowed`: the resources `--only` takes, a CRM CLI's with `crm`. */
44
+ export const searchAllOptions = (command, allowed = SOURCE_SEARCHED) => command
45
+ .option("--only <resources>", `only these resources, separated by commas: ${allowed.filter((one) => one !== "meetings").join(", ")}; ` +
46
+ "meetings when --source names a meeting account", (value) => searchAllOnlyOf(value, allowed))
45
47
  .option("--source <accounts>", "only these accounts, separated by commas: provider:account as a hit shows it, a provider, a kind or " +
46
48
  "group:<name>; each resource reads the ones of its kind, and meetings only when it names one")
47
49
  .option("--max-meetings <n>", "how many of the meeting account's meetings to look through, newest first, or all for every one (default 100)", maxMeetingsOf)
@@ -3,9 +3,9 @@ import type { CacheDatabase } from "../../store/driver.js";
3
3
  import type { Messenger } from "./context.js";
4
4
  /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
5
5
  export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
6
- error?: undefined;
7
6
  path: string;
8
7
  exists: boolean;
8
+ error?: undefined;
9
9
  } | {
10
10
  path: string;
11
11
  exists: boolean;
@@ -3,7 +3,8 @@ import { dirname, join } from "node:path";
3
3
  import { CliError, resolvePaths, writeSecurely } from "@wirecat/cli-base";
4
4
  import { annotate } from "@wirecat/cli-base/commands";
5
5
  import { Argument, Command, Option } from "commander";
6
- import { describeWhen, findProgram, lastRun, listSchedules, readSchedule, recordSkip, runSchedule, SCHEDULE_DEFAULTS, scheduleFile, scheduleName, spentToday, timerFor, } from "../../background/schedules.js";
6
+ import { parseRoute, ROUTE_HELP, routeText } from "../../background/deliver.js";
7
+ import { describeWhen, findProgram, lastRun, listSchedules, NOTIFY_WHEN, notifyOf, readSchedule, recordSkip, runSchedule, SCHEDULE_DEFAULTS, SKIP_REASONS, scheduleFile, scheduleName, spentToday, timerFor, } from "../../background/schedules.js";
7
8
  import { thisMachine } from "../../background/system.js";
8
9
  import { locationVariables } from "../../background/units.js";
9
10
  import { parseDuration } from "../../domain/duration.js";
@@ -79,7 +80,14 @@ const scheduleAnswer = ({ environment, createdAt, ...schedule }, extra) => ({
79
80
  const withWhen = (command) => command
80
81
  .option("--every <duration>", "repeat: 30m, 2h, 1d")
81
82
  .option("--at <HH:MM>", "every day at this local time")
82
- .option("--at-time <time>", "one run: 30m, 2d from now, or an ISO time; the schedule removes itself after it");
83
+ .option("--at-time <time>", "one run: 30m, 2d from now, or an ISO time; the schedule removes itself after it")
84
+ .addOption(new Option("--notify-on <when>", "desktop notice after a run (default failures for an agent that repeats with --every, else always)").choices(NOTIFY_WHEN))
85
+ .option("--to <route>", `also send the result, when --notify-on says so, by the owner's own tg or mail: ${ROUTE_HELP}; repeat it for more`, (route, routes = []) => [...routes, routeText(parseRoute(route))]);
86
+ /** What a schedule keeps of `--notify-on` and `--to`. */
87
+ const deliveryOf = ({ notifyOn, to }) => ({
88
+ ...(notifyOn ? { notify: notifyOn } : {}),
89
+ ...(to?.length ? { to } : {}),
90
+ });
83
91
  /** What the schedule command needs of a CLI: who it is and, per command, where it prints and which profile runs. */
84
92
  /** `skill show follow-ups` exists only for messenger CLIs; any other CLI gets a read-only summary it can run. */
85
93
  const promptExample = (app) => app.messenger === false
@@ -209,8 +217,8 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
209
217
  const spent = spentToday(file);
210
218
  if (spent >= maxCostPerDay) {
211
219
  const why = `today's runs cost ${usd(spent)}, the ${usd(maxCostPerDay)} a day allowed; it runs again tomorrow`;
212
- recordSkip(file, why);
213
- return why;
220
+ recordSkip(file, why, SKIP_REASONS.costDayCap);
221
+ return { why, reason: SKIP_REASONS.costDayCap };
214
222
  }
215
223
  }
216
224
  const holder = schedule.onNewMail;
@@ -219,13 +227,18 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
219
227
  const waiting = await newMail(self, holder);
220
228
  if (waiting.unmarked)
221
229
  return undefined;
222
- if (waiting.emails === 0)
223
- return `no mail stored past ${holder}'s mark`;
230
+ if (waiting.emails === 0) {
231
+ const why = `no mail stored past ${holder}'s mark`;
232
+ recordSkip(file, why, SKIP_REASONS.mailNone);
233
+ return { why, reason: SKIP_REASONS.mailNone };
234
+ }
224
235
  const waited = waiting.oldestStoredAt === null ? 0 : Date.now() - waiting.oldestStoredAt;
225
236
  if (!timed || waiting.emails >= mailBatch || waited >= mailWaitMinutes * 60_000)
226
237
  return undefined;
227
- return (`${waiting.emails} email(s) wait past ${holder}'s mark; it starts at ${mailBatch}, ` +
228
- `or once the oldest has waited ${mailWaitMinutes}m`);
238
+ const why = `${waiting.emails} email(s) wait past ${holder}'s mark; it starts at ${mailBatch}, ` +
239
+ `or once the oldest has waited ${mailWaitMinutes}m`;
240
+ recordSkip(file, why, SKIP_REASONS.mailBelowThreshold);
241
+ return { why, reason: SKIP_REASONS.mailBelowThreshold };
229
242
  };
230
243
  withWhen(grantsAuthority(annotate(command.command("command"), {
231
244
  literal_after_dashes: true,
@@ -248,6 +261,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
248
261
  when: whenOf(this.opts(), COMMAND_MINIMUM_MINUTES),
249
262
  environment: environment(),
250
263
  createdAt: new Date().toISOString(),
264
+ ...deliveryOf(this.opts()),
251
265
  });
252
266
  });
253
267
  withWhen(grantsAuthority(annotate(command.command("agent"), {
@@ -329,6 +343,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
329
343
  ...(mailWaitMinutes === undefined ? {} : { mailWaitMinutes }),
330
344
  ...(mailBatch === undefined ? {} : { mailBatch }),
331
345
  ...(options.maxCostPerDay === undefined ? {} : { maxCostPerDay: options.maxCostPerDay }),
346
+ ...deliveryOf(options),
332
347
  });
333
348
  });
334
349
  if (newMail)
@@ -349,6 +364,8 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
349
364
  name: schedule.name,
350
365
  kind: schedule.kind,
351
366
  when: describeWhen(schedule.when),
367
+ notify: notifyOf(schedule),
368
+ ...(schedule.to?.length ? { to: schedule.to } : {}),
352
369
  last: lastRun(scheduleFile(state, schedule.name)) ?? null,
353
370
  ...(schedule.kind === "agent"
354
371
  ? {
@@ -369,10 +386,12 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
369
386
  return "";
370
387
  }
371
388
  return rows
372
- .map(({ name, kind, when, last, spentTodayUsd, maxCostPerDayUsd }) => [
389
+ .map(({ name, kind, when, notify, to, last, spentTodayUsd, maxCostPerDayUsd }) => [
373
390
  name,
374
391
  kind,
375
392
  when,
393
+ `notify ${notify}`,
394
+ ...(to ? [`to ${to.join(", ")}`] : []),
376
395
  last
377
396
  ? `last ${last.at.slice(0, 16).replace("T", " ")} exit ${last.exitCode}` +
378
397
  (last.failures
@@ -386,6 +405,9 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
386
405
  ...(last?.skipped
387
406
  ? [`skipped ${last.skipped.at.slice(0, 16).replace("T", " ")}: ${last.skipped.why}`]
388
407
  : []),
408
+ ...(last?.deliveries ?? [])
409
+ .filter((one) => !one.ok)
410
+ .map((one) => `not delivered to ${one.to}: ${one.reason}${one.error ? ` (${one.error})` : ""}`),
389
411
  ].join(" "))
390
412
  .join("\n");
391
413
  });
@@ -398,9 +420,10 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
398
420
  .argument("<name>", "the schedule's name, as `schedule list` shows it")
399
421
  .action(async function (name) {
400
422
  const { context, system, state, say, timer } = prepare(this);
401
- const why = await waits(this, scheduleFile(state, scheduleName(name)), { timed: false });
402
- if (why !== undefined)
403
- return say({ name, skipped: true, why }, [`${name}: skipped — ${why}`]);
423
+ const skip = await waits(this, scheduleFile(state, scheduleName(name)), { timed: false });
424
+ if (skip !== undefined) {
425
+ return say({ name, skipped: true, why: skip.why, reason: skip.reason }, [`${name}: skipped — ${skip.why}`]);
426
+ }
404
427
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
405
428
  timer: timer(),
406
429
  agentRuns,
@@ -0,0 +1,57 @@
1
+ import { type Command } from "commander";
2
+ import { type Body } from "../../cli/envelope.js";
3
+ import type { AppIdentity } from "../../ports/app.js";
4
+ import type { MessageStore } from "../../store/store.js";
5
+ import type { AccountKey } from "../../store/types.js";
6
+ import type { CrmSource } from "../source.js";
7
+ /**
8
+ * The selected profile's CRM account, opened by the app for one command and closed once its answer is built, before
9
+ * anything is printed (WC-LIFE-4).
10
+ */
11
+ export interface CrmRun {
12
+ /** The profile's name, which `store status` gives as each sync's `profile`. */
13
+ profile: string;
14
+ /** The profile's CRM account as the shared store names it: every command reads and syncs only it. */
15
+ account: AccountKey;
16
+ store: MessageStore;
17
+ /** The CRM, built on first use: a command that only reads the store never reads the sign-in. */
18
+ source: () => CrmSource;
19
+ /** A profile lock pins the process (WC-ACCT-5): `search all` then reads only this account's deals. */
20
+ locked?: boolean;
21
+ env?: NodeJS.ProcessEnv;
22
+ close?: () => Promise<void>;
23
+ }
24
+ export interface CrmCommandOptions {
25
+ /** The CLI itself: the first word of examples, and `search all`. */
26
+ app: AppIdentity;
27
+ /** Opens the selected profile for one command; `readOnly` when the command only reads the store. */
28
+ open: (command: Command, access: {
29
+ readOnly: boolean;
30
+ }) => Promise<CrmRun>;
31
+ /** The CRM has leads: mounts `leads list|show`. True when absent. */
32
+ leads?: boolean;
33
+ now?: () => number;
34
+ }
35
+ /** What `deals list` and `leads list` narrow by, the same in memo and every CRM CLI (WC-OUT-11). */
36
+ export declare const DEALS_QUERY: {
37
+ readonly entity: "deal";
38
+ readonly backend: "native";
39
+ readonly fields: import("@wirecat/cli-base/query").FieldSpec[];
40
+ };
41
+ export declare const LEADS_QUERY: {
42
+ readonly entity: "lead";
43
+ readonly backend: "native";
44
+ readonly fields: import("@wirecat/cli-base/query").FieldSpec[];
45
+ };
46
+ export declare const PIPELINES_QUERY: {
47
+ readonly entity: "pipeline";
48
+ readonly backend: "native";
49
+ readonly fields: readonly [import("@wirecat/cli-base/query").FieldSpec];
50
+ };
51
+ export declare const STAGES_QUERY: {
52
+ readonly entity: "stage";
53
+ readonly backend: "native";
54
+ readonly fields: readonly [import("@wirecat/cli-base/query").FieldSpec, import("@wirecat/cli-base/query").FieldSpec];
55
+ };
56
+ export type CrmWithin = (command: Command, work: (run: CrmRun) => Promise<Body>) => Promise<void>;
57
+ export declare const addCrmCommands: (program: Command, options: CrmCommandOptions) => void;
@@ -0,0 +1,300 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
3
+ import { InvalidArgumentError } from "commander";
4
+ import * as v from "valibot";
5
+ import { answerEntity } from "../../cli/answer-entity.js";
6
+ import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
7
+ import { honoursDryRun } from "../../cli/dry-run-guard.js";
8
+ import { FETCH, LOCAL_READ, NO_EFFECTS } from "../../cli/effects.js";
9
+ import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
10
+ import { queryTerms, termField, withQuery } from "../../cli/list-query.js";
11
+ import { lineOf } from "../../cli/steps.js";
12
+ import { CRM_KINDS } from "../../domain/crm.js";
13
+ import { DEAL_KINDS, DEAL_STATUSES } from "../../domain/deals.js";
14
+ import { count, text } from "../../entities/fields.js";
15
+ import { crmStatus, syncCrm } from "../sync.js";
16
+ import { addCrmSearchAll } from "./search-all.js";
17
+ const MOST = 500;
18
+ const DEAL_FIELDS = [
19
+ termField("status", "enum", "status:open", { values: DEAL_STATUSES }),
20
+ termField("stage", "kw", 'stage:"Quote sent"', { relation: "a stage's name, in any letter case" }),
21
+ termField("pipeline", "kw", "pipeline:Sales", { relation: "pipeline:<id>, or a name only one pipeline has" }),
22
+ termField("organization", "ref", 'organization:"organization:3"'),
23
+ ];
24
+ /** What `deals list` and `leads list` narrow by, the same in memo and every CRM CLI (WC-OUT-11). */
25
+ export const DEALS_QUERY = { entity: "deal", backend: "native", fields: DEAL_FIELDS };
26
+ export const LEADS_QUERY = { entity: "lead", backend: "native", fields: DEAL_FIELDS };
27
+ export const PIPELINES_QUERY = {
28
+ entity: "pipeline",
29
+ backend: "native",
30
+ fields: [termField("kind", "enum", "kind:lead", { values: DEAL_KINDS })],
31
+ };
32
+ export const STAGES_QUERY = {
33
+ entity: "stage",
34
+ backend: "native",
35
+ fields: [
36
+ termField("kind", "enum", "kind:deal", { values: DEAL_KINDS }),
37
+ termField("pipeline", "kw", "pipeline:Sales", { relation: "pipeline:<id>, or a name only one pipeline has" }),
38
+ ],
39
+ };
40
+ /** A CRM's users typed these; a list item names its own, as `events list` does. */
41
+ const DEAL_UNTRUSTED = ["title", "summary", "stage", "pipeline_name", "organization.name", "people.name"];
42
+ const PIPELINE_UNTRUSTED = ["name", "stages.name"];
43
+ const STAGE_UNTRUSTED = ["name", "pipeline_name"];
44
+ const crmSync = v.strictObject({
45
+ provider: text,
46
+ account: text,
47
+ pipelines: count,
48
+ synced: v.array(v.strictObject({
49
+ kind: v.picklist(CRM_KINDS),
50
+ round: v.picklist(["full", "changes"]),
51
+ reset: v.boolean(),
52
+ state: v.picklist(["partial", "complete"]),
53
+ saved: count,
54
+ removed: count,
55
+ unlisted: count,
56
+ pass: v.optional(v.picklist(["complete", "partial", "held"])),
57
+ error: v.optional(text),
58
+ })),
59
+ issues: v.array(v.strictObject({
60
+ kind: v.picklist(CRM_KINDS),
61
+ reason: text,
62
+ message: text,
63
+ external_id: v.optional(text),
64
+ })),
65
+ complete: v.boolean(),
66
+ state: v.picklist(["partial", "complete"]),
67
+ stopped: v.optional(v.picklist(["requests", "deadline", "cancelled"])),
68
+ progress: v.strictObject({ requests: count, saved: count, steps: count }),
69
+ hint: v.optional(text),
70
+ });
71
+ const crmSyncProgress = v.strictObject({
72
+ kind: v.picklist(CRM_KINDS),
73
+ round: v.nullable(v.picklist(["full", "changes"])),
74
+ id_pass: v.nullable(text),
75
+ stored: count,
76
+ job: text,
77
+ profile: text,
78
+ done: count,
79
+ total: v.nullable(count),
80
+ remaining: v.nullable(count),
81
+ state: v.picklist(["partial", "complete"]),
82
+ updated_at: v.nullable(text),
83
+ cursor_age_ms: v.nullable(count),
84
+ line: text,
85
+ });
86
+ const integer = (most) => (value) => {
87
+ if (!/^[1-9]\d*$/.test(value) || Number(value) > most)
88
+ throw new InvalidArgumentError(`expected 1 to ${most}`);
89
+ return Number(value);
90
+ };
91
+ /** The words below the program, as `page.next` and `sync`'s next step run it again. */
92
+ const pathOf = (command) => command.parent?.parent ? [...pathOf(command.parent), command.name()] : [command.name()];
93
+ const marked = (items, untrusted) => items.map((item) => ({ ...snakeKeys(item), untrusted }));
94
+ const addCrmCommandsDefinition = (program, options) => {
95
+ const now = () => options.now?.() ?? Date.now();
96
+ const within = async (command, work) => {
97
+ const run = await options.open(command, { readOnly: metaOf(command).effects?.local !== "write" });
98
+ let body;
99
+ try {
100
+ body = await work(run);
101
+ }
102
+ finally {
103
+ await run.close?.();
104
+ }
105
+ renderAnswer(command, outputFor(command), body);
106
+ };
107
+ const example = (command, ...lines) => annotate(command, { examples: lines.map((line) => `${options.app.command} work ${line}`) });
108
+ 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 " +
109
+ "changed since the last sync, or everything with --rescan; a run stops before its deadline or --max-pages " +
110
+ "and the next one goes on from there"), "sync", "sync --rescan", "sync --max-pages 20")
111
+ .option("--rescan", "list everything again, every id too, dropping a round in progress")
112
+ .option("--max-pages <pages>", "at most this many requests to the CRM in this run", integer(100_000))
113
+ .action(async (opts, command) => {
114
+ const { signal } = environmentOf(command);
115
+ await within(command, async (run) => {
116
+ const report = await syncCrm(run.source(), run.store, run.account, {
117
+ now: now(),
118
+ budget: runBudgetOf(command, opts.maxPages),
119
+ ...(signal ? { signal } : {}),
120
+ ...(opts.rescan ? { rescan: true } : {}),
121
+ });
122
+ const failed = report.synced.filter((one) => one.error !== undefined);
123
+ const timeout = command.optsWithGlobals().timeout;
124
+ // The same command again, never --rescan: that would drop the round this run leaves in progress.
125
+ const stuck = report.state === "partial" && report.progress.steps === 0 && failed.length === 0;
126
+ const next = failed.length || (report.state === "partial" && !stuck)
127
+ ? [
128
+ {
129
+ kind: "command",
130
+ step: "crm.sync",
131
+ command: "sync",
132
+ args: Object.fromEntries(Object.entries({ max_pages: opts.maxPages, timeout }).filter(([, value]) => value !== undefined)),
133
+ why: failed.length
134
+ ? "a kind failed to sync; running sync again retries it"
135
+ : "the sync stopped before its end to answer in time; running it again goes on from there",
136
+ safe_to_auto_execute: true,
137
+ },
138
+ ]
139
+ : undefined;
140
+ const progressAt = lineOf(command, "store status");
141
+ const hint = failed.length
142
+ ? `${failed.map((one) => `${one.kind}: ${one.error}`).join("; ")} — run sync again to retry`
143
+ : stuck
144
+ ? `this run moved nothing on: raise --timeout or --max-pages, then run sync again; see progress: ${progressAt}`
145
+ : report.state === "partial"
146
+ ? `run sync again to continue; see progress: ${progressAt}`
147
+ : undefined;
148
+ const { hint: _hint, ...rest } = report;
149
+ // What the healthy kinds brought is saved and answered; each failure and each item not kept is an issue.
150
+ const issues = [
151
+ ...failed.map((one) => ({
152
+ code: "provider_error",
153
+ reason: "sync.partial",
154
+ source: `crm:${one.kind}`,
155
+ })),
156
+ ...report.issues.map((one) => ({
157
+ code: "validation_error",
158
+ reason: one.reason,
159
+ source: one.externalId === undefined ? `crm:${one.kind}` : `crm:${one.kind}/${one.externalId}`,
160
+ })),
161
+ ];
162
+ const partial = report.state === "partial" || issues.length > 0;
163
+ return itemOf(snakeKeys({ ...rest, ...(hint === undefined ? {} : { hint }) }), {
164
+ ...(partial ? { meta: { complete: false, ...(issues.length ? { issues } : {}) } } : {}),
165
+ ...(next ? { next } : {}),
166
+ });
167
+ });
168
+ });
169
+ const store = annotate(program.command("store").description("the local copy of the CRM"), { effects: NO_EFFECTS });
170
+ example(answerEntity(annotate(store.command("status"), { effects: LOCAL_READ }), "crm_sync_progress", crmSyncProgress).description("how far each kind's sync is: rows saved, partial or complete, when it last moved, how many are stored"), "store status --json").action(async (_opts, command) => {
171
+ await within(command, async (run) => {
172
+ const status = await crmStatus(run.store, run.account, { profile: run.profile, now: now() });
173
+ return listOf(pathOf(command).join(" "), {
174
+ items: status.items,
175
+ limit: status.limit,
176
+ has_more: false,
177
+ page: 1,
178
+ });
179
+ });
180
+ });
181
+ const items = (kind) => {
182
+ const plural = `${kind}s`;
183
+ const query = kind === "deal" ? DEALS_QUERY : LEADS_QUERY;
184
+ const group = annotate(program.command(plural), { effects: NO_EFFECTS }).description(kind === "deal"
185
+ ? "the CRM's deals as last synced: stage, status, amount and the people on them"
186
+ : "the CRM's leads as last synced");
187
+ example(withQuery(annotate(group.command("list"), { effects: LOCAL_READ, output: { entity: kind } }).description(`${plural} copied from the CRM, newest first; run sync to bring them up to date`), query)
188
+ .option("--limit <n>", `${plural} per page, 1–${MOST}`, integer(MOST), 50)
189
+ .option("--page <n>", "which page, starting at 1", integer(1_000_000), 1), `${plural} list status:open --json`, `${plural} list stage:"Quote sent"`).action(async (words, opts, command) => {
190
+ const terms = queryTerms(words, query, `${plural} list`);
191
+ await within(command, async (run) => {
192
+ const filter = {
193
+ kind,
194
+ account: run.account,
195
+ ...(terms.has("status") ? { status: terms.get("status") } : {}),
196
+ ...(terms.has("stage") ? { stage: terms.get("stage") } : {}),
197
+ ...(terms.has("pipeline") ? { pipeline: terms.get("pipeline") } : {}),
198
+ ...(terms.has("organization") ? { organization: terms.get("organization") } : {}),
199
+ };
200
+ const found = await run.store.deals.list({
201
+ ...filter,
202
+ limit: Math.min(opts.limit + 1, MOST),
203
+ offset: (opts.page - 1) * opts.limit,
204
+ });
205
+ return listOf(pathOf(command).join(" "), {
206
+ items: marked(found.slice(0, opts.limit), DEAL_UNTRUSTED),
207
+ limit: opts.limit,
208
+ page: opts.page,
209
+ has_more: found.length > opts.limit,
210
+ ...(words.length ? { args: { query: words } } : {}),
211
+ });
212
+ });
213
+ });
214
+ example(annotate(group.command("show"), { effects: LOCAL_READ, output: { entity: kind } })
215
+ .description(`one ${kind} with its pipeline, stage, status, amount and the people on it`)
216
+ .argument("<ref>", `${kind}:<id>, as a list shows it`), `${plural} show ${kind}:12`).action(async (reference, _opts, command) => {
217
+ await within(command, async (run) => {
218
+ const found = await ownCopy(run, kind, reference);
219
+ return itemOf({ ...snakeKeys(found), untrusted: DEAL_UNTRUSTED });
220
+ });
221
+ });
222
+ };
223
+ /** A row of this profile's account: another account's copy, or the owner's own, is not found here. */
224
+ const ownCopy = async (run, kind, reference) => {
225
+ if (!reference.startsWith(`${kind}:`))
226
+ throw new CliError("validation_error", `${kind}s show takes a ${kind}:<id> reference, as a list shows it`, {
227
+ reason: "ref.malformed",
228
+ });
229
+ const found = await run.store.deals.get(reference);
230
+ if (found.account?.provider !== run.account.provider || found.account.account !== run.account.account)
231
+ throw new CliError("not_found", `${reference} is not a copy from this profile's CRM account`);
232
+ return found;
233
+ };
234
+ items("deal");
235
+ if (options.leads !== false)
236
+ items("lead");
237
+ const pipelines = annotate(program.command("pipelines"), { effects: NO_EFFECTS }).description("the CRM's pipelines and their stages, as last synced");
238
+ example(withQuery(annotate(pipelines.command("list"), { effects: LOCAL_READ, output: { entity: "pipeline" } }).description("pipelines copied from the CRM, with their stages in order and how many are open in each"), PIPELINES_QUERY), "pipelines list --json", "pipelines list kind:lead").action(async (words, _opts, command) => {
239
+ const terms = queryTerms(words, PIPELINES_QUERY, "pipelines list");
240
+ await within(command, async (run) => {
241
+ const found = await run.store.deals.pipelines({
242
+ account: run.account,
243
+ ...(terms.has("kind") ? { kind: terms.get("kind") } : {}),
244
+ });
245
+ return listOf(pathOf(command).join(" "), {
246
+ items: marked(found, PIPELINE_UNTRUSTED),
247
+ limit: Math.max(1, found.length),
248
+ page: 1,
249
+ has_more: false,
250
+ ...(words.length ? { args: { query: words } } : {}),
251
+ });
252
+ });
253
+ });
254
+ example(annotate(pipelines.command("show"), { effects: LOCAL_READ, output: { entity: "pipeline" } })
255
+ .description("one pipeline with its funnel: how many reached each stage, how many went on, and the win rate")
256
+ .argument("<pipeline>", "pipeline:<id>, or a name only one of the CRM's pipelines has"), "pipelines show pipeline:3").action(async (reference, _opts, command) => {
257
+ await within(command, async (run) => {
258
+ const report = await run.store.deals.pipeline(reference, { account: run.account });
259
+ return itemOf({ ...snakeKeys(report), untrusted: PIPELINE_UNTRUSTED });
260
+ });
261
+ });
262
+ const stages = annotate(program.command("stages"), { effects: NO_EFFECTS }).description("the stages of the CRM's pipelines, as last synced");
263
+ example(withQuery(annotate(stages.command("list"), { effects: LOCAL_READ, output: { entity: "stage" } }).description("every stage of the CRM's pipelines in order, with its outcome and how many are open in it"), STAGES_QUERY), "stages list --json", "stages list kind:lead").action(async (words, _opts, command) => {
264
+ const terms = queryTerms(words, STAGES_QUERY, "stages list");
265
+ await within(command, async (run) => {
266
+ const kind = terms.has("kind") ? terms.get("kind") : undefined;
267
+ const all = await run.store.deals.pipelines({ account: run.account, ...(kind ? { kind } : {}) });
268
+ const chosen = terms.has("pipeline")
269
+ ? [
270
+ await run.store.deals.pipeline(terms.get("pipeline"), {
271
+ account: run.account,
272
+ ...(kind ? { kind } : {}),
273
+ }),
274
+ ]
275
+ : all;
276
+ const found = chosen.flatMap((pipeline) => pipeline.stages.map(({ name, position, outcome, externalId, open }) => ({
277
+ name,
278
+ position,
279
+ outcome,
280
+ externalId,
281
+ open,
282
+ pipeline: pipeline.ref,
283
+ pipelineName: pipeline.name,
284
+ kind: pipeline.kind,
285
+ })));
286
+ return listOf(pathOf(command).join(" "), {
287
+ items: marked(found, STAGE_UNTRUSTED),
288
+ limit: Math.max(1, found.length),
289
+ page: 1,
290
+ has_more: false,
291
+ ...(words.length ? { args: { query: words } } : {}),
292
+ });
293
+ });
294
+ });
295
+ const search = program.commands.find((one) => one.name() === "search") ??
296
+ annotate(program.command("search"), { effects: NO_EFFECTS }).description("search what the local store holds");
297
+ addCrmSearchAll(search, { within, app: options.app, example });
298
+ };
299
+ // honoursDryRun: a host program other than `run` would otherwise let a write through under --dry-run.
300
+ export const addCrmCommands = honoursDryRun(sharedCommand(addCrmCommandsDefinition));
@@ -0,0 +1,12 @@
1
+ import { Command } from "commander";
2
+ import type { AppIdentity } from "../../ports/app.js";
3
+ import type { CrmWithin } from "./index.js";
4
+ /**
5
+ * `search all` in a CRM CLI: the shared search over the store, its own deals and leads beside the messages, notes and
6
+ * events other WireCat tools stored. A locked profile reads only its own account's deals.
7
+ */
8
+ export declare const addCrmSearchAll: (searches: Command, { within, app, example, }: {
9
+ within: CrmWithin;
10
+ app: AppIdentity;
11
+ example: (command: Command, ...lines: string[]) => Command;
12
+ }) => void;
@@ -0,0 +1,63 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { annotate } from "@wirecat/cli-base/commands";
3
+ import { Command } from "commander";
4
+ import { outputFor } from "../../cli/context.js";
5
+ import { LOCAL_READ } from "../../cli/effects.js";
6
+ import { historySkipsOf } from "../../cli/messenger/history-gaps.js";
7
+ import { allAnswer, offsetOf, SOURCE_SEARCHED } from "../../cli/messenger/search-answers.js";
8
+ import { searchAllOptions } from "../../cli/messenger/search-command.js";
9
+ import { SAVE_HELP } from "../../meetings/index.js";
10
+ import { meaningLane } from "../../services/messages-meaning.js";
11
+ import { searchAllWithMeetings } from "../../services/search-all-meetings.js";
12
+ /** What a CRM CLI's `search all` reads: its deals and leads first, then what the other tools stored. */
13
+ const SEARCHED = ["crm", ...SOURCE_SEARCHED];
14
+ const DEFAULT_LIMIT = 20;
15
+ /**
16
+ * `search all` in a CRM CLI: the shared search over the store, its own deals and leads beside the messages, notes and
17
+ * events other WireCat tools stored. A locked profile reads only its own account's deals.
18
+ */
19
+ export const addCrmSearchAll = (searches, { within, app, example, }) => {
20
+ const all = annotate(new Command("all"), { effects: LOCAL_READ, partial: true, output: { entity: "hit" } })
21
+ .summary("search stored deals and leads, messages, notes and calendar events")
22
+ .description("search the CRM's deals and leads by the words of their title, and messages, notes and calendar events other " +
23
+ "WireCat tools stored — best match first. Use it when you do not know where something was written")
24
+ .argument("<query...>", 'words and "phrases"; deals and leads match the words of their title')
25
+ .addHelpText("after", SAVE_HELP);
26
+ searches.addCommand(searchAllOptions(all, SEARCHED));
27
+ example(all, "search all Example LLC", "search all website --only crm --json");
28
+ all.action(async (words, _opts, leaf) => {
29
+ const opts = leaf.opts();
30
+ await within(leaf, async (run) => {
31
+ if (run.locked && opts.source !== undefined)
32
+ throw new CliError("permission_error", "this process is locked to one profile; search all reads its own CRM account only, without --source", { reason: "profile.locked" });
33
+ const limit = opts.limit ?? DEFAULT_LIMIT;
34
+ const offset = offsetOf(opts.page, limit);
35
+ const env = run.env ?? process.env;
36
+ const found = await searchAllWithMeetings(run.store, run.account, {
37
+ text: words.join(" "),
38
+ limit,
39
+ ...(offset === undefined ? {} : { offset }),
40
+ ...(opts.exact ? { exact: true } : {}),
41
+ ...(opts.timezone === undefined ? {} : { timezone: opts.timezone }),
42
+ env,
43
+ only: opts.only ?? SEARCHED,
44
+ ...(opts.source === undefined ? {} : { source: opts.source }),
45
+ ...(opts.maxMeetings === undefined ? {} : { maxMeetings: opts.maxMeetings }),
46
+ ...(run.locked ? { scope: { crm: [run.account] } } : {}),
47
+ }, { app }, undefined, meaningLane({ env, messenger: { app }, store: async () => run.store }));
48
+ const note = (text) => outputFor(leaf).renderer.note(text);
49
+ for (const { resource, source, reason } of found.skipped)
50
+ note(`${resource ?? source} not searched: ${reason}`);
51
+ if (!found.items.length)
52
+ note("nothing found in what the local store holds");
53
+ const skips = await historySkipsOf(run.store, found.accounts).catch(() => []);
54
+ const again = {
55
+ query: words.join(" "),
56
+ ...(opts.only === undefined ? {} : { only: opts.only.join(",") }),
57
+ ...(opts.source === undefined ? {} : { source: opts.source }),
58
+ ...(opts.exact ? { exact: true } : {}),
59
+ };
60
+ return allAnswer("search all", found, { limit, page: opts.page ?? 1, again }, opts.exact === true, skips);
61
+ });
62
+ });
63
+ };
@@ -0,0 +1,2 @@
1
+ export type { CrmChanges, CrmIdPage, CrmPage, CrmSource, CrmSourceTraits } from "./source.js";
2
+ export { type CrmCursor, type CrmSyncIssue, type CrmSyncOptions, type CrmSyncReport, type CrmSyncResult, crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
@@ -0,0 +1 @@
1
+ export { crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";