@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.
- package/CHANGELOG.md +79 -0
- package/dist/background/deliver.d.ts +7 -1
- package/dist/background/deliver.js +66 -10
- package/dist/background/telegram-html.d.ts +2 -0
- package/dist/background/telegram-html.js +66 -0
- package/dist/calendar/cli/index.js +4 -11
- package/dist/calendar/mcp/index.d.ts +12 -12
- package/dist/cli/context.d.ts +12 -0
- package/dist/cli/context.js +17 -3
- package/dist/cli/contract-templates.js +1 -0
- package/dist/cli/early-refusal.js +7 -5
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.js +1 -1
- package/dist/cli/permission-keys.d.ts +10 -2
- package/dist/cli/preview.d.ts +3 -0
- package/dist/cli/preview.js +9 -5
- package/dist/cli/program.js +3 -0
- package/dist/crm/cli/index.js +76 -17
- package/dist/crm/index.d.ts +1 -1
- package/dist/crm/index.js +1 -1
- package/dist/crm/source.d.ts +6 -1
- package/dist/crm/sync.d.ts +5 -4
- package/dist/crm/sync.js +22 -4
- package/dist/crm/testing/index.d.ts +1 -1
- package/dist/crm/testing/index.js +1 -1
- package/dist/crm/testing/source-contract.js +28 -0
- package/dist/crm/testing/source.d.ts +8 -1
- package/dist/crm/testing/source.js +53 -9
- package/dist/crm/testing/sync-contract.js +14 -3
- package/dist/crm/writer.d.ts +11 -2
- package/dist/crm/writer.js +31 -0
- package/dist/crm/writes.d.ts +12 -2
- package/dist/crm/writes.js +93 -8
- package/dist/domain/crm.d.ts +29 -1
- package/dist/entities/deal.d.ts +16 -0
- package/dist/entities/deal.js +8 -0
- package/dist/entities/index.d.ts +12 -0
- package/dist/errors/reasons.js +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/kit/index.d.ts +1 -0
- package/dist/kit/index.js +1 -0
- package/dist/kit/run-scope.d.ts +34 -0
- package/dist/kit/run-scope.js +112 -0
- package/dist/mcp/app-server.js +7 -2
- package/dist/meetings/cli/index.d.ts +5 -0
- package/dist/meetings/cli/index.js +4 -3
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/store/driver.d.ts +2 -0
- package/dist/store/driver.js +2 -0
- package/dist/store/search-index.js +6 -1
- package/dist/store/sqlite/accounts.js +1 -0
- package/dist/store/sqlite/crm.d.ts +5 -1
- package/dist/store/sqlite/crm.js +52 -0
- package/dist/store/sqlite/deals.d.ts +5 -0
- package/dist/store/sqlite/deals.js +13 -0
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +6 -0
- package/dist/store/sqlite/note-index.d.ts +1 -1
- package/dist/store/sqlite/note-index.js +3 -0
- package/dist/store/sqlite/schema.d.ts +217 -0
- package/dist/store/sqlite/schema.js +18 -0
- package/package.json +2 -2
package/dist/crm/writer.js
CHANGED
|
@@ -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.
|
package/dist/crm/writes.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/crm/writes.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/domain/crm.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/entities/deal.d.ts
CHANGED
|
@@ -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>;
|
package/dist/entities/deal.js
CHANGED
|
@@ -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"]),
|
package/dist/entities/index.d.ts
CHANGED
|
@@ -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>;
|
package/dist/errors/reasons.js
CHANGED
|
@@ -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";
|
package/dist/kit/index.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/mcp/app-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
124
|
+
const source = deps.source;
|
|
125
|
+
if (!source)
|
|
125
126
|
throw new MeetingError("configuration_error", "meeting source is unavailable");
|
|
126
|
-
print(await pull(
|
|
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,
|