@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
@@ -3,6 +3,37 @@ export const CRM_VERBS = ["create", "update", "move", "close", "remove"];
3
3
  export const CRM_ASSIGN_OTHERS_KEY = "crm.assign-others";
4
4
  /** The keys the CRM writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
5
5
  export const CRM_WRITE_KEYS = [CRM_ASSIGN_OTHERS_KEY];
6
+ const PLURALS = { deals: "deal", leads: "lead" };
7
+ const VERB_OF = {
8
+ create: "create",
9
+ update: "update",
10
+ move: "move",
11
+ close: "close",
12
+ delete: "remove",
13
+ };
14
+ /**
15
+ * Every key a CRM write command may check beyond its own path, for `commandPermissions({ alsoChecks })`, so its
16
+ * `--dry-run` names each: a stage implies the move key and a won or lost one the close key, an owner other than the
17
+ * sign-in's `crm.assign-others`, and `also` the CLI's own (the same `CrmWrites.alsoChecks` the real run checks).
18
+ */
19
+ export const crmAlsoChecks = (also) => (path) => {
20
+ const [noun, command] = path;
21
+ const kind = noun === undefined ? undefined : PLURALS[noun];
22
+ const verb = command === undefined ? undefined : VERB_OF[command];
23
+ if (path.length !== 2 || kind === undefined || verb === undefined)
24
+ return [];
25
+ const close = { key: `${noun}.close`, when: "a won or lost stage" };
26
+ const implied = verb === "create" || verb === "update"
27
+ ? [
28
+ { key: `${noun}.move`, when: "--stage" },
29
+ close,
30
+ { key: CRM_ASSIGN_OTHERS_KEY, when: "--owner other than the sign-in's user" },
31
+ ]
32
+ : verb === "move"
33
+ ? [close]
34
+ : [];
35
+ return [...implied, ...(also?.(kind, verb) ?? [])];
36
+ };
6
37
  /**
7
38
  * Built in, and only ever tightening (WC-PERM-7): an ordinary change goes ahead, a deletion asks, and a record is not
8
39
  * given to somebody else until the owner allows it.
@@ -2,11 +2,12 @@ import type { DealKind } from "../domain/deals.js";
2
2
  import type { AppIdentity } from "../ports/app.js";
3
3
  import type { Warn } from "../runs/events.js";
4
4
  import { type Asker, type GuardSettings } from "../sends/guard.js";
5
+ import { type PermissionKey } from "../sends/permissions.js";
5
6
  import type { Deal } from "../store/sqlite/deals.js";
6
7
  import type { MessageStore } from "../store/store.js";
7
8
  import type { AccountKey } from "../store/types.js";
8
9
  import type { CrmSource } from "./source.js";
9
- import { type CrmWriter } from "./writer.js";
10
+ import { type CrmVerb, type CrmWriter } from "./writer.js";
10
11
  /** What a CLI hands the CRM writes for one profile. */
