@fleox-agent-crm/agentcrm 0.0.0-stage → 0.3.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/LICENSE +143 -0
- package/README.md +223 -2
- package/changelog.json +57 -0
- package/dist/admin/admin-files.json +26 -0
- package/dist/admin/assets/chartsImpl-CE2akJat.js +91 -0
- package/dist/admin/assets/firebase-BHSUggRo.js +2 -0
- package/dist/admin/assets/gitJson-BulGh2KV.js +3 -0
- package/dist/admin/assets/index-DidVVK7L.js +1181 -0
- package/dist/admin/assets/index-hMXwHSvN.css +1 -0
- package/dist/admin/assets/index.esm-BFc7vwnl.js +400 -0
- package/dist/admin/assets/index.esm-CtEQZRb1.js +1651 -0
- package/dist/admin/assets/index.esm-Dum0J07z.js +16 -0
- package/dist/admin/assets/index.esm2017-EOzKbpUS.js +390 -0
- package/dist/admin/assets/lucide-react.min-DEDlHCPj.js +6 -0
- package/dist/admin/assets/sdk-CnrLnlRo.js +1717 -0
- package/dist/admin/config.js +3 -0
- package/dist/admin/favicon.svg +1 -0
- package/dist/admin/index.html +139 -0
- package/dist/admin/runtime/agentcrm-runtime.js +104 -0
- package/dist/admin/runtime/lucide-react.js +791 -0
- package/dist/admin/runtime/react-dom-client.js +17 -0
- package/dist/admin/runtime/react-dom.js +20 -0
- package/dist/admin/runtime/react-i18next.js +19 -0
- package/dist/admin/runtime/react-jsx-runtime.js +17 -0
- package/dist/admin/runtime/react.js +22 -0
- package/dist/agent/agentcrm.mjs +3932 -0
- package/dist/agent/chunks/_commonjsHelpers-ClciMIcB.mjs +14 -0
- package/dist/agent/chunks/activities-Cj_SIOay.mjs +227 -0
- package/dist/agent/chunks/api-B6OqNVWp.mjs +97 -0
- package/dist/agent/chunks/appearance-BaShelzt.mjs +174 -0
- package/dist/agent/chunks/automations-BWrvLqP6.mjs +698 -0
- package/dist/agent/chunks/backup-ClSmxmFZ.mjs +103 -0
- package/dist/agent/chunks/batch-CVETiJj5.mjs +142 -0
- package/dist/agent/chunks/compact-CsFPaAAq.mjs +69 -0
- package/dist/agent/chunks/context-CxFfqS4D.mjs +65 -0
- package/dist/agent/chunks/core-Dy7vY9H6.mjs +255 -0
- package/dist/agent/chunks/dedupe-BepmsaHv.mjs +123 -0
- package/dist/agent/chunks/encoding-C9MzdP95.mjs +36 -0
- package/dist/agent/chunks/exportCsv-B5hRvkNm.mjs +95 -0
- package/dist/agent/chunks/extCheck--7lmVvl7.mjs +193 -0
- package/dist/agent/chunks/extPackage-BGl5Oqs8.mjs +115 -0
- package/dist/agent/chunks/extScaffold-DqxI7R1e.mjs +320 -0
- package/dist/agent/chunks/extensionActions-D2HtpQz4.mjs +48 -0
- package/dist/agent/chunks/extensions-BP1k60md.mjs +151 -0
- package/dist/agent/chunks/featureRegistry-C9nbVShp.mjs +149 -0
- package/dist/agent/chunks/fields-B0bitgmr.mjs +476 -0
- package/dist/agent/chunks/files-CjsPJSR6.mjs +158 -0
- package/dist/agent/chunks/files-q8Xu5bft.mjs +367 -0
- package/dist/agent/chunks/firebase-DxGVQV9E.mjs +454 -0
- package/dist/agent/chunks/firebaseRules-iori-s7U.mjs +221 -0
- package/dist/agent/chunks/forms-BKU5F9WF.mjs +847 -0
- package/dist/agent/chunks/gitJson-fjr8mcHe.mjs +528 -0
- package/dist/agent/chunks/hosting-Bme7LxPc.mjs +144 -0
- package/dist/agent/chunks/http-Bk9ICb7I.mjs +127 -0
- package/dist/agent/chunks/ids-CvnmrMJX.mjs +59 -0
- package/dist/agent/chunks/ids-u-noYcy5.mjs +14 -0
- package/dist/agent/chunks/importCsv-DmE_sBch.mjs +334 -0
- package/dist/agent/chunks/index-B-4W0-P4.mjs +535 -0
- package/dist/agent/chunks/index-BM8qeGOb.mjs +2489 -0
- package/dist/agent/chunks/index-Bnmav-Oh.mjs +311 -0
- package/dist/agent/chunks/index-CRkw3_iF.mjs +147 -0
- package/dist/agent/chunks/index-CWrrF1wl.mjs +111 -0
- package/dist/agent/chunks/index-DUSdTBkz.mjs +2988 -0
- package/dist/agent/chunks/index-RCmo-uld.mjs +26 -0
- package/dist/agent/chunks/index-w03ReRs4.mjs +9780 -0
- package/dist/agent/chunks/install-CpHoUFtL.mjs +188 -0
- package/dist/agent/chunks/jsx-runtime-Ca2uwGDr.mjs +76 -0
- package/dist/agent/chunks/loader-C9nm3iwx.mjs +184 -0
- package/dist/agent/chunks/lucide-react.min-Bt3gtepY.mjs +64 -0
- package/dist/agent/chunks/memory-WNR3VHUJ.mjs +56 -0
- package/dist/agent/chunks/migrate-CsClnnN8.mjs +182 -0
- package/dist/agent/chunks/modules-CqdVQVJb.mjs +177 -0
- package/dist/agent/chunks/nodeImport-BPCt5uMu.mjs +38 -0
- package/dist/agent/chunks/package-DWbn3t2R.mjs +194 -0
- package/dist/agent/chunks/papaparse-DjBYnQM2.mjs +1285 -0
- package/dist/agent/chunks/pipelines-DCdRBNBV.mjs +234 -0
- package/dist/agent/chunks/plan-C0TBrU92.mjs +254 -0
- package/dist/agent/chunks/pluginRegistry-DyHtlkcr.mjs +83 -0
- package/dist/agent/chunks/query-C5nJB_3C.mjs +192 -0
- package/dist/agent/chunks/records-DoqiT9xR.mjs +2191 -0
- package/dist/agent/chunks/registry-Ceexawxl.mjs +182 -0
- package/dist/agent/chunks/registry-DijAKZ24.mjs +431 -0
- package/dist/agent/chunks/relations-B9TynBmA.mjs +191 -0
- package/dist/agent/chunks/reports-BB59N6ke.mjs +189 -0
- package/dist/agent/chunks/runtime-DHo7ZEEO.mjs +14852 -0
- package/dist/agent/chunks/runtimeConfig-CCOFg1RM.mjs +250 -0
- package/dist/agent/chunks/s3-Dz50-NuA.mjs +903 -0
- package/dist/agent/chunks/schema-7-4-qtSS.mjs +203 -0
- package/dist/agent/chunks/schema-DlHt2PxI.mjs +367 -0
- package/dist/agent/chunks/sdk-whvfg786.mjs +43644 -0
- package/dist/agent/chunks/seed-CugPGYZL.mjs +523 -0
- package/dist/agent/chunks/server-ISwlDRCN.mjs +242 -0
- package/dist/agent/chunks/settings-BtvR_jdm.mjs +76 -0
- package/dist/agent/chunks/standardObjects-CheqfDqa.mjs +243 -0
- package/dist/agent/chunks/stats-CfeJssSS.mjs +51 -0
- package/dist/agent/chunks/store-5gqRSKXg.mjs +122 -0
- package/dist/agent/chunks/themes-DPZFv3yc.mjs +43 -0
- package/dist/agent/chunks/tools-Bew8lxKx.mjs +844 -0
- package/dist/agent/chunks/types-BOy9PUl7.mjs +44 -0
- package/dist/agent/chunks/users-DI1BvS0f.mjs +107 -0
- package/dist/agent/chunks/utils-DmI8FtXP.mjs +83 -0
- package/dist/agent/chunks/views-fO_BM5U_.mjs +109 -0
- package/dist/version.json +7 -0
- package/package.json +97 -4
- package/plugin/.claude-plugin/plugin.json +17 -0
- package/plugin/skills/agentcrm-admin/SKILL.md +191 -0
- package/plugin/skills/agentcrm-admin/references/appearance.md +26 -0
- package/plugin/skills/agentcrm-admin/references/import.md +16 -0
- package/plugin/skills/agentcrm-admin/references/records.md +30 -0
- package/plugin/skills/agentcrm-admin/references/schema.md +59 -0
- package/plugin/skills/agentcrm-extensions/SKILL.md +93 -0
- package/plugin/skills/agentcrm-extensions/references/module-recipes.md +213 -0
- package/plugin/skills/agentcrm-extensions/references/runtime-api.md +41 -0
- package/plugin/skills/agentcrm-extensions/references/testing.md +76 -0
- package/plugin/skills/agentcrm-extensions/references/theme-recipes.md +65 -0
- package/plugin/skills/agentcrm-install/SKILL.md +116 -0
- package/plugin/skills/agentcrm-install/references/firebase.md +18 -0
- package/plugin/skills/agentcrm-install/references/github.md +20 -0
- package/plugin/skills/agentcrm-install/references/mcp.md +17 -0
- package/plugin/skills/agentcrm-install/references/plans.md +45 -0
- package/plugin/skills/agentcrm-install/references/s3.md +24 -0
- package/plugin/skills/agentcrm-manage/SKILL.md +108 -0
- package/plugin/skills/agentcrm-manage/references/cron.md +11 -0
- package/plugin/skills/agentcrm-manage/references/doctor.md +18 -0
- package/plugin/skills/agentcrm-sales/SKILL.md +92 -0
- package/plugin/skills/agentcrm-sales/references/enrichment.md +13 -0
- package/plugin/skills/agentcrm-sales/references/playbooks.md +29 -0
- package/plugin/skills/agentcrm-sales/references/weekly-report.md +27 -0
- package/types/agentcrm-runtime.d.ts +894 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createRequire as __acrmRequire } from "node:module";
|
|
2
|
+
import { fileURLToPath as __acrmPath } from "node:url";
|
|
3
|
+
import { dirname as __acrmDirname } from "node:path";
|
|
4
|
+
const require2 = __acrmRequire(import.meta.url);
|
|
5
|
+
const __filename = __acrmPath(import.meta.url);
|
|
6
|
+
const __dirname = __acrmDirname(__filename);
|
|
7
|
+
import { dateValueToMs, colorAt } from "./fields-B0bitgmr.mjs";
|
|
8
|
+
import { s as slugId } from "./ids-CvnmrMJX.mjs";
|
|
9
|
+
import { s as stageOptions, p as pipelineOptions } from "./standardObjects-CheqfDqa.mjs";
|
|
10
|
+
import { assertCan, OpsError } from "./context-CxFfqS4D.mjs";
|
|
11
|
+
import { updateRecord } from "./records-DoqiT9xR.mjs";
|
|
12
|
+
import { requireRecord, pipelinesOf, requireObject, requirePipeline, recordsOf, asDoc, thaw } from "./store-5gqRSKXg.mjs";
|
|
13
|
+
import "./pluginRegistry-DyHtlkcr.mjs";
|
|
14
|
+
import "./query-C5nJB_3C.mjs";
|
|
15
|
+
import "./relations-B9TynBmA.mjs";
|
|
16
|
+
function listPipelines(ctx, object) {
|
|
17
|
+
assertCan(ctx, "records.read");
|
|
18
|
+
return pipelinesOf(ctx.store, object);
|
|
19
|
+
}
|
|
20
|
+
function getPipeline(ctx, id) {
|
|
21
|
+
assertCan(ctx, "records.read");
|
|
22
|
+
return requirePipeline(ctx.store, id);
|
|
23
|
+
}
|
|
24
|
+
function pipelineOfRecord(ctx, record) {
|
|
25
|
+
const all = pipelinesOf(ctx.store, record.object);
|
|
26
|
+
const byField = all.find((p) => p.id === record.fields.pipeline);
|
|
27
|
+
if (byField) return byField;
|
|
28
|
+
return all.find((p) => p.stages.some((s) => s.id === record.fields[p.stageField])) ?? all[0];
|
|
29
|
+
}
|
|
30
|
+
function syncPipelineOptions(ctx, objectId, pipelines) {
|
|
31
|
+
const object = requireObject(ctx.store, objectId);
|
|
32
|
+
const stageFields = new Set(pipelines.map((p) => p.stageField));
|
|
33
|
+
let touched = false;
|
|
34
|
+
const fields = object.fields.map((f) => {
|
|
35
|
+
if (stageFields.has(f.id)) {
|
|
36
|
+
touched = true;
|
|
37
|
+
return { ...thaw(f), options: stageOptions(pipelines.filter((p) => p.stageField === f.id)) };
|
|
38
|
+
}
|
|
39
|
+
if (f.id === "pipeline" && f.type === "select") {
|
|
40
|
+
touched = true;
|
|
41
|
+
return { ...thaw(f), options: pipelineOptions(pipelines) };
|
|
42
|
+
}
|
|
43
|
+
return f;
|
|
44
|
+
});
|
|
45
|
+
if (!touched) return null;
|
|
46
|
+
const next = { ...object, fields, updatedAt: ctx.now() };
|
|
47
|
+
return { op: "set", collection: "objects", doc: asDoc(next) };
|
|
48
|
+
}
|
|
49
|
+
function normalizeStages(ctx, stages, selfId) {
|
|
50
|
+
if (!stages.length) throw new OpsError("validation", "A pipeline needs at least one stage.");
|
|
51
|
+
const taken = new Set(pipelinesOf(ctx.store).filter((p) => p.id !== selfId).flatMap((p) => p.stages.map((s) => s.id)));
|
|
52
|
+
const out = [];
|
|
53
|
+
stages.forEach((s, i) => {
|
|
54
|
+
const label = s.label?.trim();
|
|
55
|
+
if (!label) throw new OpsError("validation", `Stage ${i + 1} needs a label.`);
|
|
56
|
+
const id = s.id && !taken.has(s.id) && !out.some((x) => x.id === s.id) ? s.id : slugId(label, [...taken, ...out.map((x) => x.id)]);
|
|
57
|
+
const kind = s.kind ?? (/^(won|gagn)/i.test(label) ? "won" : /^(lost|perdu)/i.test(label) ? "lost" : "open");
|
|
58
|
+
const probability = Math.max(0, Math.min(100, Math.round(s.probability ?? (kind === "won" ? 100 : kind === "lost" ? 0 : Math.round((i + 1) / stages.length * 80)))));
|
|
59
|
+
out.push({ id, label, color: s.color ?? colorAt(i), probability, kind });
|
|
60
|
+
});
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
async function createPipeline(ctx, input) {
|
|
64
|
+
assertCan(ctx, "pipelines.write");
|
|
65
|
+
const object = requireObject(ctx.store, input.object ?? "deals");
|
|
66
|
+
const stageField = input.stageField ?? "stage";
|
|
67
|
+
const field = object.fields.find((f) => f.id === stageField);
|
|
68
|
+
if (!field || field.type !== "select") throw new OpsError("validation", `${object.labelPlural} has no select field "${stageField}" to hold stages.`);
|
|
69
|
+
const name = input.name?.trim();
|
|
70
|
+
if (!name) throw new OpsError("validation", "A pipeline needs a name.");
|
|
71
|
+
const id = input.id ?? slugId(name, pipelinesOf(ctx.store).map((p) => p.id));
|
|
72
|
+
const pipeline = { id, object: object.id, name, stageField, stages: normalizeStages(ctx, input.stages, null) };
|
|
73
|
+
const all = [...pipelinesOf(ctx.store, object.id), pipeline];
|
|
74
|
+
const sync = syncPipelineOptions(ctx, object.id, all);
|
|
75
|
+
await ctx.store.write([{ op: "set", collection: "pipelines", doc: asDoc(pipeline) }, ...sync ? [sync] : []], { message: `Create pipeline ${name}` });
|
|
76
|
+
return requirePipeline(ctx.store, id);
|
|
77
|
+
}
|
|
78
|
+
async function updatePipeline(ctx, id, patch) {
|
|
79
|
+
assertCan(ctx, "pipelines.write");
|
|
80
|
+
const before = requirePipeline(ctx.store, id);
|
|
81
|
+
const next = thaw(before);
|
|
82
|
+
if (patch.name !== void 0) {
|
|
83
|
+
if (!patch.name.trim()) throw new OpsError("validation", "A pipeline needs a name.");
|
|
84
|
+
next.name = patch.name.trim();
|
|
85
|
+
}
|
|
86
|
+
const mutations = [];
|
|
87
|
+
if (patch.stages) {
|
|
88
|
+
next.stages = normalizeStages(ctx, patch.stages, id);
|
|
89
|
+
const kept = new Set(next.stages.map((s) => s.id));
|
|
90
|
+
const removed = before.stages.filter((s) => !kept.has(s.id)).map((s) => s.id);
|
|
91
|
+
if (removed.length) {
|
|
92
|
+
const fallback = patch.moveRemovedTo ?? next.stages.find((s) => s.kind === "open")?.id ?? next.stages[0].id;
|
|
93
|
+
if (!kept.has(fallback)) throw new OpsError("validation", `Unknown stage "${fallback}".`);
|
|
94
|
+
const target = next.stages.find((s) => s.id === fallback);
|
|
95
|
+
for (const r of recordsOf(ctx.store, before.object, { includeDeleted: true })) {
|
|
96
|
+
if (removed.includes(r.fields[before.stageField])) {
|
|
97
|
+
mutations.push({ op: "patch", collection: "records", id: r.id, fields: { [`fields.${before.stageField}`]: fallback, "fields.probability": target.probability, updatedAt: ctx.now() } });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
mutations.unshift({ op: "set", collection: "pipelines", doc: asDoc(next) });
|
|
103
|
+
const sync = syncPipelineOptions(ctx, before.object, pipelinesOf(ctx.store, before.object).map((p) => p.id === id ? next : p));
|
|
104
|
+
if (sync) mutations.push(sync);
|
|
105
|
+
await ctx.store.write(mutations, { message: `Update pipeline ${next.name}` });
|
|
106
|
+
return requirePipeline(ctx.store, id);
|
|
107
|
+
}
|
|
108
|
+
async function deletePipeline(ctx, id, opts = {}) {
|
|
109
|
+
assertCan(ctx, "pipelines.write");
|
|
110
|
+
const pipeline = requirePipeline(ctx.store, id);
|
|
111
|
+
const others = pipelinesOf(ctx.store, pipeline.object).filter((p) => p.id !== id);
|
|
112
|
+
if (!others.length) throw new OpsError("invalid", "The last pipeline of an object can't be deleted.");
|
|
113
|
+
const target = others.find((p) => p.id === opts.moveTo) ?? others[0];
|
|
114
|
+
const stageIds = new Set(pipeline.stages.map((s) => s.id));
|
|
115
|
+
const mutations = [{ op: "delete", collection: "pipelines", id }];
|
|
116
|
+
let moved = 0;
|
|
117
|
+
for (const r of recordsOf(ctx.store, pipeline.object, { includeDeleted: true })) {
|
|
118
|
+
if (r.fields.pipeline === id || stageIds.has(r.fields[pipeline.stageField])) {
|
|
119
|
+
moved++;
|
|
120
|
+
mutations.push({
|
|
121
|
+
op: "patch",
|
|
122
|
+
collection: "records",
|
|
123
|
+
id: r.id,
|
|
124
|
+
fields: { "fields.pipeline": target.id, [`fields.${target.stageField}`]: target.stages[0].id, "fields.probability": target.stages[0].probability, updatedAt: ctx.now() }
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const sync = syncPipelineOptions(ctx, pipeline.object, others);
|
|
129
|
+
if (sync) mutations.push(sync);
|
|
130
|
+
for (const v of ctx.store.list("views")) {
|
|
131
|
+
if (v.pipelineId === id) mutations.push({ op: "patch", collection: "views", id: v.id, fields: { pipelineId: target.id } });
|
|
132
|
+
}
|
|
133
|
+
await ctx.store.write(mutations, { message: `Delete pipeline ${pipeline.name}` });
|
|
134
|
+
return { id, moved };
|
|
135
|
+
}
|
|
136
|
+
async function moveStage(ctx, recordId, stage, opts = {}) {
|
|
137
|
+
assertCan(ctx, "records.write");
|
|
138
|
+
const record = requireRecord(ctx.store, recordId);
|
|
139
|
+
const pipelines = pipelinesOf(ctx.store, record.object);
|
|
140
|
+
if (!pipelines.length) throw new OpsError("invalid", `${record.object} records have no pipeline.`);
|
|
141
|
+
const current = pipelineOfRecord(ctx, record);
|
|
142
|
+
const lower = stage.trim().toLowerCase();
|
|
143
|
+
const find = (p) => p.stages.find((s) => s.id === stage) ?? p.stages.find((s) => s.label.toLowerCase() === lower);
|
|
144
|
+
const ordered = current ? [current, ...pipelines.filter((p) => p !== current)] : pipelines;
|
|
145
|
+
let pipeline;
|
|
146
|
+
let target;
|
|
147
|
+
for (const p of ordered) {
|
|
148
|
+
target = find(p);
|
|
149
|
+
if (target) {
|
|
150
|
+
pipeline = p;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!pipeline || !target) {
|
|
155
|
+
throw new OpsError("validation", `Unknown stage "${stage}". Stages: ${(current ?? pipelines[0]).stages.map((s) => s.label).join(", ")}.`);
|
|
156
|
+
}
|
|
157
|
+
const input = { [pipeline.stageField]: target.id, probability: target.probability };
|
|
158
|
+
const object = requireObject(ctx.store, record.object);
|
|
159
|
+
if (!object.fields.some((f) => f.id === "probability")) delete input.probability;
|
|
160
|
+
if (object.fields.some((f) => f.id === "pipeline")) input.pipeline = pipeline.id;
|
|
161
|
+
if (opts.lostReason !== void 0 && object.fields.some((f) => f.id === "lostReason")) input.lostReason = opts.lostReason;
|
|
162
|
+
if (target.kind !== "open" && object.fields.some((f) => f.id === "closeDate") && !record.fields.closeDate) {
|
|
163
|
+
input.closeDate = new Date(ctx.now()).toISOString().slice(0, 10);
|
|
164
|
+
}
|
|
165
|
+
return updateRecord(ctx, recordId, input, { message: `Move to ${target.label}` });
|
|
166
|
+
}
|
|
167
|
+
function amountOf(r) {
|
|
168
|
+
return typeof r.fields.amount === "number" ? r.fields.amount : 0;
|
|
169
|
+
}
|
|
170
|
+
function forecast(ctx, opts = {}) {
|
|
171
|
+
assertCan(ctx, "reports.read");
|
|
172
|
+
const all = pipelinesOf(ctx.store);
|
|
173
|
+
const pipeline = opts.pipelineId ? requirePipeline(ctx.store, opts.pipelineId) : all.find((p) => p.object === "deals") ?? all[0];
|
|
174
|
+
if (!pipeline) throw new OpsError("notFound", "No pipeline.");
|
|
175
|
+
const object = requireObject(ctx.store, pipeline.object);
|
|
176
|
+
const from = opts.from !== void 0 ? dateValueToMs(opts.from) : null;
|
|
177
|
+
const to = opts.to !== void 0 ? dateValueToMs(opts.to) : null;
|
|
178
|
+
const records = recordsOf(ctx.store, pipeline.object).filter((r) => {
|
|
179
|
+
if (opts.ownerId && r.ownerId !== opts.ownerId) return false;
|
|
180
|
+
if (pipelineOfRecord(ctx, r)?.id !== pipeline.id) return false;
|
|
181
|
+
const close = dateValueToMs(r.fields.closeDate);
|
|
182
|
+
if (from !== null && (close === null || close < from)) return false;
|
|
183
|
+
if (to !== null && (close === null || close > to)) return false;
|
|
184
|
+
return true;
|
|
185
|
+
});
|
|
186
|
+
const byStage = pipeline.stages.map((s) => ({ stageId: s.id, label: s.label, kind: s.kind, probability: s.probability, count: 0, amount: 0, weighted: 0 }));
|
|
187
|
+
const months = /* @__PURE__ */ new Map();
|
|
188
|
+
let wonAmount = 0;
|
|
189
|
+
let lostAmount = 0;
|
|
190
|
+
for (const r of records) {
|
|
191
|
+
const stage = byStage.find((s) => s.stageId === r.fields[pipeline.stageField]);
|
|
192
|
+
if (!stage) continue;
|
|
193
|
+
const amount = amountOf(r);
|
|
194
|
+
const probability = typeof r.fields.probability === "number" ? r.fields.probability : stage.probability;
|
|
195
|
+
const weighted = stage.kind === "won" ? amount : stage.kind === "lost" ? 0 : amount * probability / 100;
|
|
196
|
+
stage.count++;
|
|
197
|
+
stage.amount += amount;
|
|
198
|
+
stage.weighted += weighted;
|
|
199
|
+
if (stage.kind === "won") wonAmount += amount;
|
|
200
|
+
else if (stage.kind === "lost") lostAmount += amount;
|
|
201
|
+
else {
|
|
202
|
+
const close = typeof r.fields.closeDate === "string" ? r.fields.closeDate.slice(0, 7) : "none";
|
|
203
|
+
const m = months.get(close) ?? { month: close, count: 0, amount: 0, weighted: 0 };
|
|
204
|
+
m.count++;
|
|
205
|
+
m.amount += amount;
|
|
206
|
+
m.weighted += weighted;
|
|
207
|
+
months.set(close, m);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const open = byStage.filter((s) => s.kind === "open");
|
|
211
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
212
|
+
return {
|
|
213
|
+
pipelineId: pipeline.id,
|
|
214
|
+
currency: object.fields.find((f) => f.id === "amount")?.currency,
|
|
215
|
+
openCount: open.reduce((a, s) => a + s.count, 0),
|
|
216
|
+
openAmount: round(open.reduce((a, s) => a + s.amount, 0)),
|
|
217
|
+
weightedAmount: round(open.reduce((a, s) => a + s.weighted, 0)),
|
|
218
|
+
wonAmount: round(wonAmount),
|
|
219
|
+
lostAmount: round(lostAmount),
|
|
220
|
+
byStage: byStage.map((s) => ({ ...s, amount: round(s.amount), weighted: round(s.weighted) })),
|
|
221
|
+
byMonth: [...months.values()].sort((a, b) => a.month === "none" ? 1 : b.month === "none" ? -1 : a.month < b.month ? -1 : 1).map((m) => ({ ...m, amount: round(m.amount), weighted: round(m.weighted) }))
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
export {
|
|
225
|
+
createPipeline,
|
|
226
|
+
deletePipeline,
|
|
227
|
+
forecast,
|
|
228
|
+
getPipeline,
|
|
229
|
+
listPipelines,
|
|
230
|
+
moveStage,
|
|
231
|
+
pipelineOfRecord,
|
|
232
|
+
syncPipelineOptions,
|
|
233
|
+
updatePipeline
|
|
234
|
+
};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createRequire as __acrmRequire } from "node:module";
|
|
2
|
+
import { fileURLToPath as __acrmPath } from "node:url";
|
|
3
|
+
import { dirname as __acrmDirname } from "node:path";
|
|
4
|
+
const require2 = __acrmRequire(import.meta.url);
|
|
5
|
+
const __filename = __acrmPath(import.meta.url);
|
|
6
|
+
const __dirname = __acrmDirname(__filename);
|
|
7
|
+
import { isAbsolute, resolve } from "node:path";
|
|
8
|
+
import { splitSecretSettings } from "./runtimeConfig-CCOFg1RM.mjs";
|
|
9
|
+
import { v as validateAlias, f as findBackend, a as parseAccount, g as getCredential, b as requireCredential, C as CliError, c as findLocation, L as LOCATIONS, d as findHosting, H as HOSTINGS } from "../agentcrm.mjs";
|
|
10
|
+
import "node:util";
|
|
11
|
+
import "node:fs";
|
|
12
|
+
import "node:url";
|
|
13
|
+
import "node:crypto";
|
|
14
|
+
import "node:os";
|
|
15
|
+
import "node:child_process";
|
|
16
|
+
import "node:http";
|
|
17
|
+
const SECRET_KEYS = ["token", "apiKey", "accessKeyId", "secretAccessKey", "passphrase", "password"];
|
|
18
|
+
function fail(message, hint) {
|
|
19
|
+
throw new CliError("invalid-plan", message, hint ?? "Run `agentcrm options --json` for the expected fields.", 2);
|
|
20
|
+
}
|
|
21
|
+
function str(v) {
|
|
22
|
+
return typeof v === "string" ? v.trim() : "";
|
|
23
|
+
}
|
|
24
|
+
function cleanFolder(folder) {
|
|
25
|
+
return folder.trim().replace(/^\/+|\/+$/g, "");
|
|
26
|
+
}
|
|
27
|
+
function copySettings(spec, field) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const [k, v] of Object.entries(spec.settings ?? {})) {
|
|
30
|
+
if (v === void 0 || v === null) continue;
|
|
31
|
+
if (SECRET_KEYS.includes(k)) fail(`${field}.settings.${k} must not be in the plan — store it with \`agentcrm credentials set\` and reference the account in ${field}.credential.`);
|
|
32
|
+
out[k] = typeof v === "string" ? v.trim() : v;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function credentialValues(account, allowed, field) {
|
|
37
|
+
const { type } = parseAccount(account);
|
|
38
|
+
if (!allowed.includes(type)) fail(`${field}.credential must be a ${allowed.join(" / ")} account, got ${type}.`);
|
|
39
|
+
return requireCredential(account).values;
|
|
40
|
+
}
|
|
41
|
+
function resolveLocation(spec, field = "data.location") {
|
|
42
|
+
const type = str(spec?.type);
|
|
43
|
+
const loc = findLocation(type);
|
|
44
|
+
if (!loc) fail(`Unknown ${field}.type "${type}" (one of ${LOCATIONS.map((l) => l.id).join(", ")}).`);
|
|
45
|
+
const s = copySettings(spec, field);
|
|
46
|
+
if (type === "local-folder") {
|
|
47
|
+
const path = str(s.path);
|
|
48
|
+
if (!path || !isAbsolute(path)) fail(`${field}.settings.path must be an absolute folder path.`);
|
|
49
|
+
return { target: { connectorId: "local-folder", settings: { handleKey: "data-folder", path: resolve(path) } }, type, path: resolve(path) };
|
|
50
|
+
}
|
|
51
|
+
const account = str(spec.credential);
|
|
52
|
+
if (!account) fail(`${field}.credential is required (${loc.credentialTypes.join(" / ")} account).`);
|
|
53
|
+
const secrets = credentialValues(account, loc.credentialTypes, field);
|
|
54
|
+
if (type === "s3") {
|
|
55
|
+
if (!str(s.bucket)) fail(`${field}.settings.bucket is required.`);
|
|
56
|
+
if (!str(s.region) && !str(s.endpoint)) fail(`${field}.settings.region is required.`);
|
|
57
|
+
return { target: { connectorId: "s3", settings: { region: str(s.region) || "us-east-1", ...s, siteUrl: "", ...secrets } }, account, type };
|
|
58
|
+
}
|
|
59
|
+
if (type === "github" && (!str(s.owner) || !str(s.repo))) fail(`${field}.settings.owner and .repo are required.`);
|
|
60
|
+
if (type === "gitlab" && !str(s.project)) fail(`${field}.settings.project is required.`);
|
|
61
|
+
return {
|
|
62
|
+
target: { connectorId: "git-pages", settings: { ...s, provider: type, branch: str(s.branch) || "main", host: "other", siteUrl: "", ...secrets } },
|
|
63
|
+
account,
|
|
64
|
+
type
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function resolveHosting(spec, reuse) {
|
|
68
|
+
const type = str(spec?.type);
|
|
69
|
+
const h = findHosting(type);
|
|
70
|
+
if (!h) fail(`Unknown hosting.type "${type}" (one of ${HOSTINGS.map((x) => x.id).join(", ")}).`);
|
|
71
|
+
if (!h.cli) fail(`hosting "${type}" can't be set up from the CLI.`, h.notes.join(" "));
|
|
72
|
+
const s = copySettings(spec, "hosting");
|
|
73
|
+
const folder = cleanFolder(str(spec.folder) || "admin");
|
|
74
|
+
if (!/^[\w.-]+(\/[\w.-]+)*$/.test(folder)) fail("hosting.folder must be a relative folder name, e.g. admin.");
|
|
75
|
+
let settings;
|
|
76
|
+
let account = str(spec.credential);
|
|
77
|
+
if (type === "local-folder") {
|
|
78
|
+
const path = str(s.path);
|
|
79
|
+
if (!path || !isAbsolute(path)) fail("hosting.settings.path must be an absolute folder path.");
|
|
80
|
+
settings = { path: resolve(path), siteUrl: str(s.siteUrl) };
|
|
81
|
+
account = "";
|
|
82
|
+
} else {
|
|
83
|
+
if (type === "s3") {
|
|
84
|
+
if (!str(s.bucket)) fail("hosting.settings.bucket is required.");
|
|
85
|
+
settings = { region: str(s.region) || "us-east-1", ...s };
|
|
86
|
+
} else {
|
|
87
|
+
const provider = type === "github-pages" ? "github" : type === "gitlab-pages" ? "gitlab" : str(s.provider) || "github";
|
|
88
|
+
if (provider === "github" && (!str(s.owner) || !str(s.repo))) fail("hosting.settings.owner and .repo are required.");
|
|
89
|
+
if (provider === "gitlab" && !str(s.project)) fail("hosting.settings.project is required.");
|
|
90
|
+
const siteUrl2 = str(s.siteUrl) || (type === "github-pages" ? `https://${str(s.owner).toLowerCase()}.github.io/${str(s.repo)}` : "");
|
|
91
|
+
settings = {
|
|
92
|
+
...s,
|
|
93
|
+
provider,
|
|
94
|
+
branch: str(s.branch) || "main",
|
|
95
|
+
dir: cleanFolder(str(s.dir) || (type === "gitlab-pages" ? "public" : "")),
|
|
96
|
+
host: type === "cloudflare-pages" ? "cloudflare-pages" : type,
|
|
97
|
+
siteUrl: siteUrl2
|
|
98
|
+
};
|
|
99
|
+
if (!account && reuse && parseAccount(reuse).type === provider) account = reuse;
|
|
100
|
+
}
|
|
101
|
+
if (!account) fail(`hosting.credential is required (${h.credentialTypes.join(" / ")} account).`);
|
|
102
|
+
}
|
|
103
|
+
const siteUrl = str(settings.siteUrl).replace(/\/+$/, "");
|
|
104
|
+
if (type !== "local-folder" && !/^https?:\/\/[^/]+/.test(siteUrl)) fail("hosting.settings.siteUrl must be an http(s) URL.");
|
|
105
|
+
if (siteUrl) settings.siteUrl = siteUrl;
|
|
106
|
+
const secrets = account ? credentialValues(account, h.credentialTypes, "hosting") : {};
|
|
107
|
+
const publicSettings = Object.fromEntries(Object.entries(splitSecretSettings(settings).publicSettings));
|
|
108
|
+
return {
|
|
109
|
+
target: { connectorId: h.connectorId, settings: { ...settings, ...secrets } },
|
|
110
|
+
hosting: { type, connectorId: h.connectorId, settings: publicSettings, folder },
|
|
111
|
+
adminUrl: siteUrl ? `${siteUrl}/${folder}/` : null,
|
|
112
|
+
account: account || void 0
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function resolvePlan(plan, opts = {}) {
|
|
116
|
+
if (!plan || typeof plan !== "object") fail("The plan must be a JSON object.");
|
|
117
|
+
const alias = validateAlias(str(plan.alias));
|
|
118
|
+
const name = str(plan.name) || alias;
|
|
119
|
+
const ownerEmail = str(plan.owner?.email).toLowerCase();
|
|
120
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ownerEmail)) fail("owner.email (the first CRM admin) is required.", 'e.g. "owner": { "name": "Ada Lovelace", "email": "ada@acme.com" }');
|
|
121
|
+
const owner = { name: str(plan.owner?.name) || ownerEmail.split("@")[0], email: ownerEmail };
|
|
122
|
+
const backendId = str(plan.data?.backend);
|
|
123
|
+
const backend = findBackend(backendId);
|
|
124
|
+
if (!backend) fail(`Unknown data.backend "${backendId}" (journal, git-json, firebase).`);
|
|
125
|
+
if (!backend.cli) fail(`Data backend "${backend.id}" can't be set up from the CLI.`, backend.notes.join(" "));
|
|
126
|
+
const credentials = {};
|
|
127
|
+
const warnings = [];
|
|
128
|
+
let runtime;
|
|
129
|
+
let dataTarget;
|
|
130
|
+
let dataType;
|
|
131
|
+
let dataPath;
|
|
132
|
+
let backendSecrets;
|
|
133
|
+
let passphraseAccount;
|
|
134
|
+
let passphrase;
|
|
135
|
+
if (plan.data.backend === "journal") {
|
|
136
|
+
const data = plan.data;
|
|
137
|
+
const loc = resolveLocation(data.location);
|
|
138
|
+
dataTarget = loc.target;
|
|
139
|
+
dataType = loc.type;
|
|
140
|
+
dataPath = loc.path;
|
|
141
|
+
if (loc.account) credentials.data = loc.account;
|
|
142
|
+
const root = cleanFolder(str(data.root) || "agentcrm");
|
|
143
|
+
const encrypted = data.encryption !== false;
|
|
144
|
+
if (encrypted) {
|
|
145
|
+
const enc = data.encryption === void 0 ? {} : data.encryption;
|
|
146
|
+
passphraseAccount = str(enc.credential) || `passphrase:${alias}`;
|
|
147
|
+
if (parseAccount(passphraseAccount).type !== "passphrase") fail("data.encryption.credential must be a passphrase account.");
|
|
148
|
+
const stored = getCredential(passphraseAccount)?.values.passphrase;
|
|
149
|
+
if (!stored && enc.credential) requireCredential(passphraseAccount);
|
|
150
|
+
passphrase = stored;
|
|
151
|
+
credentials.passphrase = passphraseAccount;
|
|
152
|
+
} else {
|
|
153
|
+
warnings.push("The dataset is NOT encrypted: anyone who can read the storage reads every contact. Keep the location private.");
|
|
154
|
+
}
|
|
155
|
+
const publicSettings = splitSecretSettings(dataTarget.settings).publicSettings;
|
|
156
|
+
delete publicSettings.path;
|
|
157
|
+
delete publicSettings.siteUrl;
|
|
158
|
+
runtime = { backend: "journal", name, journal: { connectorId: dataTarget.connectorId, settings: publicSettings, root, encrypted } };
|
|
159
|
+
} else if (plan.data.backend === "git-json") {
|
|
160
|
+
const g = plan.data.git ?? {};
|
|
161
|
+
const provider = str(g.provider);
|
|
162
|
+
if (provider !== "github" && provider !== "gitlab") fail('data.git.provider must be "github" or "gitlab".');
|
|
163
|
+
if (provider === "github" && (!str(g.owner) || !str(g.repo))) fail("data.git.owner and data.git.repo are required for GitHub.");
|
|
164
|
+
if (provider === "gitlab" && !str(g.project)) fail("data.git.project is required for GitLab.");
|
|
165
|
+
const account = str(plan.data.credential);
|
|
166
|
+
if (!account) fail("data.credential is required (e.g. github:acme).");
|
|
167
|
+
backendSecrets = credentialValues(account, [provider], "data");
|
|
168
|
+
credentials.data = account;
|
|
169
|
+
runtime = {
|
|
170
|
+
backend: "git-json",
|
|
171
|
+
name,
|
|
172
|
+
git: {
|
|
173
|
+
provider,
|
|
174
|
+
...provider === "github" ? { owner: str(g.owner), repo: str(g.repo) } : { project: str(g.project) },
|
|
175
|
+
branch: str(g.branch) || "main",
|
|
176
|
+
...str(g.apiBaseUrl) ? { apiBaseUrl: str(g.apiBaseUrl) } : {},
|
|
177
|
+
dir: cleanFolder(str(g.dir))
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
warnings.push("git-json keeps every past value in the repository history: deleted records stay readable there. Keep the repository private.");
|
|
181
|
+
} else {
|
|
182
|
+
const fb = plan.data.firebase ?? {};
|
|
183
|
+
for (const k of ["apiKey", "authDomain", "projectId", "appId"]) if (!str(fb[k])) fail(`data.firebase.${k} is required (Firebase console → Project settings → Your apps → config).`);
|
|
184
|
+
const account = str(plan.data.credential);
|
|
185
|
+
if (!account) fail("data.credential is required (firebase:<name>, email + password of the first admin).");
|
|
186
|
+
backendSecrets = credentialValues(account, ["firebase"], "data");
|
|
187
|
+
credentials.data = account;
|
|
188
|
+
const methods = Array.isArray(plan.data.signInMethods) ? plan.data.signInMethods.filter((m) => m === "password" || m === "google") : [];
|
|
189
|
+
runtime = {
|
|
190
|
+
backend: "firebase",
|
|
191
|
+
name,
|
|
192
|
+
firebase: Object.fromEntries(["apiKey", "authDomain", "projectId", "storageBucket", "messagingSenderId", "appId", "measurementId"].filter((k) => str(fb[k])).map((k) => [k, str(fb[k])])),
|
|
193
|
+
...methods.length ? { signInMethods: [.../* @__PURE__ */ new Set(["password", ...methods])] } : {}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
let hostingTarget = null;
|
|
197
|
+
let hosting = null;
|
|
198
|
+
let adminUrl = null;
|
|
199
|
+
if (plan.hosting) {
|
|
200
|
+
const h = resolveHosting(plan.hosting, credentials.data);
|
|
201
|
+
hostingTarget = h.target;
|
|
202
|
+
hosting = h.hosting;
|
|
203
|
+
adminUrl = h.adminUrl;
|
|
204
|
+
if (h.account) credentials.hosting = h.account;
|
|
205
|
+
if (adminUrl && !adminUrl.startsWith("https://") && !/^http:\/\/(localhost|127\.0\.0\.1)/.test(adminUrl)) {
|
|
206
|
+
warnings.push(`${adminUrl} is plain HTTP: browsers only allow the admin's request signing and encryption on HTTPS pages. Put HTTPS (a CDN) in front.`);
|
|
207
|
+
}
|
|
208
|
+
if (dataTarget && hostingTarget.connectorId === "s3" && dataTarget.connectorId === "s3" && hostingTarget.settings.bucket === dataTarget.settings.bucket && hostingTarget.settings.endpoint === dataTarget.settings.endpoint) {
|
|
209
|
+
warnings.push(
|
|
210
|
+
runtime.backend === "journal" && runtime.journal.encrypted ? "The data lives in the bucket that serves the admin: the encrypted files are downloadable by anyone. Prefer a separate private bucket." : "The data lives UNENCRYPTED in the public bucket that serves the admin: anyone can download every contact. Use a separate private bucket or encryption."
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
alias,
|
|
216
|
+
name,
|
|
217
|
+
owner,
|
|
218
|
+
backend: runtime.backend,
|
|
219
|
+
runtime,
|
|
220
|
+
dataTarget,
|
|
221
|
+
dataType,
|
|
222
|
+
dataPath,
|
|
223
|
+
backendSecrets,
|
|
224
|
+
passphraseAccount,
|
|
225
|
+
passphrase,
|
|
226
|
+
hostingTarget,
|
|
227
|
+
hosting,
|
|
228
|
+
adminUrl,
|
|
229
|
+
credentials,
|
|
230
|
+
demo: plan.demo === true,
|
|
231
|
+
currency: str(plan.currency) || void 0,
|
|
232
|
+
locale: str(plan.locale) || void 0,
|
|
233
|
+
warnings
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const PLAN_EXAMPLE = {
|
|
237
|
+
alias: "acme-crm",
|
|
238
|
+
name: "Acme CRM",
|
|
239
|
+
owner: { name: "Ada Lovelace", email: "ada@acme.com" },
|
|
240
|
+
data: {
|
|
241
|
+
backend: "journal",
|
|
242
|
+
location: { type: "s3", settings: { endpoint: "https://s3.gra.io.cloud.ovh.net", region: "gra", bucket: "acme-crm-data" }, credential: "s3:ovh" },
|
|
243
|
+
root: "agentcrm"
|
|
244
|
+
},
|
|
245
|
+
hosting: { type: "s3", settings: { endpoint: "https://s3.gra.io.cloud.ovh.net", region: "gra", bucket: "acme-crm-admin", siteUrl: "https://crm.acme.com", publicRead: true }, credential: "s3:ovh", folder: "admin" },
|
|
246
|
+
demo: false
|
|
247
|
+
};
|
|
248
|
+
export {
|
|
249
|
+
PLAN_EXAMPLE,
|
|
250
|
+
cleanFolder,
|
|
251
|
+
resolveHosting,
|
|
252
|
+
resolveLocation,
|
|
253
|
+
resolvePlan
|
|
254
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createRequire as __acrmRequire } from "node:module";
|
|
2
|
+
import { fileURLToPath as __acrmPath } from "node:url";
|
|
3
|
+
import { dirname as __acrmDirname } from "node:path";
|
|
4
|
+
const require2 = __acrmRequire(import.meta.url);
|
|
5
|
+
const __filename = __acrmPath(import.meta.url);
|
|
6
|
+
const __dirname = __acrmDirname(__filename);
|
|
7
|
+
function createPluginRegistry(onError = defaultOnError) {
|
|
8
|
+
const filters = /* @__PURE__ */ new Map();
|
|
9
|
+
const actions = /* @__PURE__ */ new Map();
|
|
10
|
+
let counter = 0;
|
|
11
|
+
const sorted = (list) => [...list].sort((a, b) => a.priority - b.priority || a.order - b.order);
|
|
12
|
+
const registry = {
|
|
13
|
+
addFilter(hook, fn, priority = 10) {
|
|
14
|
+
const list = filters.get(hook) ?? [];
|
|
15
|
+
list.push({ fn, priority, order: counter++ });
|
|
16
|
+
filters.set(hook, list);
|
|
17
|
+
return () => registry.removeFilter(hook, fn);
|
|
18
|
+
},
|
|
19
|
+
removeFilter(hook, fn) {
|
|
20
|
+
const list = filters.get(hook);
|
|
21
|
+
if (list) filters.set(hook, list.filter((e) => e.fn !== fn));
|
|
22
|
+
},
|
|
23
|
+
async applyFilters(hook, value, ...args) {
|
|
24
|
+
let current = value;
|
|
25
|
+
for (const e of sorted(filters.get(hook) ?? [])) {
|
|
26
|
+
try {
|
|
27
|
+
current = await e.fn(current, ...args);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
onError(hook, err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return current;
|
|
33
|
+
},
|
|
34
|
+
applyFiltersSync(hook, value, ...args) {
|
|
35
|
+
let current = value;
|
|
36
|
+
for (const e of sorted(filters.get(hook) ?? [])) {
|
|
37
|
+
const next = e.fn(current, ...args);
|
|
38
|
+
if (next instanceof Promise) throw new Error(`Filter "${hook}" must be synchronous; got a Promise.`);
|
|
39
|
+
current = next;
|
|
40
|
+
}
|
|
41
|
+
return current;
|
|
42
|
+
},
|
|
43
|
+
addAction(hook, fn, priority = 10) {
|
|
44
|
+
const list = actions.get(hook) ?? [];
|
|
45
|
+
list.push({ fn, priority, order: counter++ });
|
|
46
|
+
actions.set(hook, list);
|
|
47
|
+
return () => registry.removeAction(hook, fn);
|
|
48
|
+
},
|
|
49
|
+
removeAction(hook, fn) {
|
|
50
|
+
const list = actions.get(hook);
|
|
51
|
+
if (list) actions.set(hook, list.filter((e) => e.fn !== fn));
|
|
52
|
+
},
|
|
53
|
+
async doAction(hook, ...args) {
|
|
54
|
+
for (const e of sorted(actions.get(hook) ?? [])) {
|
|
55
|
+
try {
|
|
56
|
+
await e.fn(...args);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
onError(hook, err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
hasAction(hook) {
|
|
63
|
+
return (actions.get(hook)?.length ?? 0) > 0;
|
|
64
|
+
},
|
|
65
|
+
reset() {
|
|
66
|
+
filters.clear();
|
|
67
|
+
actions.clear();
|
|
68
|
+
counter = 0;
|
|
69
|
+
},
|
|
70
|
+
listHooks() {
|
|
71
|
+
return { filters: [...filters.keys()], actions: [...actions.keys()] };
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return registry;
|
|
75
|
+
}
|
|
76
|
+
function defaultOnError(hook, err) {
|
|
77
|
+
console.error(`[plugins] "${hook}" callback failed:`, err);
|
|
78
|
+
}
|
|
79
|
+
const plugins = createPluginRegistry();
|
|
80
|
+
export {
|
|
81
|
+
createPluginRegistry,
|
|
82
|
+
plugins
|
|
83
|
+
};
|