11
12
  export interface CrmWrites {
12
13
  writer: CrmWriter;
@@ -17,6 +18,11 @@ export interface CrmWrites {
17
18
  self?: string;
18
19
  env?: NodeJS.ProcessEnv;
19
20
  warn?: Warn;
21
+ /**
22
+ * The CLI's own keys a write also checks, such as Bitrix24's `api.crm.deal.update`; pass the same function to
23
+ * `crmAlsoChecks` so `--dry-run` names them.
24
+ */
25
+ alsoChecks?: (kind: DealKind, verb: CrmVerb) => readonly PermissionKey[];
20
26
  }
21
27
  export interface CrmWriteContext {
22
28
  store: MessageStore;
@@ -40,7 +46,11 @@ export interface CrmFieldsInput {
40
46
  currency?: string;
41
47
  closeDate?: string;
42
48
  owner?: string;
43
- custom?: Record<string, string | null>;
49
+ /**
50
+ * `--field` pairs as typed, in order: a field by its code or whole label, a list field's option by its id or label,
51
+ * repeated for each value of a multiple field; an empty value clears it.
52
+ */
53
+ custom?: readonly (readonly [name: string, value: string])[];
44
54
  }
45
55
  export interface CrmWritten {
46
56
  operationId: string;
@@ -118,14 +118,80 @@ const fieldsOf = (input, pipeline, currency) => {
118
118
  throw invalid("--owner is the CRM user's id", "--owner");
119
119
  fields.owner = input.owner.trim();
120
120
  }
121
- if (input.custom !== undefined && Object.keys(input.custom).length) {
122
- for (const code of Object.keys(input.custom))
123
- if (!FIELD_CODE.test(code))
124
- throw invalid(`${code} is not a field code`, "--field");
125
- fields.custom = input.custom;
126
- }
127
121
  return { fields, stage };
128
122
  };
123
+ /** Case, spaces and underscores aside: `lead_source` and `Lead source` are one name, as bitrix24-cli folds them. */
124
+ const folded = (name) => name
125
+ .trim()
126
+ .toLocaleLowerCase()
127
+ .replace(/[\s_]+/gu, " ");
128
+ const named = ({ code, label }) => ({ code, label });
129
+ /** A field by its code or its whole label; two with that label are refused with both, never guessed. */
130
+ const fieldNamed = (fields, name) => {
131
+ const want = folded(name);
132
+ const byCode = fields.find((field) => folded(field.code) === want);
133
+ if (byCode)
134
+ return byCode;
135
+ const labelled = fields.filter((field) => field.label !== null && folded(field.label) === want);
136
+ if (labelled.length === 1)
137
+ return labelled[0];
138
+ if (labelled.length > 1)
139
+ throw new CliError("validation_error", `${labelled.length} custom fields are labelled ${name}; name one by its code`, {
140
+ reason: "field.ambiguous",
141
+ param: "--field",
142
+ candidates: labelled.map(named),
143
+ });
144
+ const close = fields.filter((field) => [field.code, field.label ?? ""].some((one) => {
145
+ const other = folded(one);
146
+ return other !== "" && (other.includes(want) || want.includes(other));
147
+ }));
148
+ throw new CliError("validation_error", `no custom field is named ${name}${close.length ? "; close ones are in candidates" : ""} — \`fields list\` lists them`, { reason: "field.unknown", param: "--field", candidates: close.map(named) });
149
+ };
150
+ const optionOf = (field, value) => {
151
+ if (!field.options)
152
+ return value;
153
+ const found = field.options.find((option) => option.id === value.trim()) ??
154
+ field.options.find((option) => folded(option.label) === folded(value));
155
+ if (!found)
156
+ throw new CliError("validation_error", `${field.label ?? field.code} has no option ${value}`, {
157
+ reason: "field.unknown-option",
158
+ param: "--field",
159
+ field: field.code,
160
+ allowed: field.options,
161
+ });
162
+ return found.id;
163
+ };
164
+ /**
165
+ * `--field` pairs by the synced field definitions: a label to its code, an option to its id, a multiple field's
166
+ * repeats into a list. A CRM whose definitions are not stored takes codes as typed.
167
+ */
168
+ const customOf = async (context, kind, pairs) => {
169
+ if (!pairs?.length)
170
+ return undefined;
171
+ const fields = await context.store.crm.fields(context.account, kind);
172
+ const values = new Map();
173
+ for (const [name, value] of pairs) {
174
+ const field = fields.length ? fieldNamed(fields, name) : null;
175
+ const code = field?.code ?? name.trim();
176
+ if (!field && !FIELD_CODE.test(code))
177
+ throw invalid(`${name} is not a field code`, "--field");
178
+ const held = values.get(code) ?? { field, all: [] };
179
+ held.all.push(value === "" ? null : field ? optionOf(field, value) : value);
180
+ values.set(code, held);
181
+ }
182
+ return Object.fromEntries([...values].map(([code, { field, all }]) => {
183
+ const given = all.filter((one) => one !== null);
184
+ if (field?.multiple)
185
+ return [code, given.length ? given : null];
186
+ if (all.length > 1)
187
+ throw new CliError("validation_error", `${field?.label ?? code} holds one value; give --field once`, {
188
+ reason: "deal.field",
189
+ param: "--field",
190
+ field: code,
191
+ });
192
+ return [code, all[0] ?? null];
193
+ }));
194
+ };
129
195
  /** Every key the write checks: its own, and each action its fields also take (WC-PERM-22). */
130
196
  const keysOf = (context, kind, verb, { stage, owner }) => {
131
197
  const extra = [];
@@ -135,7 +201,7 @@ const keysOf = (context, kind, verb, { stage, owner }) => {
135
201
  extra.push(keyOf(kind, "close"));
136
202
  if (owner != null && owner !== context.writes.self)
137
203
  extra.push(CRM_ASSIGN_OTHERS_KEY);
138
- return { key: keyOf(kind, verb), extra };
204
+ return { key: keyOf(kind, verb), extra: [...extra, ...(context.writes.alsoChecks?.(kind, verb) ?? [])] };
139
205
  };
140
206
  /** The profile's guard over the write's own key and the keys its fields imply (`keyedGuard`). */
141
207
  const guardOf = (context, extra) => {
@@ -146,7 +212,20 @@ const guardOf = (context, extra) => {
146
212
  /** Sends the write once, never again: a lost answer is `outcome_unknown`, with what the owner may do about it. */
147
213
  const written = async (context, { key, extra }, operationId, act) => {
148
214
  try {
149
- return await guardedWrite(guardOf(context, extra), { chatId: null, kind: "edit", key, operationId }, act);
215
+ // A write the CRM made and could not read back is sent: its journal line says so, and the read's failure follows.
216
+ const done = await guardedWrite(guardOf(context, extra), { chatId: null, kind: "edit", key, operationId }, async () => {
217
+ try {
218
+ return { item: await act(), unread: null };
219
+ }
220
+ catch (error) {
221
+ if (isCliError(error) && error.details.sent === true)
222
+ return { item: null, unread: error };
223
+ throw error;
224
+ }
225
+ });
226
+ if (done.unread)
227
+ throw new CliError(done.unread.code, `${done.unread.message} — the CRM made the change; sync to store it, do not write it again`, { ...done.unread.details, operation_id: operationId });
228
+ return done.item;
150
229
  }
151
230
  catch (error) {
152
231
  if (!isCliError(error) || error.code !== "outcome_unknown")
@@ -192,6 +271,9 @@ export const createCrmItem = async (context, kind, input, options = {}) => {
192
271
  throw invalid(`a new ${kind} needs --title`, "--title");
193
272
  const pipeline = await pipelineOf(context, kind, input.pipeline);
194
273
  const { fields, stage } = fieldsOf({ ...input, pipeline: pipeline.ref }, pipeline, null);
274
+ const custom = await customOf(context, kind, input.custom);
275
+ if (custom)
276
+ fields.custom = custom;
195
277
  const operationId = operationOf(context, options.operationId);
196
278
  const item = await written(context, keysOf(context, kind, "create", { stage, owner: fields.owner }), operationId, () => writer.create(kind, fields, { operationId }));
197
279
  return { operationId, item: await kept(context, kind, item, operationId) };
@@ -206,6 +288,9 @@ export const updateCrmItem = async (context, kind, reference, input, options = {
206
288
  ? undefined
207
289
  : await pipelineOf(context, kind, input.pipeline === undefined ? copy.pipeline : input.pipeline);
208
290
  const { fields, stage } = fieldsOf(input, pipeline, copy.currency);
291
+ const custom = await customOf(context, kind, input.custom);
292
+ if (custom)
293
+ fields.custom = custom;
209
294
  if (Object.keys(fields).length === 0)
210
295
  throw invalid("nothing to change: name a field");
211
296
  const operationId = operationOf(context, options.operationId);
@@ -52,9 +52,20 @@ export interface CrmItem {
52
52
  updatedAt?: number | null;
53
53
  /** The CRM's own page for it, http or https. */
54
54
  url?: string | null;
55
- /** What the CRM sends that has no column, such as custom fields with their labels; never a secret. */
55
+ /** The CRM user it is assigned to, by the user's id: never a contact's. */
56
+ assignee?: string | null;
57
+ /**
58
+ * What the CRM sends that has no column; never a secret. Its own fields' values are here by code as `CrmValue`,
59
+ * which the deal's `provider_metadata` answers.
60
+ */
56
61
  metadata?: Record<string, unknown>;
57
62
  }
63
+ /** One of the CRM's own fields' values: its label, and for a list field the option's label and `option_id`. */
64
+ export interface CrmValue {
65
+ label: string | null;
66
+ value: unknown;
67
+ option_id?: string | string[];
68
+ }
58
69
  /**
59
70
  * A CRM contact: it becomes an identity of the CRM's provider, `<account>/contact/<id>`, joined to a person by exact
60
71
  * email only. A CRM user, such as a deal's owner, is not a contact: its ids are another sequence (`<account>/user/<id>`).
@@ -72,6 +83,23 @@ export interface CrmOrganization {
72
83
  url?: string | null;
73
84
  metadata?: Record<string, unknown>;
74
85
  }
86
+ /** One of a CRM account's own fields on a kind: what the code a row's `metadata` is keyed by means. */
87
+ export interface CrmField {
88
+ /** The CRM's key for it, unique in its kind. */
89
+ code: string;
90
+ /** Its title in the CRM; null when the CRM gives none. */
91
+ label: string | null;
92
+ /** The CRM's own word for its type, such as `string` or `enumeration`. */
93
+ type: string;
94
+ multiple: boolean;
95
+ /** Whether the CRM can filter a list by it. */
96
+ filterable: boolean;
97
+ /** A list field's options in the CRM's order; absent on any other field. */
98
+ options?: {
99
+ id: string;
100
+ label: string;
101
+ }[];
102
+ }
75
103
  export interface CrmRecords {
76
104
  deal: CrmItem;
77
105
  lead: CrmItem;
@@ -33,6 +33,14 @@ export declare const deal: v.StrictObjectSchema<{
33
33
  readonly account: v.StringSchema<undefined>;
34
34
  }, undefined>, undefined>;
35
35
  readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
36
+ /** A CRM copy's assignee, the identity `<account>/user/<id>`. */
37
+ readonly assignee: v.NullableSchema<v.StringSchema<undefined>, undefined>;
38
+ /** A CRM copy's own fields by code; a list field's value is its option's label, `option_id` its id. */
39
+ readonly provider_metadata: v.RecordSchema<v.StringSchema<undefined>, v.StrictObjectSchema<{
40
+ readonly label: v.NullableSchema<v.StringSchema<undefined>, undefined>;
41
+ readonly value: v.UnknownSchema;
42
+ readonly option_id: v.OptionalSchema<v.UnionSchema<[v.StringSchema<undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>, undefined>;
43
+ }, undefined>, undefined>;
36
44
  readonly people: v.ArraySchema<v.StrictObjectSchema<{
37
45
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
38
46
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -86,6 +94,14 @@ export declare const lead: v.StrictObjectSchema<{
86
94
  readonly account: v.StringSchema<undefined>;
87
95
  }, undefined>, undefined>;
88
96
  readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
97
+ /** A CRM copy's assignee, the identity `<account>/user/<id>`. */
98
+ readonly assignee: v.NullableSchema<v.StringSchema<undefined>, undefined>;
99
+ /** A CRM copy's own fields by code; a list field's value is its option's label, `option_id` its id. */
100
+ readonly provider_metadata: v.RecordSchema<v.StringSchema<undefined>, v.StrictObjectSchema<{
101
+ readonly label: v.NullableSchema<v.StringSchema<undefined>, undefined>;
102
+ readonly value: v.UnknownSchema;
103
+ readonly option_id: v.OptionalSchema<v.UnionSchema<[v.StringSchema<undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>, undefined>;
104
+ }, undefined>, undefined>;
89
105
  readonly people: v.ArraySchema<v.StrictObjectSchema<{
90
106
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
91
107
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -32,6 +32,14 @@ export const deal = v.strictObject({
32
32
  /** The CRM account it is a copy from; `null` for the owner's own. */
33
33
  account: crmAccount,
34
34
  external_id: nullableText,
35
+ /** A CRM copy's assignee, the identity `<account>/user/<id>`. */
36
+ assignee: nullableText,
37
+ /** A CRM copy's own fields by code; a list field's value is its option's label, `option_id` its id. */
38
+ provider_metadata: v.record(v.string(), v.strictObject({
39
+ label: v.nullable(v.string()),
40
+ value: v.unknown(),
41
+ option_id: v.optional(v.union([v.string(), v.array(v.string())])),
42
+ })),
35
43
  people: v.array(v.strictObject({ ...party.entries, role: v.picklist(["owner", "contact", "decision-maker"]) })),
36
44
  author: v.strictObject({
37
45
  type: v.picklist(["person", "bot"]),
@@ -262,6 +262,12 @@ export declare const ENTITIES: {
262
262
  readonly account: v.StringSchema<undefined>;
263
263
  }, undefined>, undefined>;
264
264
  readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
265
+ readonly assignee: v.NullableSchema<v.StringSchema<undefined>, undefined>;
266
+ readonly provider_metadata: v.RecordSchema<v.StringSchema<undefined>, v.StrictObjectSchema<{
267
+ readonly label: v.NullableSchema<v.StringSchema<undefined>, undefined>;
268
+ readonly value: v.UnknownSchema;
269
+ readonly option_id: v.OptionalSchema<v.UnionSchema<[v.StringSchema<undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>, undefined>;
270
+ }, undefined>, undefined>;
265
271
  readonly people: v.ArraySchema<v.StrictObjectSchema<{
266
272
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
267
273
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -311,6 +317,12 @@ export declare const ENTITIES: {
311
317
  readonly account: v.StringSchema<undefined>;
312
318
  }, undefined>, undefined>;
313
319
  readonly external_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
320
+ readonly assignee: v.NullableSchema<v.StringSchema<undefined>, undefined>;
321
+ readonly provider_metadata: v.RecordSchema<v.StringSchema<undefined>, v.StrictObjectSchema<{
322
+ readonly label: v.NullableSchema<v.StringSchema<undefined>, undefined>;
323
+ readonly value: v.UnknownSchema;
324
+ readonly option_id: v.OptionalSchema<v.UnionSchema<[v.StringSchema<undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>, undefined>;
325
+ }, undefined>, undefined>;
314
326
  readonly people: v.ArraySchema<v.StrictObjectSchema<{
315
327
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
316
328
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -215,6 +215,10 @@ const OTHERS = [
215
215
  validation("crm.stage-unknown", "a CRM item stands in a stage the store does not hold; it was not kept"),
216
216
  validation("crm.item-invalid", "a CRM item has a field the store refuses, such as a currency it does not keep"),
217
217
  validation("crm.pipeline-required", "the CRM has more than one pipeline of the kind; the write names one"),
218
+ validation("field.unknown", "no custom field of the kind has that code or label; `fields list` lists them"),
219
+ validation("field.ambiguous", "more than one custom field has that label; name one by its code"),
220
+ validation("field.unknown-option", "the list field has no option with that id or label"),
221
+ validation("field.unknown-kind", "fields are listed for deals, leads, people and organizations only"),
218
222
  validation("crm.write-unsupported", "the CRM does not take this write, or does not recognise a repeated one"),
219
223
  validation("crm.unlisted-held", "a list of every id would mark most of a kind deleted; nothing was, until --rescan"),
220
224
  validation("order.duplicate", "the shop's order with this number is already stored; change its status instead"),
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { bareCheck, type CheckWords, checkWords } from "./domain/checks.js";
2
2
  export type { CounterField, CounterObservation, CounterObservations, CounterState } from "./domain/counters.js";
3
3
  export { COUNTER_FIELDS, counterValue, observedCounters } from "./domain/counters.js";
4
- export { CRM_KINDS, type CrmItem, type CrmKind, type CrmOrganization, type CrmPerson, type CrmPipeline, type CrmRecords, type CrmStage, isItemKind, } from "./domain/crm.js";
4
+ export { CRM_KINDS, type CrmField, type CrmItem, type CrmKind, type CrmOrganization, type CrmPerson, type CrmPipeline, type CrmRecords, type CrmStage, type CrmValue, isItemKind, } from "./domain/crm.js";
5
5
  export { DEAL_KINDS, DEAL_ROLES, DEAL_STATUSES, type DealKind, type DealRole, type DealStatus, STAGE_OUTCOMES, type StageOutcome, } from "./domain/deals.js";
6
6
  export { FINANCE_REVIEWS, type FinanceReview, INVOICE_DIRECTIONS, INVOICE_STATUSES, type InvoiceDirection, type InvoiceStatus, MONEY_DIRECTIONS, type MoneyDirection, } from "./domain/finance.js";
7
7
  export { type FormattedText, type HtmlFormatting, type MarkdownFormatting, type TextSpan, validateFormattedText, } from "./domain/formatting.js";
@@ -2,5 +2,6 @@ export { ARGV_LOG, argvLogSetup } from "./argv-log.js";
2
2
  export { type ContractCase, type ContractOptions, contractCases, OPTIONAL_METHODS } from "./contract.js";
3
3
  export { type FakeAdapter, fakeAdapter } from "./fake.js";
4
4
  export { type McpStdio, type McpStdioStreams, type McpToolResult, mcpOverStdio } from "./mcp-stdio.js";
5
+ export { neverAnswers, type RunScopeCase, type RunScopeOptions, runScopeCases } from "./run-scope.js";
5
6
  export { type SearchRecipes, searchRecipes, seedSearchRecipes } from "./search-recipes.js";
6
7
  export { BUSY_PAGE, contractSeed, digitIds, type IdKind, type IdMaker, type Seed, wordIds } from "./seed.js";
package/dist/kit/index.js CHANGED
@@ -2,5 +2,6 @@ export { ARGV_LOG, argvLogSetup } from "./argv-log.js";
2
2
  export { contractCases, OPTIONAL_METHODS } from "./contract.js";
3
3
  export { fakeAdapter } from "./fake.js";
4
4
  export { mcpOverStdio } from "./mcp-stdio.js";
5
+ export { neverAnswers, runScopeCases } from "./run-scope.js";
5
6
  export { searchRecipes, seedSearchRecipes } from "./search-recipes.js";
6
7
  export { BUSY_PAGE, contractSeed, digitIds, wordIds } from "./seed.js";
@@ -0,0 +1,34 @@
1
+ import { type ProgramDefinition, type RunOptions } from "../cli/program.js";
2
+ export interface RunScopeCase {
3
+ name: string;
4
+ /** Throws an `AssertionError` when the command does not run inside cli-core's run. */
5
+ run(): Promise<void>;
6
+ }
7
+ export interface RunScopeOptions {
8
+ /** The CLI's own program definition, as its `run()` hands it to cli-core. */
9
+ definition: ProgramDefinition;
10
+ /**
11
+ * The words that run each remote command to success against the answering fake, by command path:
12
+ * `{ "comments show": ["personal", "comments", "show", "c1"] }`. A write whose level is `ask` needs a profile that
13
+ * allows it, or its refusal fails the case.
14
+ */
15
+ commands: Record<string, readonly string[]>;
16
+ /** Remote commands left out, each with its reason: one that listens until stopped, one that asks npm. */
17
+ skip?: Record<string, string>;
18
+ /** What `run()` gets beside the streams: the keyring, and the fake that answers every request. */
19
+ answering: () => RunOptions;
20
+ /** The same, with a fake that takes every request and never settles it, `neverAnswers` for an HTTP service. */
21
+ silent: () => RunOptions;
22
+ /** The `--timeout` a silent run gets; 100 ms. */
23
+ timeoutMs?: number;
24
+ }
25
+ /** A `fetch` that never settles, not even on abort: only the run's own deadline can end the command. */
26
+ export declare const neverAnswers: typeof fetch;
27
+ /**
28
+ * Cases that each remote command runs inside cli-core's run — `baseContext(command, resolveSettings).run(body)` — and
29
+ * not beside it, so `--record` keeps a run named after the command (WC-LIFE-15); and that `--timeout` ends it when
30
+ * its service never answers, a write as `outcome_unknown` (WC-LIFE-5, WC-LIFE-9). The recorded case is the one that
31
+ * catches a command beside the run: `run()`'s own deadline still ends such a command on time. The first case fails
32
+ * on a remote command with no words and no skip.
33
+ */
34
+ export declare const runScopeCases: (options: RunScopeOptions) => RunScopeCase[];
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { metaOf } from "@wirecat/cli-base/commands";
6
+ import { createProgram, run } from "../cli/program.js";
7
+ import { findRun, runsDirFor } from "../runs/run.js";
8
+ /** A `fetch` that never settles, not even on abort: only the run's own deadline can end the command. */
9
+ export const neverAnswers = (() => new Promise(() => { }));
10
+ const GRACE_MS = 2000;
11
+ const remoteCommands = (program, path = []) => program.commands.flatMap((command) => {
12
+ const at = [...path, command.name()];
13
+ if (command.commands.length > 0)
14
+ return remoteCommands(command, at);
15
+ const meta = metaOf(command);
16
+ const remote = meta.effects?.remote;
17
+ return remote && remote !== "none" ? [{ path: at.join(" "), remote, partial: meta.partial === true }] : [];
18
+ });
19
+ const within = async (ms, what, body) => {
20
+ let timer;
21
+ const late = new Promise((_, reject) => {
22
+ timer = setTimeout(() => reject(new assert.AssertionError({ message: what })), ms);
23
+ timer.unref();
24
+ });
25
+ try {
26
+ return await Promise.race([body, late]);
27
+ }
28
+ finally {
29
+ clearTimeout(timer);
30
+ }
31
+ };
32
+ const parsed = (text) => {
33
+ try {
34
+ return JSON.parse(text.trim());
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ };
40
+ /**
41
+ * Cases that each remote command runs inside cli-core's run — `baseContext(command, resolveSettings).run(body)` — and
42
+ * not beside it, so `--record` keeps a run named after the command (WC-LIFE-15); and that `--timeout` ends it when
43
+ * its service never answers, a write as `outcome_unknown` (WC-LIFE-5, WC-LIFE-9). The recorded case is the one that
44
+ * catches a command beside the run: `run()`'s own deadline still ends such a command on time. The first case fails
45
+ * on a remote command with no words and no skip.
46
+ */
47
+ export const runScopeCases = (options) => {
48
+ const { definition, commands, skip = {}, timeoutMs = 100 } = options;
49
+ const { app } = definition;
50
+ const cli = async (argv, given) => {
51
+ const out = [];
52
+ const err = [];
53
+ const env = {
54
+ ...process.env,
55
+ ...given.env,
56
+ [`${app.envPrefix}_STATE_DIR`]: mkdtempSync(join(tmpdir(), "run-scope-")),
57
+ };
58
+ const code = await run(argv, definition, {
59
+ ...given,
60
+ env,
61
+ tty: false,
62
+ streams: { data: (text) => out.push(text), diagnostic: (text) => err.push(text) },
63
+ });
64
+ return { code, out: out.join(""), err: err.join(""), env };
65
+ };
66
+ const machine = (words) => words.some((word) => word === "--json" || word.startsWith("--json=")) ? [...words] : [...words, "--json"];
67
+ const recordedCase = (path, words) => ({
68
+ name: `${path} runs in a recorded run named after it`,
69
+ run: async () => {
70
+ const ran = await within(GRACE_MS, `${path} did not end against the answering fake`, (async () => cli(["--record", ...machine(words)], options.answering()))());
71
+ assert.equal(ran.code, 0, `${path} must succeed against the answering fake; it exited ${ran.code}: ${ran.err}`);
72
+ const runId = parsed(ran.out)?.meta?.run_id;
73
+ assert.equal(typeof runId, "string", `${path} answered without meta.run_id`);
74
+ assert.doesNotMatch(String(runId), /-process-/, `${path} answered outside a run of its own: ${runId}`);
75
+ const kept = findRun(runsDirFor(app, ran.env), String(runId));
76
+ assert.ok(kept, `--record kept no run ${runId} for ${path}`);
77
+ assert.equal(kept.metadata.command, path, `the run of ${path} is named ${kept.metadata.command}`);
78
+ },
79
+ });
80
+ const timeoutCase = ({ path, remote, partial }, words) => {
81
+ const expected = remote === "write" ? "outcome_unknown" : "timeout";
82
+ return {
83
+ name: `${path} ends in ${expected}${partial ? " or a partial answer" : ""} at --timeout when the service never answers`,
84
+ run: async () => {
85
+ const ran = await within(timeoutMs + GRACE_MS, `${path} was still running ${GRACE_MS} ms after its --timeout ${timeoutMs}ms`, cli(["--timeout", `${timeoutMs}ms`, ...machine(words)], options.silent()));
86
+ const answered = parsed(ran.out)?.meta?.complete;
87
+ if (partial && ran.code === 0 && answered === false)
88
+ return;
89
+ const code = parsed(ran.err)?.error?.code;
90
+ assert.equal(code, expected, `${path} against a silent service ended in ${String(code)}: ${ran.out}${ran.err}`);
91
+ },
92
+ };
93
+ };
94
+ const remote = remoteCommands(createProgram(definition));
95
+ const coverage = {
96
+ name: "every remote command has its words or a reason to skip it",
97
+ run: async () => {
98
+ const missing = remote.map(({ path }) => path).filter((path) => !(path in commands) && !(path in skip));
99
+ assert.deepEqual(missing, [], `remote commands with no words in \`commands\` and no reason in \`skip\``);
100
+ const known = new Set(remote.map(({ path }) => path));
101
+ const stray = [...Object.keys(commands), ...Object.keys(skip)].filter((path) => !known.has(path));
102
+ assert.deepEqual(stray, [], "paths in `commands` or `skip` that are not remote commands of this program");
103
+ },
104
+ };
105
+ return [
106
+ coverage,
107
+ ...remote.flatMap((one) => {
108
+ const words = commands[one.path];
109
+ return words ? [recordedCase(one.path, words), timeoutCase(one, words)] : [];
110
+ }),
111
+ ];
112
+ };
@@ -124,8 +124,13 @@ export const serveAppOverStdio = async (command, context, options) => {
124
124
  });
125
125
  const ended = await new Promise((resolve) => {
126
126
  wire.ended.then(() => resolve("input"));
127
- if (own)
128
- signal?.addEventListener("abort", () => resolve("stopped"), { once: true });
127
+ if (own) {
128
+ // A stop that came before this line fires no abort event.
129
+ if (signal?.aborted)
130
+ resolve("stopped");
131
+ else
132
+ signal?.addEventListener("abort", () => resolve("stopped"), { once: true });
133
+ }
129
134
  else {
130
135
  process.once("SIGINT", () => resolve("stopped"));
131
136
  process.once("SIGTERM", () => resolve("stopped"));
@@ -11,6 +11,11 @@ export interface MeetingCommandDeps {
11
11
  importHelp?: string;
12
12
  writeFile?: (path: string, content: string) => Promise<void>;
13
13
  write: (value: unknown, format: "json" | "jsonl" | "text") => void;
14
+ /**
15
+ * Runs a command that reaches the account inside cli-core's run, so it is recorded and ended by `--timeout`:
16
+ * `inCommandRun` from `@wirecat/cli-core/cli`. Required, since this module cannot reach the run itself.
17
+ */
18
+ inRun: <T>(command: Command, body: () => Promise<T>) => Promise<T>;
14
19
  now?: () => number;
15
20
  }
16
21
  export { addMeetingPassageCommand, createMeetingPassageCommand, type MeetingPassageFlags, meetingPassageRequest, } from "./passage.js";
@@ -121,14 +121,15 @@ const addMeetingCommandsDefinition = (program, deps) => {
121
121
  .option("--since-time <time>", "fetch meetings starting at or after this ISO 8601 date or time", date)
122
122
  .option("--lookback-days <days>", "revisit recent occurrences for late transcripts (0–31)", lookbackDays, 0)
123
123
  .action(async (opts, command) => {
124
- if (!deps.source)
124
+ const source = deps.source;
125
+ if (!source)
125
126
  throw new MeetingError("configuration_error", "meeting source is unavailable");
126
- print(await pull(deps.source, deps.store, {
127
+ await deps.inRun(command, async () => print(await pull(source, deps.store, {
127
128
  accountId: deps.accountId,
128
129
  since: opts.sinceTime === undefined ? undefined : new Date(opts.sinceTime).toISOString(),
129
130
  now: deps.now?.(),
130
131
  lookbackMs: opts.lookbackDays * 86400000,
131
- }), command);
132
+ }), command));
132
133
  });
133
134
  const importCommand = annotate(group.command("import"), {
134
135
  effects: LOCAL_WRITE,