@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,3932 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __acrmRequire } from "node:module";
|
|
3
|
+
import { fileURLToPath as __acrmPath } from "node:url";
|
|
4
|
+
import { dirname as __acrmDirname } from "node:path";
|
|
5
|
+
const require2 = __acrmRequire(import.meta.url);
|
|
6
|
+
const __filename = __acrmPath(import.meta.url);
|
|
7
|
+
const __dirname = __acrmDirname(__filename);
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { existsSync, readFileSync, statSync, mkdirSync, writeFileSync, chmodSync, renameSync, openSync, writeSync, closeSync, unlinkSync, mkdtempSync, readdirSync, createReadStream } from "node:fs";
|
|
10
|
+
import { join, resolve, dirname, basename, extname, sep, normalize, isAbsolute } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
13
|
+
import { homedir, tmpdir } from "node:os";
|
|
14
|
+
import { spawnSync, execFileSync, spawn } from "node:child_process";
|
|
15
|
+
import { createServer } from "node:http";
|
|
16
|
+
class MemoryStorage {
|
|
17
|
+
data = /* @__PURE__ */ new Map();
|
|
18
|
+
get length() {
|
|
19
|
+
return this.data.size;
|
|
20
|
+
}
|
|
21
|
+
key(index) {
|
|
22
|
+
return [...this.data.keys()][index] ?? null;
|
|
23
|
+
}
|
|
24
|
+
getItem(key) {
|
|
25
|
+
return this.data.get(key) ?? null;
|
|
26
|
+
}
|
|
27
|
+
setItem(key, value) {
|
|
28
|
+
this.data.set(key, String(value));
|
|
29
|
+
}
|
|
30
|
+
removeItem(key) {
|
|
31
|
+
this.data.delete(key);
|
|
32
|
+
}
|
|
33
|
+
clear() {
|
|
34
|
+
this.data.clear();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
let installed = false;
|
|
38
|
+
function installBrowserEnv() {
|
|
39
|
+
if (installed) return;
|
|
40
|
+
installed = true;
|
|
41
|
+
const g = globalThis;
|
|
42
|
+
for (const name2 of ["localStorage", "sessionStorage"]) {
|
|
43
|
+
Object.defineProperty(globalThis, name2, { value: new MemoryStorage(), configurable: true, writable: true });
|
|
44
|
+
}
|
|
45
|
+
if (typeof g.window === "undefined") {
|
|
46
|
+
Object.defineProperty(globalThis, "window", { value: globalThis, configurable: true, writable: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
class CliError extends Error {
|
|
50
|
+
constructor(code, message, hint, exitCode = 1) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.hint = hint;
|
|
54
|
+
this.exitCode = exitCode;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function createIo(json) {
|
|
58
|
+
return {
|
|
59
|
+
json,
|
|
60
|
+
out: (text) => process.stdout.write(text.endsWith("\n") ? text : text + "\n"),
|
|
61
|
+
err: (text) => process.stderr.write(text.endsWith("\n") ? text : text + "\n")
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function printResult(io, data, human) {
|
|
65
|
+
if (io.json) io.out(JSON.stringify({ ok: true, data }));
|
|
66
|
+
else io.out(human());
|
|
67
|
+
}
|
|
68
|
+
const OPS_HINTS = {
|
|
69
|
+
forbidden: "The CRM user the CLI acts as lacks this permission (see `site show` → user, `users list`). Ask an admin, or re-register the site with an admin user (`site add … --user <email>`).",
|
|
70
|
+
notFound: "Check the id (list commands print them: `records list --object …`, `objects list`, `views list`…).",
|
|
71
|
+
validation: "Fix the input: `objects get <object>` lists field ids, types and select options.",
|
|
72
|
+
conflict: "Something with the same key already exists — list it first, then update instead of creating.",
|
|
73
|
+
invalid: "Check the arguments with `agentcrm help <command>`."
|
|
74
|
+
};
|
|
75
|
+
function knownError(error) {
|
|
76
|
+
if (!(error instanceof Error)) return null;
|
|
77
|
+
if (error.name === "PassphraseError") {
|
|
78
|
+
const reason = error.reason;
|
|
79
|
+
return reason === "missing" ? new CliError(
|
|
80
|
+
"passphrase-required",
|
|
81
|
+
"This dataset is encrypted and the site has no passphrase credential.",
|
|
82
|
+
"Store it (`agentcrm credentials set passphrase:<alias>` in the user's own terminal), then attach it: `agentcrm site credentials <alias> --passphrase-credential passphrase:<alias>`."
|
|
83
|
+
) : new CliError("passphrase-wrong", "Wrong passphrase: the data could not be decrypted.", "Store the right one with `agentcrm credentials set <the site's passphrase account>` (see `site show`).");
|
|
84
|
+
}
|
|
85
|
+
if (error.name === "OpsError") {
|
|
86
|
+
const code = error.code ?? "invalid";
|
|
87
|
+
return new CliError(code, error.message, OPS_HINTS[code], code === "validation" || code === "invalid" ? 2 : 1);
|
|
88
|
+
}
|
|
89
|
+
if (error.name === "FieldValidationError") {
|
|
90
|
+
return new CliError("validation", error.message, OPS_HINTS.validation, 2);
|
|
91
|
+
}
|
|
92
|
+
if (error.name === "DocConflictError") {
|
|
93
|
+
return new CliError("conflict", `The data changed elsewhere while this command ran: ${error.message}`, "Run the same command again (it reloads the latest data).");
|
|
94
|
+
}
|
|
95
|
+
if (error.name === "StorageError") {
|
|
96
|
+
const status = error.status ?? 0;
|
|
97
|
+
const hint = status === 401 || status === 403 ? "The storage refused the credentials: check the key pair / token and its permissions (`agentcrm doctor`)." : status === 404 ? "Bucket, repository or path not found: check the names in the plan / site." : status === 0 ? "Network error or CORS: check the endpoint URL and the connection." : "Retry once; run `agentcrm doctor` if it persists.";
|
|
98
|
+
return new CliError("storage", `${error.message}${status ? ` (HTTP ${status})` : ""}`, hint);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function toCliError(error) {
|
|
103
|
+
return error instanceof CliError ? error : knownError(error) ?? new CliError(
|
|
104
|
+
"internal",
|
|
105
|
+
error instanceof Error ? error.message : String(error),
|
|
106
|
+
"Unexpected error. Retry once; if it persists, report it with the command and this output."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
function printError(io, error) {
|
|
110
|
+
const e = toCliError(error);
|
|
111
|
+
if (io.json) io.out(JSON.stringify({ ok: false, error: { code: e.code, message: e.message, ...e.hint ? { hint: e.hint } : {} } }));
|
|
112
|
+
else io.err(`Error: ${e.message}${e.hint ? `
|
|
113
|
+
Hint: ${e.hint}` : ""}`);
|
|
114
|
+
return e.exitCode;
|
|
115
|
+
}
|
|
116
|
+
const output = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
117
|
+
__proto__: null,
|
|
118
|
+
CliError,
|
|
119
|
+
createIo,
|
|
120
|
+
knownError,
|
|
121
|
+
printError,
|
|
122
|
+
printResult,
|
|
123
|
+
toCliError
|
|
124
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
125
|
+
const name = "@fleox-agent-crm/agentcrm";
|
|
126
|
+
const version = "0.3.0";
|
|
127
|
+
const type = "module";
|
|
128
|
+
const description = "The static CRM your AI agent can run: a Twenty / HubSpot-style CRM that runs entirely in the browser or headless through the agentcrm CLI, with data on storage you own.";
|
|
129
|
+
const license = "SEE LICENSE IN LICENSE";
|
|
130
|
+
const engines = { "node": ">=20" };
|
|
131
|
+
const bin = { "agentcrm": "dist/agent/agentcrm.mjs" };
|
|
132
|
+
const files$1 = ["dist/agent", "dist/admin", "types/agentcrm-runtime.d.ts", "dist/version.json", "plugin", "changelog.json", "README.md", "LICENSE"];
|
|
133
|
+
const agentcrm = { "minSkillsVersion": "0.1.0", "dataSchema": 1 };
|
|
134
|
+
const scripts = { "dev": "vite", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.agent.json", "test": "vitest run", "test:watch": "vitest", "build": "vite build && node scripts/write-admin-manifest.mjs && vite build --config vite.agent.config.ts && node scripts/write-version.mjs", "build:examples": "node scripts/build-examples.mjs", "preview": "vite preview", "check:skills": "node scripts/check-skills.mjs", "hermes:build": "node scripts/build-hermes.mjs", "check:hermes": "node scripts/build-hermes.mjs --check && python3 -m unittest discover -s hermes/tests", "version": "node scripts/sync-version.mjs && npm run build && node scripts/build-hermes.mjs && git add -A plugin hermes .claude-plugin", "gen:runtime-stubs": "node scripts/gen-runtime-stubs.mjs", "prepublishOnly": "npm run typecheck && npm test && npm run build && npm run check:skills" };
|
|
135
|
+
const devDependencies = { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@types/node": "^25.9.9", "@types/papaparse": "^5.5.2", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^5.2.0", "autoprefixer": "^10.6.2", "clsx": "^2.1.1", "date-fns": "^4.4.0", "dompurify": "^3.4.17", "fake-indexeddb": "^6.2.7", "firebase": "^11.10.0", "happy-dom": "^20.14.6", "i18next": "^23.16.8", "i18next-browser-languagedetector": "^8.3.0", "jszip": "^3.10.2", "lucide-react": "^0.460.0", "marked": "^18.1.3", "minisearch": "^7.2.0", "papaparse": "^5.7.0", "postcss": "^8.5.32", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.7.4", "react-is": "^18.3.1", "react-router-dom": "^6.30.6", "recharts": "^3.10.1", "tailwindcss": "^3.4.19", "typescript": "^6.0.3", "vite": "^6.4.5", "vitest": "^4.1.12" };
|
|
136
|
+
const repository = { "type": "git", "url": "git+https://github.com/fleox/agentcrm.git" };
|
|
137
|
+
const homepage = "https://github.com/fleox/agentcrm#readme";
|
|
138
|
+
const keywords = ["crm", "static", "mcp", "ai-agent", "claude", "sales", "pipeline", "s3", "encrypted"];
|
|
139
|
+
const publishConfig = { "access": "public" };
|
|
140
|
+
const bugs = { "url": "https://github.com/fleox/agentcrm/issues" };
|
|
141
|
+
const pkg = {
|
|
142
|
+
name,
|
|
143
|
+
version,
|
|
144
|
+
type,
|
|
145
|
+
description,
|
|
146
|
+
license,
|
|
147
|
+
engines,
|
|
148
|
+
bin,
|
|
149
|
+
files: files$1,
|
|
150
|
+
agentcrm,
|
|
151
|
+
scripts,
|
|
152
|
+
devDependencies,
|
|
153
|
+
repository,
|
|
154
|
+
homepage,
|
|
155
|
+
keywords,
|
|
156
|
+
publishConfig,
|
|
157
|
+
bugs
|
|
158
|
+
};
|
|
159
|
+
function isBundled() {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
function distRoot() {
|
|
163
|
+
if (process.env.AGENTCRM_DIST) return resolve(process.env.AGENTCRM_DIST);
|
|
164
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
165
|
+
return resolve(here, "..");
|
|
166
|
+
}
|
|
167
|
+
function ownPackageRoot() {
|
|
168
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
169
|
+
return resolve(here, "..", "..");
|
|
170
|
+
}
|
|
171
|
+
function packageRoot() {
|
|
172
|
+
return resolve(distRoot(), "..");
|
|
173
|
+
}
|
|
174
|
+
function readVersionInfo() {
|
|
175
|
+
const file2 = join(distRoot(), "version.json");
|
|
176
|
+
if (existsSync(file2)) {
|
|
177
|
+
try {
|
|
178
|
+
const v = JSON.parse(readFileSync(file2, "utf8"));
|
|
179
|
+
if (v.version) return { name: v.name ?? pkg.name, version: v.version, minSkillsVersion: v.minSkillsVersion ?? pkg.agentcrm.minSkillsVersion, dataSchema: v.dataSchema ?? pkg.agentcrm.dataSchema };
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { name: pkg.name, version: pkg.version, minSkillsVersion: pkg.agentcrm.minSkillsVersion, dataSchema: pkg.agentcrm.dataSchema };
|
|
184
|
+
}
|
|
185
|
+
const PACKAGE_NAME = pkg.name;
|
|
186
|
+
const FIELD_TYPES = [
|
|
187
|
+
"text",
|
|
188
|
+
"longText",
|
|
189
|
+
"number",
|
|
190
|
+
"currency",
|
|
191
|
+
"percent",
|
|
192
|
+
"date",
|
|
193
|
+
"datetime",
|
|
194
|
+
"boolean",
|
|
195
|
+
"select",
|
|
196
|
+
"multiSelect",
|
|
197
|
+
"email",
|
|
198
|
+
"phone",
|
|
199
|
+
"url",
|
|
200
|
+
"relation",
|
|
201
|
+
"user",
|
|
202
|
+
"rating",
|
|
203
|
+
"address",
|
|
204
|
+
"tags",
|
|
205
|
+
"image",
|
|
206
|
+
"files"
|
|
207
|
+
];
|
|
208
|
+
const ACTIVITY_TYPES = ["note", "task", "call", "email", "meeting"];
|
|
209
|
+
const FILTER_OPS = [
|
|
210
|
+
"eq",
|
|
211
|
+
"neq",
|
|
212
|
+
"contains",
|
|
213
|
+
"notContains",
|
|
214
|
+
"empty",
|
|
215
|
+
"notEmpty",
|
|
216
|
+
"gt",
|
|
217
|
+
"gte",
|
|
218
|
+
"lt",
|
|
219
|
+
"lte",
|
|
220
|
+
"in",
|
|
221
|
+
"notIn",
|
|
222
|
+
"before",
|
|
223
|
+
"after",
|
|
224
|
+
"withinDays"
|
|
225
|
+
];
|
|
226
|
+
const USER_ROLES = ["admin", "member", "viewer"];
|
|
227
|
+
function homeDir() {
|
|
228
|
+
return resolve(process.env.AGENTCRM_HOME || join(homedir(), ".agentcrm"));
|
|
229
|
+
}
|
|
230
|
+
function homePath(...parts) {
|
|
231
|
+
return join(homeDir(), ...parts);
|
|
232
|
+
}
|
|
233
|
+
function readJsonFile(file2, fallback) {
|
|
234
|
+
if (!existsSync(file2)) return fallback;
|
|
235
|
+
return JSON.parse(readFileSync(file2, "utf8"));
|
|
236
|
+
}
|
|
237
|
+
function writeJsonFile(file2, value, mode = 420) {
|
|
238
|
+
mkdirSync(dirname(file2), { recursive: true, mode: 448 });
|
|
239
|
+
const tmp = `${file2}.${process.pid}.tmp`;
|
|
240
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode });
|
|
241
|
+
chmodSync(tmp, mode);
|
|
242
|
+
renameSync(tmp, file2);
|
|
243
|
+
}
|
|
244
|
+
function isWorldReadable(file2) {
|
|
245
|
+
if (process.platform === "win32" || !existsSync(file2)) return false;
|
|
246
|
+
return (statSync(file2).mode & 63) !== 0;
|
|
247
|
+
}
|
|
248
|
+
const CREDENTIAL_TYPES = {
|
|
249
|
+
s3: {
|
|
250
|
+
fields: ["accessKeyId", "secretAccessKey"],
|
|
251
|
+
label: "S3-compatible access key pair",
|
|
252
|
+
howTo: "An access key restricted to the CRM bucket(s) with GetObject / PutObject / DeleteObject / ListBucket (AWS IAM user, Cloudflare R2 API token, OVHcloud / Scaleway S3 user). Import an existing aws CLI profile with --from-aws-profile."
|
|
253
|
+
},
|
|
254
|
+
github: {
|
|
255
|
+
fields: ["token"],
|
|
256
|
+
label: "GitHub personal access token",
|
|
257
|
+
howTo: "https://github.com/settings/personal-access-tokens/new — fine-grained, only the CRM data repo (private) and the admin hosting repo, Repository permissions: Contents Read and write, Pages Read and write (optional), Metadata Read-only. Or reuse the GitHub CLI login with --from-gh."
|
|
258
|
+
},
|
|
259
|
+
gitlab: {
|
|
260
|
+
fields: ["token"],
|
|
261
|
+
label: "GitLab access token",
|
|
262
|
+
howTo: "GitLab project → Settings → Access tokens (or Preferences → Access tokens): scope `api`, role Developer or higher."
|
|
263
|
+
},
|
|
264
|
+
firebase: {
|
|
265
|
+
fields: ["email", "password"],
|
|
266
|
+
label: "Firebase admin login (email + password)",
|
|
267
|
+
howTo: "The email and password of a CRM administrator in the Firebase project (Authentication → Users). The CLI signs in exactly like the admin login screen."
|
|
268
|
+
},
|
|
269
|
+
passphrase: {
|
|
270
|
+
fields: ["passphrase"],
|
|
271
|
+
label: "Dataset encryption passphrase",
|
|
272
|
+
howTo: "Generated by `agentcrm install` (or `credentials set passphrase:<name> --generate`). Each browser asks for it once. Save it in a password manager: without it nobody can read the data."
|
|
273
|
+
},
|
|
274
|
+
anthropic: {
|
|
275
|
+
fields: ["apiKey"],
|
|
276
|
+
label: "Anthropic API key (AI assistant)",
|
|
277
|
+
howTo: "https://console.anthropic.com/settings/keys — used by the in-admin assistant from the browser; the CLI only stores it so it can be copied into the admin."
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
const ACCOUNT_RE = /^([a-z0-9-]+):([A-Za-z0-9._@-]{1,100})$/;
|
|
281
|
+
function file$1() {
|
|
282
|
+
return homePath("credentials.json");
|
|
283
|
+
}
|
|
284
|
+
function load$1() {
|
|
285
|
+
return readJsonFile(file$1(), { version: 1, accounts: {} });
|
|
286
|
+
}
|
|
287
|
+
function save$1(data) {
|
|
288
|
+
writeJsonFile(file$1(), data, 384);
|
|
289
|
+
}
|
|
290
|
+
function parseAccount(account) {
|
|
291
|
+
const m = ACCOUNT_RE.exec(account);
|
|
292
|
+
if (!m || !CREDENTIAL_TYPES[m[1]]) {
|
|
293
|
+
throw new CliError(
|
|
294
|
+
"invalid-account",
|
|
295
|
+
`Invalid account "${account}".`,
|
|
296
|
+
`Use <type>:<name> with type one of ${Object.keys(CREDENTIAL_TYPES).join(", ")} — e.g. s3:main.`,
|
|
297
|
+
2
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return { type: m[1], name: m[2] };
|
|
301
|
+
}
|
|
302
|
+
function setCredential(account, values) {
|
|
303
|
+
const { type: type2 } = parseAccount(account);
|
|
304
|
+
const missing = CREDENTIAL_TYPES[type2].fields.filter((f) => !values[f]?.trim());
|
|
305
|
+
if (missing.length) throw new CliError("missing-fields", `Missing ${missing.join(", ")} for ${account}.`, void 0, 2);
|
|
306
|
+
const data = load$1();
|
|
307
|
+
const entry = {
|
|
308
|
+
type: type2,
|
|
309
|
+
values: Object.fromEntries(CREDENTIAL_TYPES[type2].fields.map((f) => [f, values[f].trim()])),
|
|
310
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
311
|
+
};
|
|
312
|
+
data.accounts[account] = entry;
|
|
313
|
+
save$1(data);
|
|
314
|
+
return entry;
|
|
315
|
+
}
|
|
316
|
+
function removeCredential(account) {
|
|
317
|
+
const data = load$1();
|
|
318
|
+
if (!data.accounts[account]) return false;
|
|
319
|
+
delete data.accounts[account];
|
|
320
|
+
save$1(data);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
function getCredential(account) {
|
|
324
|
+
parseAccount(account);
|
|
325
|
+
const path = file$1();
|
|
326
|
+
if (isWorldReadable(path)) chmodSync(path, 384);
|
|
327
|
+
return load$1().accounts[account] ?? null;
|
|
328
|
+
}
|
|
329
|
+
function requireCredential(account) {
|
|
330
|
+
const cred = getCredential(account);
|
|
331
|
+
if (!cred) {
|
|
332
|
+
const { type: type2 } = parseAccount(account);
|
|
333
|
+
throw new CliError(
|
|
334
|
+
"credential-missing",
|
|
335
|
+
`No stored credential for ${account}.`,
|
|
336
|
+
`Ask the user to run \`agentcrm credentials set ${account}\` in their own terminal (input is hidden), or pass --from-env. ${CREDENTIAL_TYPES[type2].howTo}`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return cred;
|
|
340
|
+
}
|
|
341
|
+
function listCredentials() {
|
|
342
|
+
return Object.entries(load$1().accounts).map(([account, c]) => ({
|
|
343
|
+
account,
|
|
344
|
+
type: c.type,
|
|
345
|
+
fields: Object.keys(c.values),
|
|
346
|
+
updatedAt: c.updatedAt
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
const credentials = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
350
|
+
__proto__: null,
|
|
351
|
+
CREDENTIAL_TYPES,
|
|
352
|
+
getCredential,
|
|
353
|
+
listCredentials,
|
|
354
|
+
parseAccount,
|
|
355
|
+
removeCredential,
|
|
356
|
+
requireCredential,
|
|
357
|
+
setCredential
|
|
358
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
359
|
+
const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
360
|
+
function file() {
|
|
361
|
+
return homePath("sites.json");
|
|
362
|
+
}
|
|
363
|
+
function load() {
|
|
364
|
+
return readJsonFile(file(), { version: 1, default: null, sites: {} });
|
|
365
|
+
}
|
|
366
|
+
function save(data) {
|
|
367
|
+
writeJsonFile(file(), data);
|
|
368
|
+
}
|
|
369
|
+
function validateAlias(alias) {
|
|
370
|
+
if (!ALIAS_RE.test(alias)) {
|
|
371
|
+
throw new CliError("invalid-alias", `Invalid site alias "${alias}".`, "Lower-case letters, digits and dashes, e.g. acme-crm.", 2);
|
|
372
|
+
}
|
|
373
|
+
return alias;
|
|
374
|
+
}
|
|
375
|
+
function listSites() {
|
|
376
|
+
const data = load();
|
|
377
|
+
return { default: data.default, sites: Object.values(data.sites).sort((a, b) => a.alias.localeCompare(b.alias)) };
|
|
378
|
+
}
|
|
379
|
+
function getSite(alias) {
|
|
380
|
+
return load().sites[alias] ?? null;
|
|
381
|
+
}
|
|
382
|
+
function putSite(site) {
|
|
383
|
+
const data = load();
|
|
384
|
+
data.sites[site.alias] = { ...site, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
385
|
+
save(data);
|
|
386
|
+
}
|
|
387
|
+
function removeSite(alias) {
|
|
388
|
+
const data = load();
|
|
389
|
+
if (!data.sites[alias]) return false;
|
|
390
|
+
delete data.sites[alias];
|
|
391
|
+
if (data.default === alias) data.default = null;
|
|
392
|
+
save(data);
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
function renameSite(from, to) {
|
|
396
|
+
validateAlias(to);
|
|
397
|
+
const data = load();
|
|
398
|
+
const site = data.sites[from];
|
|
399
|
+
if (!site) throw siteNotFound(from, data);
|
|
400
|
+
if (data.sites[to]) throw new CliError("site-exists", `A site named "${to}" already exists.`);
|
|
401
|
+
delete data.sites[from];
|
|
402
|
+
data.sites[to] = { ...site, alias: to, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
403
|
+
if (data.default === from) data.default = to;
|
|
404
|
+
save(data);
|
|
405
|
+
}
|
|
406
|
+
function setDefaultSite(alias) {
|
|
407
|
+
const data = load();
|
|
408
|
+
if (!data.sites[alias]) throw siteNotFound(alias, data);
|
|
409
|
+
data.default = alias;
|
|
410
|
+
save(data);
|
|
411
|
+
}
|
|
412
|
+
function findProjectSite(cwd = process.cwd()) {
|
|
413
|
+
let dir = resolve(cwd);
|
|
414
|
+
for (; ; ) {
|
|
415
|
+
const candidate = join(dir, "agentcrm.json");
|
|
416
|
+
if (existsSync(candidate)) {
|
|
417
|
+
try {
|
|
418
|
+
const alias = JSON.parse(readFileSync(candidate, "utf8")).site;
|
|
419
|
+
if (typeof alias === "string" && alias) return { alias, file: candidate };
|
|
420
|
+
} catch {
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const parent = dirname(dir);
|
|
424
|
+
if (parent === dir) return null;
|
|
425
|
+
dir = parent;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function siteNotFound(alias, data) {
|
|
429
|
+
const known = Object.keys(data.sites);
|
|
430
|
+
return new CliError(
|
|
431
|
+
"site-not-found",
|
|
432
|
+
`No site named "${alias}".`,
|
|
433
|
+
known.length ? `Known sites: ${known.join(", ")}.` : "No CRM registered yet — run `agentcrm install --plan …` or `agentcrm site add`."
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
function resolveSite(explicit) {
|
|
437
|
+
const data = load();
|
|
438
|
+
const chosen = explicit || process.env.AGENTCRM_SITE?.trim() || findProjectSite()?.alias;
|
|
439
|
+
if (chosen) {
|
|
440
|
+
const site = data.sites[chosen];
|
|
441
|
+
if (!site) throw siteNotFound(chosen, data);
|
|
442
|
+
return site;
|
|
443
|
+
}
|
|
444
|
+
const all = Object.values(data.sites);
|
|
445
|
+
if (all.length === 1) return all[0];
|
|
446
|
+
if (all.length === 0) throw siteNotFound("(none)", data);
|
|
447
|
+
if (data.default && data.sites[data.default]) return data.sites[data.default];
|
|
448
|
+
throw new CliError(
|
|
449
|
+
"site-required",
|
|
450
|
+
`Several CRMs are registered: ${all.map((s) => s.alias).join(", ")}.`,
|
|
451
|
+
"Ask the user which one, then pass --site <alias> (or `agentcrm site use <alias>`).",
|
|
452
|
+
2
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
function lockSite(alias) {
|
|
456
|
+
const dir = homePath("locks");
|
|
457
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
458
|
+
const path = join(dir, `${alias}.lock`);
|
|
459
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
460
|
+
try {
|
|
461
|
+
const fd = openSync(path, "wx");
|
|
462
|
+
writeSync(fd, String(process.pid));
|
|
463
|
+
closeSync(fd);
|
|
464
|
+
return () => {
|
|
465
|
+
try {
|
|
466
|
+
unlinkSync(path);
|
|
467
|
+
} catch {
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
} catch (err) {
|
|
471
|
+
if (err.code !== "EEXIST") throw err;
|
|
472
|
+
const pid = Number(readFileSync(path, "utf8"));
|
|
473
|
+
if (pid && pid !== process.pid && isAlive(pid)) {
|
|
474
|
+
throw new CliError("site-busy", `Another agentcrm process (pid ${pid}) is writing to "${alias}".`, "Wait for it to finish, then retry.");
|
|
475
|
+
}
|
|
476
|
+
if (pid === process.pid) {
|
|
477
|
+
return () => void 0;
|
|
478
|
+
}
|
|
479
|
+
unlinkSync(path);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
throw new CliError("site-busy", `Could not lock "${alias}".`);
|
|
483
|
+
}
|
|
484
|
+
function isAlive(pid) {
|
|
485
|
+
try {
|
|
486
|
+
process.kill(pid, 0);
|
|
487
|
+
return true;
|
|
488
|
+
} catch (err) {
|
|
489
|
+
return err.code === "EPERM";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function runtimeDir() {
|
|
493
|
+
const own = ownPackageRoot();
|
|
494
|
+
const candidates = [...new Set([process.env.AGENTCRM_RUNTIME_DIR, join(distRoot(), "admin", "runtime"), join(packageRoot(), "public", "runtime"), join(own, "dist", "admin", "runtime"), join(own, "public", "runtime")].filter((p) => !!p))];
|
|
495
|
+
for (const dir of candidates) if (existsSync(join(dir, "agentcrm-runtime.js"))) return resolve(dir);
|
|
496
|
+
throw new Error(`Runtime stubs not found (looked in ${candidates.join(", ")}). Set AGENTCRM_RUNTIME_DIR to an admin build's runtime/ folder.`);
|
|
497
|
+
}
|
|
498
|
+
function safeModeFromEnv() {
|
|
499
|
+
const v = process.env.AGENTCRM_SAFE?.trim().toLowerCase();
|
|
500
|
+
return v === "1" || v === "true" || v === "yes";
|
|
501
|
+
}
|
|
502
|
+
let tmpBase = null;
|
|
503
|
+
function tmpFolder() {
|
|
504
|
+
if (!tmpBase) tmpBase = mkdtempSync(join(tmpdir(), "agentcrm-cli-ext-"));
|
|
505
|
+
return tmpBase;
|
|
506
|
+
}
|
|
507
|
+
async function nodeImportBundle() {
|
|
508
|
+
await import("./chunks/runtime-DHo7ZEEO.mjs");
|
|
509
|
+
const { createNodeImportBundle } = await import("./chunks/nodeImport-BPCt5uMu.mjs");
|
|
510
|
+
return createNodeImportBundle({ runtimeDir: runtimeDir(), tmpDir: tmpFolder() });
|
|
511
|
+
}
|
|
512
|
+
function hasEnabledExtensions(store) {
|
|
513
|
+
return store.list("extensions").some((d) => d.enabled === true);
|
|
514
|
+
}
|
|
515
|
+
async function loadSessionExtensions(store, opts = {}) {
|
|
516
|
+
const safeMode = !!opts.safe || safeModeFromEnv();
|
|
517
|
+
let loadedAny = false;
|
|
518
|
+
const sync = async () => {
|
|
519
|
+
if (safeMode) return null;
|
|
520
|
+
if (!loadedAny && !opts.force && !hasEnabledExtensions(store)) return null;
|
|
521
|
+
try {
|
|
522
|
+
const importBundle = await nodeImportBundle();
|
|
523
|
+
const { syncExtensions } = await import("./chunks/loader-C9nm3iwx.mjs");
|
|
524
|
+
loadedAny = true;
|
|
525
|
+
const original = console.error;
|
|
526
|
+
console.error = () => void 0;
|
|
527
|
+
try {
|
|
528
|
+
return await syncExtensions(store, { importBundle });
|
|
529
|
+
} finally {
|
|
530
|
+
console.error = original;
|
|
531
|
+
}
|
|
532
|
+
} catch (err) {
|
|
533
|
+
return { loaded: [], unloaded: [], failed: [{ id: "*", error: err.message }], skipped: [] };
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
const report = await sync();
|
|
537
|
+
return {
|
|
538
|
+
report,
|
|
539
|
+
safeMode,
|
|
540
|
+
sync,
|
|
541
|
+
async close() {
|
|
542
|
+
if (!loadedAny) return;
|
|
543
|
+
(await import("./chunks/registry-Ceexawxl.mjs")).deactivateAllExtensions();
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function extensionNotes(report) {
|
|
548
|
+
if (!report) return [];
|
|
549
|
+
return report.failed.map((f) => f.id === "*" ? `extensions could not be loaded: ${f.error}` : `extension ${f.id} failed to load: ${f.error} (\`agentcrm ext disable ${f.id}\`, or --safe to skip extensions)`);
|
|
550
|
+
}
|
|
551
|
+
function chainRegistries(primary, secondary) {
|
|
552
|
+
return {
|
|
553
|
+
...primary,
|
|
554
|
+
async doAction(hook, ...args) {
|
|
555
|
+
await primary.doAction(hook, ...args);
|
|
556
|
+
await secondary.doAction(hook, ...args);
|
|
557
|
+
},
|
|
558
|
+
async applyFilters(hook, value, ...args) {
|
|
559
|
+
return secondary.applyFilters(hook, await primary.applyFilters(hook, value, ...args), ...args);
|
|
560
|
+
},
|
|
561
|
+
applyFiltersSync(hook, value, ...args) {
|
|
562
|
+
return secondary.applyFiltersSync(hook, primary.applyFiltersSync(hook, value, ...args), ...args);
|
|
563
|
+
},
|
|
564
|
+
hasAction: (hook) => primary.hasAction(hook) || secondary.hasAction(hook)
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function kdfIterations() {
|
|
568
|
+
const n = Number(process.env.AGENTCRM_KDF_ITERATIONS);
|
|
569
|
+
return Number.isInteger(n) && n > 0 ? n : void 0;
|
|
570
|
+
}
|
|
571
|
+
function cliClientId() {
|
|
572
|
+
return `cli${randomBytes(4).toString("hex")}`;
|
|
573
|
+
}
|
|
574
|
+
function dataTargetOf(site) {
|
|
575
|
+
if (site.runtime.backend !== "journal") throw new CliError("unsupported-backend", `${site.alias} is not a journal dataset.`);
|
|
576
|
+
const j = site.runtime.journal;
|
|
577
|
+
const secrets = site.credentials.data ? requireCredential(site.credentials.data).values : {};
|
|
578
|
+
return { connectorId: j.connectorId, settings: { ...j.settings, ...site.dataPath ? { path: site.dataPath } : {}, ...secrets } };
|
|
579
|
+
}
|
|
580
|
+
function hostingTargetOf(site) {
|
|
581
|
+
if (!site.hosting) return null;
|
|
582
|
+
const secrets = site.credentials.hosting ? requireCredential(site.credentials.hosting).values : {};
|
|
583
|
+
if (!site.credentials.hosting && site.hosting.connectorId !== "local-folder") {
|
|
584
|
+
throw new CliError("credential-required", `No hosting credential recorded for ${site.alias}.`, `Attach one: \`agentcrm site credentials ${site.alias} --hosting-credential <account>\`.`);
|
|
585
|
+
}
|
|
586
|
+
return { connectorId: site.hosting.connectorId, settings: { ...site.hosting.settings, ...secrets } };
|
|
587
|
+
}
|
|
588
|
+
async function openStore(runtime, opts) {
|
|
589
|
+
installBrowserEnv();
|
|
590
|
+
const rc = await import("./chunks/runtimeConfig-CCOFg1RM.mjs");
|
|
591
|
+
rc.setRuntimeConfigOverride(runtime);
|
|
592
|
+
if (opts.passphrase) rc.setPassphrase(runtime, opts.passphrase);
|
|
593
|
+
if (runtime.backend === "local") {
|
|
594
|
+
throw new CliError("unsupported-backend", "This CRM keeps its data in one browser (local mode): the CLI can't reach it.", "Use the admin in that browser, or move the data to a journal / git / Firebase backend (Settings → Data).");
|
|
595
|
+
}
|
|
596
|
+
if (runtime.backend === "journal") {
|
|
597
|
+
if (!opts.dataTarget) throw new CliError("internal", "journal store without a data location");
|
|
598
|
+
const { buildConnector } = await import("./chunks/hosting-Bme7LxPc.mjs");
|
|
599
|
+
const { createJournalStore } = await import("./chunks/index-B-4W0-P4.mjs");
|
|
600
|
+
const { publicSettings, secrets } = rc.splitSecretSettings(opts.dataTarget.settings);
|
|
601
|
+
rc.setConnectorSecrets(runtime, secrets);
|
|
602
|
+
return createJournalStore({
|
|
603
|
+
connector: buildConnector(opts.dataTarget),
|
|
604
|
+
root: runtime.journal.root,
|
|
605
|
+
encrypted: runtime.journal.encrypted,
|
|
606
|
+
passphrase: opts.passphrase ?? null,
|
|
607
|
+
clientId: cliClientId(),
|
|
608
|
+
iterations: kdfIterations()
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if (runtime.backend === "git-json") {
|
|
612
|
+
const token = String(opts.secrets?.token ?? "");
|
|
613
|
+
if (token) rc.setGitToken(runtime.git, token);
|
|
614
|
+
}
|
|
615
|
+
const { createDataStore } = await import("./chunks/index-CRkw3_iF.mjs");
|
|
616
|
+
return createDataStore(runtime, { secrets: opts.secrets ?? {}, passphrase: opts.passphrase ?? null, clientId: cliClientId() });
|
|
617
|
+
}
|
|
618
|
+
function siteSecrets(site) {
|
|
619
|
+
const passphrase = site.credentials.passphrase ? requireCredential(site.credentials.passphrase).values.passphrase : null;
|
|
620
|
+
if (site.runtime.backend === "journal") return { dataTarget: dataTargetOf(site), passphrase };
|
|
621
|
+
if (site.runtime.backend === "git-json" || site.runtime.backend === "firebase") {
|
|
622
|
+
if (!site.credentials.data) {
|
|
623
|
+
throw new CliError(
|
|
624
|
+
"credential-required",
|
|
625
|
+
`No ${site.runtime.backend === "firebase" ? "Firebase login" : "git token"} recorded for ${site.alias}.`,
|
|
626
|
+
`Store it (\`agentcrm credentials set ${site.runtime.backend === "firebase" ? "firebase" : site.runtime.git.provider}:<name>\`), then \`agentcrm site credentials ${site.alias} --data-credential <account>\`.`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
return { secrets: requireCredential(site.credentials.data).values, passphrase };
|
|
630
|
+
}
|
|
631
|
+
return { passphrase };
|
|
632
|
+
}
|
|
633
|
+
async function actingUser(store, site, as) {
|
|
634
|
+
const { usersOf } = await import("./chunks/store-5gqRSKXg.mjs");
|
|
635
|
+
const users = usersOf(store);
|
|
636
|
+
const find = (ref) => users.find((u) => u.id === ref || u.email.toLowerCase() === ref.toLowerCase());
|
|
637
|
+
if (as) {
|
|
638
|
+
const u = find(as);
|
|
639
|
+
if (!u) throw new CliError("user-not-found", `No CRM user "${as}".`, "List them with `agentcrm users list`.", 2);
|
|
640
|
+
return { user: u, warnings: [] };
|
|
641
|
+
}
|
|
642
|
+
if (site.user) {
|
|
643
|
+
const u = find(site.user);
|
|
644
|
+
if (u) return { user: u, warnings: u.disabled ? [`The site's CLI user ${u.email} is disabled.`] : [] };
|
|
645
|
+
}
|
|
646
|
+
const identity = await store.identity().catch(() => null);
|
|
647
|
+
const byIdentity = identity ? find(identity.uid) ?? (identity.email ? find(identity.email) : void 0) : void 0;
|
|
648
|
+
if (byIdentity) return { user: byIdentity, warnings: [] };
|
|
649
|
+
const admin = users.find((u) => u.role === "admin" && !u.disabled);
|
|
650
|
+
if (admin) return { user: admin, warnings: site.user ? [`CRM user ${site.user} not found: acting as ${admin.email}.`] : [] };
|
|
651
|
+
return { user: null, warnings: users.length ? [] : ["The dataset has no CRM user yet: acting as the system."] };
|
|
652
|
+
}
|
|
653
|
+
async function openSession(alias, opts = {}) {
|
|
654
|
+
installBrowserEnv();
|
|
655
|
+
const site = resolveSite(alias);
|
|
656
|
+
const unlock = opts.lock ? lockSite(site.alias) : null;
|
|
657
|
+
let store = null;
|
|
658
|
+
let uninstall = null;
|
|
659
|
+
let extensions = null;
|
|
660
|
+
try {
|
|
661
|
+
store = await openStore(site.runtime, siteSecrets(site));
|
|
662
|
+
await store.ready();
|
|
663
|
+
const [{ createOpsContext }, { createPluginRegistry, plugins: defaultPlugins }, { installAutomations }] = await Promise.all([
|
|
664
|
+
import("./chunks/context-CxFfqS4D.mjs"),
|
|
665
|
+
import("./chunks/pluginRegistry-DyHtlkcr.mjs"),
|
|
666
|
+
import("./chunks/automations-BWrvLqP6.mjs")
|
|
667
|
+
]);
|
|
668
|
+
const own = createPluginRegistry((hook, err) => process.stderr.write(`[agentcrm] ${hook} handler failed: ${String(err?.message ?? err)}
|
|
669
|
+
`));
|
|
670
|
+
uninstall = installAutomations(own);
|
|
671
|
+
const plugins = chainRegistries(own, defaultPlugins);
|
|
672
|
+
const { user, warnings } = await actingUser(store, site, opts.as);
|
|
673
|
+
extensions = await loadSessionExtensions(store, { safe: opts.safe });
|
|
674
|
+
warnings.push(...extensionNotes(extensions.report));
|
|
675
|
+
const log = (msg) => process.stderr.write(`${msg}
|
|
676
|
+
`);
|
|
677
|
+
const ctx = createOpsContext(store, user, { plugins, log });
|
|
678
|
+
const system = createOpsContext(store, null, { plugins, log });
|
|
679
|
+
if (opts.lock && (!user || user.role === "admin") && store.list("objects").length) {
|
|
680
|
+
const { migrateSchema } = await import("./chunks/migrate-CsClnnN8.mjs").then((n) => n.c);
|
|
681
|
+
await migrateSchema(ctx).catch((err) => warnings.push(`Schema migration skipped: ${err.message}`));
|
|
682
|
+
}
|
|
683
|
+
if (site.hosting) {
|
|
684
|
+
const target = (() => {
|
|
685
|
+
try {
|
|
686
|
+
return hostingTargetOf(site);
|
|
687
|
+
} catch {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
})();
|
|
691
|
+
if (target) {
|
|
692
|
+
const { setStorageConfigSource, registerStorageConnector } = await import("./chunks/registry-DijAKZ24.mjs").then((n) => n.r);
|
|
693
|
+
const { buildConnector } = await import("./chunks/hosting-Bme7LxPc.mjs");
|
|
694
|
+
if (target.connectorId === "local-folder") registerStorageConnector({ id: "local-folder", create: (s) => buildConnector({ connectorId: "local-folder", settings: s }) });
|
|
695
|
+
setStorageConfigSource(async () => target);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
{
|
|
699
|
+
const forms = await import("./chunks/forms-BKU5F9WF.mjs");
|
|
700
|
+
const { buildConnector } = await import("./chunks/hosting-Bme7LxPc.mjs");
|
|
701
|
+
forms.setInboxSourceResolver((_ctx, form) => {
|
|
702
|
+
if (form.inbox.kind !== "s3") return null;
|
|
703
|
+
const settings = form.inbox.settings ?? {};
|
|
704
|
+
let target = null;
|
|
705
|
+
try {
|
|
706
|
+
target = settings.source === "data" && site.runtime.backend === "journal" ? dataTargetOf(site) : hostingTargetOf(site);
|
|
707
|
+
} catch {
|
|
708
|
+
target = null;
|
|
709
|
+
}
|
|
710
|
+
return target ? forms.connectorInboxSource(buildConnector(target), settings.prefix) : null;
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
const opened = store;
|
|
714
|
+
let closed = false;
|
|
715
|
+
return {
|
|
716
|
+
site,
|
|
717
|
+
store: opened,
|
|
718
|
+
ctx,
|
|
719
|
+
system,
|
|
720
|
+
plugins,
|
|
721
|
+
user,
|
|
722
|
+
warnings,
|
|
723
|
+
extensions,
|
|
724
|
+
hostingTarget: () => hostingTargetOf(site),
|
|
725
|
+
async close() {
|
|
726
|
+
if (closed) return;
|
|
727
|
+
closed = true;
|
|
728
|
+
try {
|
|
729
|
+
uninstall?.();
|
|
730
|
+
await extensions?.close();
|
|
731
|
+
await (await import("./chunks/batch-CVETiJj5.mjs")).flushImplicitBatch();
|
|
732
|
+
opened.close?.();
|
|
733
|
+
} finally {
|
|
734
|
+
unlock?.();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
} catch (err) {
|
|
739
|
+
uninstall?.();
|
|
740
|
+
await extensions?.close().catch(() => void 0);
|
|
741
|
+
store?.close?.();
|
|
742
|
+
unlock?.();
|
|
743
|
+
throw err;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
async function withSession(io, options, open, work, human = (r) => JSON.stringify(r, null, 2)) {
|
|
747
|
+
const session2 = await openSession(typeof options.site === "string" ? options.site : void 0, {
|
|
748
|
+
...open,
|
|
749
|
+
as: typeof options.as === "string" ? options.as : open.as,
|
|
750
|
+
safe: options.safe === true || open.safe
|
|
751
|
+
});
|
|
752
|
+
try {
|
|
753
|
+
const result = await work(session2);
|
|
754
|
+
for (const w of session2.warnings) io.err(`note: ${w}`);
|
|
755
|
+
printResult(io, result, () => human(result));
|
|
756
|
+
return 0;
|
|
757
|
+
} finally {
|
|
758
|
+
await session2.close();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const session = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
762
|
+
__proto__: null,
|
|
763
|
+
cliClientId,
|
|
764
|
+
dataTargetOf,
|
|
765
|
+
hostingTargetOf,
|
|
766
|
+
kdfIterations,
|
|
767
|
+
openSession,
|
|
768
|
+
openStore,
|
|
769
|
+
withSession
|
|
770
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
771
|
+
function usage(message, hint) {
|
|
772
|
+
throw new CliError("usage", message, hint, 2);
|
|
773
|
+
}
|
|
774
|
+
function strOpt(options, name2) {
|
|
775
|
+
const v = options[name2];
|
|
776
|
+
if (Array.isArray(v)) return v.at(-1);
|
|
777
|
+
return typeof v === "string" ? v : void 0;
|
|
778
|
+
}
|
|
779
|
+
function requireStr(options, name2, hint) {
|
|
780
|
+
const v = strOpt(options, name2);
|
|
781
|
+
if (!v || !v.trim()) usage(`--${name2} is required.`, hint);
|
|
782
|
+
return v.trim();
|
|
783
|
+
}
|
|
784
|
+
function listOpt(options, name2) {
|
|
785
|
+
const v = options[name2];
|
|
786
|
+
if (v === void 0 || v === false) return void 0;
|
|
787
|
+
const all = Array.isArray(v) ? v : [String(v)];
|
|
788
|
+
return all.flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
|
|
789
|
+
}
|
|
790
|
+
function multiOpt(options, name2) {
|
|
791
|
+
const v = options[name2];
|
|
792
|
+
if (v === void 0 || v === false) return [];
|
|
793
|
+
return Array.isArray(v) ? v : [String(v)];
|
|
794
|
+
}
|
|
795
|
+
function numOpt(options, name2) {
|
|
796
|
+
const v = strOpt(options, name2);
|
|
797
|
+
if (v === void 0) return void 0;
|
|
798
|
+
const n = Number(v);
|
|
799
|
+
if (!Number.isFinite(n)) usage(`--${name2} must be a number, got "${v}".`);
|
|
800
|
+
return n;
|
|
801
|
+
}
|
|
802
|
+
function boolOpt(options, name2) {
|
|
803
|
+
return options[name2] === true;
|
|
804
|
+
}
|
|
805
|
+
function readTextValue(value, name2) {
|
|
806
|
+
if (value === "-") return readFileSync(0, "utf8");
|
|
807
|
+
if (value.startsWith("@")) {
|
|
808
|
+
const file2 = value.slice(1);
|
|
809
|
+
if (!existsSync(file2)) usage(`--${name2}: ${file2} does not exist.`);
|
|
810
|
+
return readFileSync(file2, "utf8");
|
|
811
|
+
}
|
|
812
|
+
return value;
|
|
813
|
+
}
|
|
814
|
+
function jsonOpt(options, name2) {
|
|
815
|
+
const v = strOpt(options, name2);
|
|
816
|
+
if (v === void 0) return void 0;
|
|
817
|
+
const text = readTextValue(v, name2);
|
|
818
|
+
try {
|
|
819
|
+
return JSON.parse(text);
|
|
820
|
+
} catch (err) {
|
|
821
|
+
usage(`--${name2} is not valid JSON: ${err.message}`, `Example: --${name2} '{"name":"Acme"}' or --${name2} @file.json`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
function jsonObjectOpt(options, name2) {
|
|
825
|
+
const v = jsonOpt(options, name2);
|
|
826
|
+
if (v === void 0) return void 0;
|
|
827
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) usage(`--${name2} must be a JSON object.`);
|
|
828
|
+
return v;
|
|
829
|
+
}
|
|
830
|
+
function parseScalar(raw) {
|
|
831
|
+
const t = raw.trim();
|
|
832
|
+
if (t === "") return "";
|
|
833
|
+
try {
|
|
834
|
+
const v = JSON.parse(t);
|
|
835
|
+
if (typeof v === "string") return v;
|
|
836
|
+
if (typeof v === "number" && String(v) !== t) return raw;
|
|
837
|
+
return v;
|
|
838
|
+
} catch {
|
|
839
|
+
return raw;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
function fieldsInput(options, jsonName = "fields") {
|
|
843
|
+
const out = { ...jsonObjectOpt(options, jsonName) ?? {} };
|
|
844
|
+
for (const pair of multiOpt(options, "set")) {
|
|
845
|
+
const i = pair.indexOf("=");
|
|
846
|
+
if (i <= 0) usage(`--set expects key=value, got "${pair}".`, 'e.g. --set name="Acme Inc" --set employees=40');
|
|
847
|
+
out[pair.slice(0, i).trim()] = parseScalar(pair.slice(i + 1));
|
|
848
|
+
}
|
|
849
|
+
return out;
|
|
850
|
+
}
|
|
851
|
+
const VALUELESS = /* @__PURE__ */ new Set(["empty", "notEmpty"]);
|
|
852
|
+
const LIST_OPS = /* @__PURE__ */ new Set(["in", "notIn"]);
|
|
853
|
+
function parseFilters(values, ops) {
|
|
854
|
+
return values.map((f) => {
|
|
855
|
+
const first = f.indexOf(":");
|
|
856
|
+
if (first <= 0) usage(`--filter expects field:op:value, got "${f}".`, `Ops: ${ops.join(", ")}. e.g. --filter stage:eq:proposal --filter amount:gte:10000`);
|
|
857
|
+
const field = f.slice(0, first);
|
|
858
|
+
const rest = f.slice(first + 1);
|
|
859
|
+
const second = rest.indexOf(":");
|
|
860
|
+
const op = second < 0 ? rest : rest.slice(0, second);
|
|
861
|
+
const raw = second < 0 ? void 0 : rest.slice(second + 1);
|
|
862
|
+
if (!ops.includes(op)) usage(`Unknown filter op "${op}" in "${f}".`, `Ops: ${ops.join(", ")}.`);
|
|
863
|
+
if (VALUELESS.has(op)) return { field, op };
|
|
864
|
+
if (raw === void 0) usage(`--filter "${f}" needs a value (field:op:value).`);
|
|
865
|
+
let value = parseScalar(raw);
|
|
866
|
+
if (LIST_OPS.has(op) && !Array.isArray(value)) value = raw.split(",").map((s) => parseScalar(s.trim()));
|
|
867
|
+
return { field, op, value };
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
function parseSorts(values) {
|
|
871
|
+
return values.flatMap((v) => v.split(",")).map((s) => s.trim()).filter(Boolean).map((s) => {
|
|
872
|
+
if (s.startsWith("-")) return { field: s.slice(1), dir: "desc" };
|
|
873
|
+
const [field, dir = "asc"] = s.split(":");
|
|
874
|
+
if (dir !== "asc" && dir !== "desc") usage(`--sort direction must be asc or desc, got "${dir}".`);
|
|
875
|
+
return { field, dir };
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
function positional(positionals, index, what, usageLine) {
|
|
879
|
+
const v = positionals[index];
|
|
880
|
+
if (!v) usage(`Missing ${what}.`, `Usage: agentcrm ${usageLine}`);
|
|
881
|
+
return v;
|
|
882
|
+
}
|
|
883
|
+
function table(rows, columns) {
|
|
884
|
+
if (!rows.length) return "(none)";
|
|
885
|
+
const cell = (v) => (v === void 0 || v === null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v)).replace(/\s+/g, " ").slice(0, 60);
|
|
886
|
+
const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)));
|
|
887
|
+
const line = (vals) => vals.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
|
|
888
|
+
return [line(columns), line(widths.map((w) => "-".repeat(w))), ...rows.map((r) => line(columns.map((c) => cell(r[c]))))].join("\n");
|
|
889
|
+
}
|
|
890
|
+
const SITE_OPTION = { name: "site", type: "string", description: "Site alias (default: $AGENTCRM_SITE, ./agentcrm.json, the only site, or `site use`)." };
|
|
891
|
+
const AS_OPTION = { name: "as", type: "string", description: "Act as this CRM user (email or id) instead of the site's user." };
|
|
892
|
+
const YES_OPTION = { name: "yes", type: "boolean", description: "Apply — only after the user confirmed the plan." };
|
|
893
|
+
function fileOut(f) {
|
|
894
|
+
return {
|
|
895
|
+
id: f.id,
|
|
896
|
+
name: f.name,
|
|
897
|
+
mime: f.mime,
|
|
898
|
+
size: f.size,
|
|
899
|
+
...f.width && f.height ? { width: f.width, height: f.height } : {},
|
|
900
|
+
targets: f.targets,
|
|
901
|
+
...f.via ? { via: f.via.kind } : {},
|
|
902
|
+
createdAt: new Date(f.createdAt).toISOString(),
|
|
903
|
+
...f.deletedAt ? { deletedAt: new Date(f.deletedAt).toISOString() } : {}
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function readLocalFile(path) {
|
|
907
|
+
const full = resolve(path);
|
|
908
|
+
if (!existsSync(full)) usage(`${path} does not exist.`);
|
|
909
|
+
const st = statSync(full);
|
|
910
|
+
if (!st.isFile()) usage(`${path} is not a file.`);
|
|
911
|
+
if (st.size > 1024 * 1024 * 1024) usage(`${path} is larger than 1 GB.`);
|
|
912
|
+
return { name: basename(full), bytes: new Uint8Array(readFileSync(full)) };
|
|
913
|
+
}
|
|
914
|
+
async function uploadLocalFiles(ctx, paths, targets2 = []) {
|
|
915
|
+
const { uploadFile } = await import("./chunks/files-q8Xu5bft.mjs");
|
|
916
|
+
const { guessMime, imageDimensions } = await import("./chunks/files-CjsPJSR6.mjs");
|
|
917
|
+
const out = [];
|
|
918
|
+
for (const p of paths) {
|
|
919
|
+
const { name: name2, bytes } = readLocalFile(p);
|
|
920
|
+
const mime = guessMime(name2);
|
|
921
|
+
const dims = mime.startsWith("image/") ? imageDimensions(bytes) : null;
|
|
922
|
+
out.push(await uploadFile(ctx, { name: name2, mime, bytes, targets: targets2, ...dims ?? {} }));
|
|
923
|
+
}
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
const ATTACH_OPTION = { name: "attach", type: "string", description: "Attach a local file (repeatable; images are uploaded as they are).", multiple: true };
|
|
927
|
+
const U$1 = {
|
|
928
|
+
list: "files list [--record <id> | --activity <id>] [--related] [--kind image|pdf|document|…] [--search <name>] [--limit n]",
|
|
929
|
+
upload: "files upload <path>… (--record <id> | --activity <id>)",
|
|
930
|
+
get: "files get <id> --out <path> [--thumb]",
|
|
931
|
+
delete: "files delete <id> [--yes]",
|
|
932
|
+
usage: "files usage"
|
|
933
|
+
};
|
|
934
|
+
const filesCommand = {
|
|
935
|
+
name: "files",
|
|
936
|
+
usage: "files <list|upload|get|delete|usage> …",
|
|
937
|
+
summary: "Images and attachments: list a record's / activity's files (metadata), upload local files to a record or an activity, download one, soft-delete (bytes purged by `compact`), storage usage.",
|
|
938
|
+
subcommands: [
|
|
939
|
+
{ name: "list", usage: U$1.list, summary: "Files of a record (its own, its activities', --related: linked records') or of an activity; all files without a target." },
|
|
940
|
+
{ name: "upload", usage: U$1.upload, summary: "Upload local files and attach them to a record or an activity (CRM size limit applies).", writes: true },
|
|
941
|
+
{ name: "get", usage: U$1.get, summary: "Download a file's bytes (decrypted) to a local path." },
|
|
942
|
+
{ name: "delete", usage: U$1.delete, summary: "Soft-delete a file (removed from its records / activities; bytes purged by `compact`). Plan unless --yes.", writes: true, destructive: true },
|
|
943
|
+
{ name: "usage", usage: U$1.usage, summary: "Number of files and total size." }
|
|
944
|
+
],
|
|
945
|
+
options: [
|
|
946
|
+
SITE_OPTION,
|
|
947
|
+
AS_OPTION,
|
|
948
|
+
YES_OPTION,
|
|
949
|
+
{ name: "record", type: "string", description: "Record id." },
|
|
950
|
+
{ name: "activity", type: "string", description: "Activity id (note, task, call, email, meeting)." },
|
|
951
|
+
{ name: "related", type: "boolean", description: "list: include files of related records." },
|
|
952
|
+
{ name: "kind", type: "string", description: "list: image, pdf, document, spreadsheet, presentation, archive, text, audio, video, other." },
|
|
953
|
+
{ name: "search", type: "string", description: "list: part of the file name." },
|
|
954
|
+
{ name: "limit", type: "string", description: "list: max items (default 100)." },
|
|
955
|
+
{ name: "out", type: "string", description: "get: local path to write." },
|
|
956
|
+
{ name: "thumb", type: "boolean", description: "get: the thumbnail when there is one." }
|
|
957
|
+
],
|
|
958
|
+
async run({ io, options, positionals }) {
|
|
959
|
+
const [action, ...args] = positionals;
|
|
960
|
+
switch (action) {
|
|
961
|
+
case "list":
|
|
962
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
963
|
+
const { listFiles } = await import("./chunks/files-q8Xu5bft.mjs");
|
|
964
|
+
const target = strOpt(options, "record") ?? strOpt(options, "activity") ?? null;
|
|
965
|
+
const items = listFiles(ctx, target, { includeRelated: boolOpt(options, "related"), kind: strOpt(options, "kind"), search: strOpt(options, "search") });
|
|
966
|
+
const limit = Math.min(1e3, numOpt(options, "limit") ?? 100);
|
|
967
|
+
return { total: items.length, files: items.slice(0, limit).map(fileOut) };
|
|
968
|
+
}, (r) => `${r.total} file(s)
|
|
969
|
+
${table(r.files, ["id", "name", "mime", "size", "createdAt"])}`);
|
|
970
|
+
case "upload": {
|
|
971
|
+
if (!args.length) usage("files upload needs at least one path.", `Usage: agentcrm ${U$1.upload}`);
|
|
972
|
+
const record = strOpt(options, "record");
|
|
973
|
+
const activity = strOpt(options, "activity");
|
|
974
|
+
if (!record && !activity) usage("files upload needs --record <id> or --activity <id>.");
|
|
975
|
+
if (record && activity) usage("Give --record or --activity, not both.");
|
|
976
|
+
for (const p of args) readLocalFile(p);
|
|
977
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => {
|
|
978
|
+
const { attachFile } = await import("./chunks/files-q8Xu5bft.mjs");
|
|
979
|
+
const files2 = await uploadLocalFiles(ctx, args, record ? [record] : []);
|
|
980
|
+
if (activity) for (const f of files2) await attachFile(ctx, f.id, { kind: "activity", id: activity });
|
|
981
|
+
const { requireFile } = await import("./chunks/store-5gqRSKXg.mjs");
|
|
982
|
+
return { uploaded: files2.map((f) => fileOut(requireFile(ctx.store, f.id))) };
|
|
983
|
+
}, (r) => `Uploaded ${r.uploaded.length} file(s)
|
|
984
|
+
${table(r.uploaded, ["id", "name", "size"])}`);
|
|
985
|
+
}
|
|
986
|
+
case "get": {
|
|
987
|
+
const id = positional(args, 0, "file id", U$1.get);
|
|
988
|
+
const out = strOpt(options, "out");
|
|
989
|
+
if (!out) usage("files get needs --out <path>.");
|
|
990
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
991
|
+
const { getFile, getFileBytes } = await import("./chunks/files-q8Xu5bft.mjs");
|
|
992
|
+
const meta = getFile(ctx, id);
|
|
993
|
+
const bytes = await getFileBytes(ctx, id, { thumb: boolOpt(options, "thumb") });
|
|
994
|
+
writeFileSync(resolve(out), bytes);
|
|
995
|
+
return { id, name: meta.name, mime: meta.mime, bytes: bytes.length, path: resolve(out) };
|
|
996
|
+
}, (r) => `Wrote ${r.bytes} bytes (${r.name}) to ${r.path}`);
|
|
997
|
+
}
|
|
998
|
+
case "delete": {
|
|
999
|
+
const id = positional(args, 0, "file id", U$1.delete);
|
|
1000
|
+
return withSession(io, options, { lock: options.yes === true }, async ({ ctx }) => {
|
|
1001
|
+
const { getFile, deleteFile } = await import("./chunks/files-q8Xu5bft.mjs");
|
|
1002
|
+
const meta = fileOut(getFile(ctx, id));
|
|
1003
|
+
if (options.yes !== true) return { needsConfirmation: true, plan: { delete: meta } };
|
|
1004
|
+
await deleteFile(ctx, id);
|
|
1005
|
+
return { deleted: id };
|
|
1006
|
+
}, (r) => "plan" in r && r.plan ? `Would delete ${r.plan.delete.name} (${r.plan.delete.id}). Re-run with --yes once the user agrees.` : `Deleted ${"deleted" in r ? r.deleted : id} (bytes purged by \`agentcrm compact\`).`);
|
|
1007
|
+
}
|
|
1008
|
+
case "usage":
|
|
1009
|
+
return withSession(io, options, {}, async ({ ctx }) => (await import("./chunks/files-q8Xu5bft.mjs")).fileUsage(ctx));
|
|
1010
|
+
default:
|
|
1011
|
+
usage(`Unknown files action "${action ?? ""}".`, "Use list, upload, get, delete or usage.");
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
async function recordImageAction(io, options, args) {
|
|
1016
|
+
const [recordId, path] = args;
|
|
1017
|
+
if (!recordId) usage("records image needs a record id.", "Usage: agentcrm records image <recordId> <path> | --remove [--field <id>]");
|
|
1018
|
+
const remove = options.remove === true;
|
|
1019
|
+
if (!remove && !path) usage("records image needs an image path (or --remove).");
|
|
1020
|
+
const local = remove ? null : readLocalFile(path);
|
|
1021
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => {
|
|
1022
|
+
const { setRecordImage, clearRecordImage } = await import("./chunks/files-q8Xu5bft.mjs");
|
|
1023
|
+
const field = strOpt(options, "field");
|
|
1024
|
+
if (!local) {
|
|
1025
|
+
const r = await clearRecordImage(ctx, recordId, field);
|
|
1026
|
+
return { id: r.id, image: null };
|
|
1027
|
+
}
|
|
1028
|
+
const { guessMime, imageDimensions } = await import("./chunks/files-CjsPJSR6.mjs");
|
|
1029
|
+
const mime = guessMime(local.name);
|
|
1030
|
+
const res = await setRecordImage(ctx, recordId, field, { ...local, mime, ...imageDimensions(local.bytes) ?? {} });
|
|
1031
|
+
return { id: res.record.id, image: fileOut(res.file) };
|
|
1032
|
+
}, (r) => r.image ? `Image of ${r.id} set to ${r.image.name} (${r.image.id})` : `Image of ${r.id} removed`);
|
|
1033
|
+
}
|
|
1034
|
+
const files = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
1035
|
+
__proto__: null,
|
|
1036
|
+
ATTACH_OPTION,
|
|
1037
|
+
fileOut,
|
|
1038
|
+
filesCommand,
|
|
1039
|
+
readLocalFile,
|
|
1040
|
+
recordImageAction,
|
|
1041
|
+
uploadLocalFiles
|
|
1042
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
1043
|
+
function activityOut(a) {
|
|
1044
|
+
return {
|
|
1045
|
+
id: a.id,
|
|
1046
|
+
type: a.type,
|
|
1047
|
+
title: a.title,
|
|
1048
|
+
...a.body ? { body: a.body } : {},
|
|
1049
|
+
targets: a.targets,
|
|
1050
|
+
...a.dueAt ? { dueAt: new Date(a.dueAt).toISOString() } : {},
|
|
1051
|
+
...a.doneAt ? { doneAt: new Date(a.doneAt).toISOString() } : {},
|
|
1052
|
+
...a.assigneeId ? { assigneeId: a.assigneeId } : {},
|
|
1053
|
+
...a.meta ? { meta: a.meta } : {},
|
|
1054
|
+
...a.attachments?.length ? { attachments: a.attachments } : {},
|
|
1055
|
+
createdAt: new Date(a.createdAt).toISOString()
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
const targets = (ids) => ids.map((id) => ({ object: "", id }));
|
|
1059
|
+
function body(options) {
|
|
1060
|
+
const v = options.body;
|
|
1061
|
+
return typeof v === "string" ? readTextValue(v, "body") : void 0;
|
|
1062
|
+
}
|
|
1063
|
+
async function userRef(ctx, ref) {
|
|
1064
|
+
if (!ref) return void 0;
|
|
1065
|
+
const { usersOf } = await import("./chunks/store-5gqRSKXg.mjs");
|
|
1066
|
+
const u = usersOf(ctx.store).find((x) => x.id === ref || x.email.toLowerCase() === ref.toLowerCase());
|
|
1067
|
+
if (!u) usage(`No CRM user "${ref}".`, "`agentcrm users list` lists them.");
|
|
1068
|
+
return u.id;
|
|
1069
|
+
}
|
|
1070
|
+
function attachPaths(options) {
|
|
1071
|
+
const v = options.attach;
|
|
1072
|
+
const paths = Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
|
|
1073
|
+
for (const p of paths) readLocalFile(p);
|
|
1074
|
+
return paths;
|
|
1075
|
+
}
|
|
1076
|
+
async function attachments(ctx, paths) {
|
|
1077
|
+
if (!paths.length) return void 0;
|
|
1078
|
+
return (await uploadLocalFiles(ctx, paths)).map((f) => f.id);
|
|
1079
|
+
}
|
|
1080
|
+
const RECORD_OPT = { name: "record", type: "string", description: "Record id the activity is attached to (repeatable).", multiple: true };
|
|
1081
|
+
const activitiesCommand = {
|
|
1082
|
+
name: "activities",
|
|
1083
|
+
usage: "activities <add|list|done> …",
|
|
1084
|
+
summary: "Timeline entries: log calls, emails, meetings and notes on records; list a record's timeline (optionally with its related records' activities).",
|
|
1085
|
+
subcommands: [
|
|
1086
|
+
{ name: "add", usage: `activities add --type <${ACTIVITY_TYPES.join("|")}> --title <t> [--body <md|@file>] --record <id>… [--meta <json>] [--due <date>] [--assignee <user>] [--attach <path>]…`, summary: "Log an activity (with attachments).", writes: true },
|
|
1087
|
+
{ name: "list", usage: "activities list [--record <id>] [--type <t>]… [--related] [--limit n]", summary: "Timeline, most recent first." },
|
|
1088
|
+
{ name: "done", usage: "activities done <id> [--undo]", summary: "Mark a task done (or reopen).", writes: true }
|
|
1089
|
+
],
|
|
1090
|
+
options: [
|
|
1091
|
+
SITE_OPTION,
|
|
1092
|
+
AS_OPTION,
|
|
1093
|
+
{ name: "type", type: "string", description: `Activity type: ${ACTIVITY_TYPES.join(", ")} (list: repeatable filter).`, multiple: true },
|
|
1094
|
+
{ name: "title", type: "string", description: "Title / subject." },
|
|
1095
|
+
{ name: "body", type: "string", description: "Markdown body, or @file." },
|
|
1096
|
+
RECORD_OPT,
|
|
1097
|
+
{ name: "meta", type: "string", description: 'JSON metadata, e.g. {"durationMin":20} or {"startAt":…,"endAt":…}.' },
|
|
1098
|
+
{ name: "due", type: "string", description: "Due date (tasks): ISO date / datetime." },
|
|
1099
|
+
{ name: "assignee", type: "string", description: "Assignee user id or email." },
|
|
1100
|
+
{ name: "related", type: "boolean", description: "list: include activities of linked records." },
|
|
1101
|
+
{ name: "limit", type: "string", description: "Max items (default 50)." },
|
|
1102
|
+
{ name: "undo", type: "boolean", description: "done: reopen the task." },
|
|
1103
|
+
ATTACH_OPTION
|
|
1104
|
+
],
|
|
1105
|
+
async run({ io, options, positionals }) {
|
|
1106
|
+
const [action, id] = positionals;
|
|
1107
|
+
switch (action) {
|
|
1108
|
+
case "add": {
|
|
1109
|
+
const files2 = attachPaths(options);
|
|
1110
|
+
const type2 = requireStr(options, "type");
|
|
1111
|
+
if (!ACTIVITY_TYPES.includes(type2)) usage(`Unknown activity type "${type2}".`, `Types: ${ACTIVITY_TYPES.join(", ")}.`);
|
|
1112
|
+
const title = requireStr(options, "title");
|
|
1113
|
+
const recs = multiOpt(options, "record");
|
|
1114
|
+
if (!recs.length && type2 !== "task") usage("activities add needs --record <id> (repeatable).");
|
|
1115
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => {
|
|
1116
|
+
const { addActivity } = await import("./chunks/activities-Cj_SIOay.mjs");
|
|
1117
|
+
return activityOut(
|
|
1118
|
+
await addActivity(ctx, {
|
|
1119
|
+
type: type2,
|
|
1120
|
+
title,
|
|
1121
|
+
body: body(options),
|
|
1122
|
+
targets: targets(recs),
|
|
1123
|
+
meta: jsonObjectOpt(options, "meta"),
|
|
1124
|
+
...strOpt(options, "due") ? { dueAt: strOpt(options, "due") } : {},
|
|
1125
|
+
...strOpt(options, "assignee") ? { assigneeId: await userRef(ctx, strOpt(options, "assignee")) } : {},
|
|
1126
|
+
attachments: await attachments(ctx, files2)
|
|
1127
|
+
})
|
|
1128
|
+
);
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
case "list":
|
|
1132
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
1133
|
+
const { listActivities } = await import("./chunks/activities-Cj_SIOay.mjs");
|
|
1134
|
+
const types = listOpt(options, "type");
|
|
1135
|
+
const page = listActivities(ctx, { target: multiOpt(options, "record")[0], types, includeRelated: boolOpt(options, "related"), limit: Math.min(500, numOpt(options, "limit") ?? 50) });
|
|
1136
|
+
return { total: page.total, activities: page.items.map(activityOut) };
|
|
1137
|
+
}, (r) => `${r.total} activit${r.total === 1 ? "y" : "ies"}
|
|
1138
|
+
${table(r.activities, ["id", "type", "title", "createdAt"])}`);
|
|
1139
|
+
case "done": {
|
|
1140
|
+
if (!id) usage("activities done needs an id.");
|
|
1141
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => activityOut(await (await import("./chunks/activities-Cj_SIOay.mjs")).completeTask(ctx, id, !boolOpt(options, "undo"))));
|
|
1142
|
+
}
|
|
1143
|
+
default:
|
|
1144
|
+
usage(`Unknown activities action "${action ?? ""}".`, "Use add, list or done.");
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
const notesCommand = {
|
|
1149
|
+
name: "notes",
|
|
1150
|
+
usage: "notes add <recordId> --body <markdown|@file> [--title <t>] [--attach <path>]…",
|
|
1151
|
+
summary: "Add a note (Markdown, optional attachments) to a record's timeline.",
|
|
1152
|
+
subcommands: [{ name: "add", usage: "notes add <recordId> --body <markdown|@file> [--title <t>] [--attach <path>]…", summary: "Add a note.", writes: true }],
|
|
1153
|
+
options: [SITE_OPTION, AS_OPTION, { name: "body", type: "string", description: "Markdown, or @file." }, { name: "title", type: "string", description: "Title (default: first line)." }, ATTACH_OPTION],
|
|
1154
|
+
async run({ io, options, positionals }) {
|
|
1155
|
+
const [action, recordId] = positionals;
|
|
1156
|
+
if (action !== "add") usage(`Unknown notes action "${action ?? ""}".`, "Use: notes add <recordId> --body …");
|
|
1157
|
+
const id = positional(positionals, 1, "record id", "notes add <recordId> --body …");
|
|
1158
|
+
const text = body(options);
|
|
1159
|
+
if (!text?.trim()) usage("notes add needs --body.");
|
|
1160
|
+
const files2 = attachPaths(options);
|
|
1161
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => {
|
|
1162
|
+
const ops = await import("./chunks/activities-Cj_SIOay.mjs");
|
|
1163
|
+
const { recordOf } = await import("./chunks/store-5gqRSKXg.mjs");
|
|
1164
|
+
const r = recordOf(ctx.store, id);
|
|
1165
|
+
if (!files2.length || !r) return activityOut(await ops.addNote(ctx, id, text, strOpt(options, "title")));
|
|
1166
|
+
return activityOut(await ops.addActivity(ctx, { type: "note", title: strOpt(options, "title") ?? "", body: text, targets: [{ object: r.object, id: r.id }], attachments: await attachments(ctx, files2) }));
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
const tasksCommand = {
|
|
1171
|
+
name: "tasks",
|
|
1172
|
+
usage: "tasks <list|add|done|reassign> …",
|
|
1173
|
+
summary: "Tasks: list (open by default; by assignee, record, overdue, due soon), add with due date and assignee, mark done, reassign.",
|
|
1174
|
+
subcommands: [
|
|
1175
|
+
{ name: "list", usage: "tasks list [--assignee <user|me>] [--record <id>] [--status open|done|all] [--overdue] [--due-within <days>] [--limit n]", summary: "Tasks sorted by due date." },
|
|
1176
|
+
{ name: "add", usage: "tasks add --title <t> [--due <date>] [--assignee <user|me>] [--record <id>]… [--body <md>] [--attach <path>]…", summary: "Create a task.", writes: true },
|
|
1177
|
+
{ name: "done", usage: "tasks done <id> [--undo]", summary: "Complete (or reopen) a task.", writes: true },
|
|
1178
|
+
{ name: "reassign", usage: "tasks reassign <id> --assignee <user>", summary: "Change the assignee.", writes: true }
|
|
1179
|
+
],
|
|
1180
|
+
options: [
|
|
1181
|
+
SITE_OPTION,
|
|
1182
|
+
AS_OPTION,
|
|
1183
|
+
{ name: "title", type: "string", description: "Task title." },
|
|
1184
|
+
{ name: "body", type: "string", description: "Details (Markdown or @file)." },
|
|
1185
|
+
{ name: "due", type: "string", description: "Due date / datetime (ISO)." },
|
|
1186
|
+
{ name: "assignee", type: "string", description: 'User id or email; "me" = the CLI user.' },
|
|
1187
|
+
RECORD_OPT,
|
|
1188
|
+
{ name: "status", type: "string", description: "list: open (default), done or all." },
|
|
1189
|
+
{ name: "overdue", type: "boolean", description: "list: only overdue." },
|
|
1190
|
+
{ name: "due-within", type: "string", description: "list: due within N days." },
|
|
1191
|
+
{ name: "limit", type: "string", description: "Max items (default 50)." },
|
|
1192
|
+
{ name: "undo", type: "boolean", description: "done: reopen." },
|
|
1193
|
+
ATTACH_OPTION
|
|
1194
|
+
],
|
|
1195
|
+
async run({ io, options, positionals }) {
|
|
1196
|
+
const [action, id] = positionals;
|
|
1197
|
+
const assignee = async (ctx) => {
|
|
1198
|
+
const a = strOpt(options, "assignee");
|
|
1199
|
+
if (a === "me") return ctx.user?.id;
|
|
1200
|
+
return userRef(ctx, a);
|
|
1201
|
+
};
|
|
1202
|
+
switch (action) {
|
|
1203
|
+
case "list":
|
|
1204
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
1205
|
+
const { listTasks } = await import("./chunks/activities-Cj_SIOay.mjs");
|
|
1206
|
+
const status = strOpt(options, "status") ?? "open";
|
|
1207
|
+
if (!["open", "done", "all"].includes(status)) usage("--status must be open, done or all.");
|
|
1208
|
+
const page = listTasks(ctx, { assigneeId: await assignee(ctx), target: multiOpt(options, "record")[0], status, overdue: boolOpt(options, "overdue"), dueWithinDays: numOpt(options, "due-within"), limit: Math.min(500, numOpt(options, "limit") ?? 50) });
|
|
1209
|
+
return { total: page.total, tasks: page.items.map(activityOut) };
|
|
1210
|
+
}, (r) => `${r.total} task(s)
|
|
1211
|
+
${table(r.tasks, ["id", "title", "dueAt", "assigneeId"])}`);
|
|
1212
|
+
case "add": {
|
|
1213
|
+
const title = requireStr(options, "title");
|
|
1214
|
+
const files2 = attachPaths(options);
|
|
1215
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => {
|
|
1216
|
+
const { addActivity } = await import("./chunks/activities-Cj_SIOay.mjs");
|
|
1217
|
+
return activityOut(await addActivity(ctx, { type: "task", title, body: body(options), dueAt: strOpt(options, "due"), assigneeId: await assignee(ctx) ?? ctx.user?.id, targets: targets(multiOpt(options, "record")), attachments: await attachments(ctx, files2) }));
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
case "done":
|
|
1221
|
+
if (!id) usage("tasks done needs a task id.");
|
|
1222
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => activityOut(await (await import("./chunks/activities-Cj_SIOay.mjs")).completeTask(ctx, id, !boolOpt(options, "undo"))));
|
|
1223
|
+
case "reassign":
|
|
1224
|
+
if (!id) usage("tasks reassign needs a task id.");
|
|
1225
|
+
requireStr(options, "assignee");
|
|
1226
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => activityOut(await (await import("./chunks/activities-Cj_SIOay.mjs")).updateActivity(ctx, id, { assigneeId: await assignee(ctx) ?? null })));
|
|
1227
|
+
default:
|
|
1228
|
+
usage(`Unknown tasks action "${action ?? ""}".`, "Use list, add, done or reassign.");
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
async function recordOut(ctx, r, columns) {
|
|
1233
|
+
const { recordLabel } = await import("./chunks/fields-B0bitgmr.mjs");
|
|
1234
|
+
const { objectOf } = await import("./chunks/store-5gqRSKXg.mjs");
|
|
1235
|
+
const fields = columns ? Object.fromEntries(columns.filter((c) => c in r.fields).map((c) => [c, r.fields[c]])) : r.fields;
|
|
1236
|
+
return {
|
|
1237
|
+
id: r.id,
|
|
1238
|
+
object: r.object,
|
|
1239
|
+
label: recordLabel(objectOf(ctx.store, r.object), r),
|
|
1240
|
+
fields,
|
|
1241
|
+
...r.ownerId ? { ownerId: r.ownerId } : {},
|
|
1242
|
+
createdAt: new Date(r.createdAt).toISOString(),
|
|
1243
|
+
updatedAt: new Date(r.updatedAt).toISOString(),
|
|
1244
|
+
...r.deletedAt ? { deletedAt: new Date(r.deletedAt).toISOString() } : {}
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
const U = {
|
|
1248
|
+
list: "records list --object <id> [--filter field:op:value]… [--sort field[:desc]] [--search <q>] [--view <id>] [--owner <userId>] [--limit n] [--offset n] [--columns a,b] [--deleted]",
|
|
1249
|
+
get: "records get <id>",
|
|
1250
|
+
create: "records create --object <id> [--fields <json|@file>] [--set key=value]… [--owner <userId>]",
|
|
1251
|
+
update: "records update <id> [--fields <json|@file>] [--set key=value]… [--owner <userId>]",
|
|
1252
|
+
delete: "records delete <id>… [--hard] [--yes]",
|
|
1253
|
+
restore: "records restore <id>",
|
|
1254
|
+
search: "records search <query> [--object <id>]… [--limit n]",
|
|
1255
|
+
duplicates: "records duplicates --object <id> [--by email,domain,name,phone]",
|
|
1256
|
+
merge: "records merge <primaryId> <duplicateId>… [--yes]",
|
|
1257
|
+
image: "records image <recordId> <path> | --remove [--field <imageFieldId>]"
|
|
1258
|
+
};
|
|
1259
|
+
const recordsCommand = {
|
|
1260
|
+
name: "records",
|
|
1261
|
+
usage: "records <list|get|create|update|delete|restore|search|duplicates|merge|image> …",
|
|
1262
|
+
summary: "Records of any object (companies, people, deals, custom objects): list with filters / sorts / pagination, get with relations and timeline counts, create, update (only the given fields), soft delete / restore, full-text search, find and merge duplicates.",
|
|
1263
|
+
subcommands: [
|
|
1264
|
+
{ name: "list", usage: U.list, summary: `List records. Filter ops: ${FILTER_OPS.join(", ")}. Values: in/notIn comma-separated; dates YYYY-MM-DD, today, +30d.` },
|
|
1265
|
+
{ name: "get", usage: U.get, summary: "One record with its relations (both directions) and activity count." },
|
|
1266
|
+
{ name: "create", usage: U.create, summary: "Create a record (field ids from `objects get`; select fields take an option id or label).", writes: true },
|
|
1267
|
+
{ name: "update", usage: U.update, summary: "Update some fields (null clears a field).", writes: true },
|
|
1268
|
+
{ name: "delete", usage: U.delete, summary: "Soft delete (restorable until `compact`); --hard removes at once. Plan unless --yes.", writes: true, destructive: true },
|
|
1269
|
+
{ name: "restore", usage: U.restore, summary: "Restore a soft-deleted record.", writes: true },
|
|
1270
|
+
{ name: "search", usage: U.search, summary: "Full-text search (names, emails, domains, phones)." },
|
|
1271
|
+
{ name: "duplicates", usage: U.duplicates, summary: "Groups of likely duplicates (same email / domain / name / phone)." },
|
|
1272
|
+
{ name: "merge", usage: U.merge, summary: "Merge duplicates into the primary (empty fields filled, relations and activities moved). Plan unless --yes.", writes: true, destructive: true },
|
|
1273
|
+
{ name: "image", usage: U.image, summary: "Set the record's avatar / logo from a local image (the object's image field), or --remove it.", writes: true }
|
|
1274
|
+
],
|
|
1275
|
+
options: [
|
|
1276
|
+
SITE_OPTION,
|
|
1277
|
+
AS_OPTION,
|
|
1278
|
+
YES_OPTION,
|
|
1279
|
+
{ name: "object", type: "string", description: "Object id (companies, people, deals, …).", multiple: true },
|
|
1280
|
+
{ name: "filter", type: "string", description: "field:op:value (repeatable).", multiple: true },
|
|
1281
|
+
{ name: "sort", type: "string", description: "field[:asc|desc] (repeatable; --sort=-field also means desc).", multiple: true },
|
|
1282
|
+
{ name: "search", type: "string", description: "list: full-text filter." },
|
|
1283
|
+
{ name: "view", type: "string", description: "list: apply a saved view's filters and sorts." },
|
|
1284
|
+
{ name: "owner", type: "string", description: 'list: owned by this user id; create / update: set the owner ("" clears).' },
|
|
1285
|
+
{ name: "limit", type: "string", description: "Page size (default 50, max 500)." },
|
|
1286
|
+
{ name: "offset", type: "string", description: "Page offset." },
|
|
1287
|
+
{ name: "columns", type: "string", description: "list: only these field ids in the output (comma-separated)." },
|
|
1288
|
+
{ name: "deleted", type: "boolean", description: "list: only soft-deleted records." },
|
|
1289
|
+
{ name: "fields", type: "string", description: "create / update: field values as a JSON object or @file.json." },
|
|
1290
|
+
{ name: "set", type: "string", description: "create / update: key=value (repeatable; numbers / true / null / JSON parsed).", multiple: true },
|
|
1291
|
+
{ name: "hard", type: "boolean", description: "delete: remove immediately (no restore)." },
|
|
1292
|
+
{ name: "by", type: "string", description: "duplicates: email,domain,name,phone." },
|
|
1293
|
+
{ name: "remove", type: "boolean", description: "image: remove the record's image." },
|
|
1294
|
+
{ name: "field", type: "string", description: "image: image field id (default: the object's image field)." }
|
|
1295
|
+
],
|
|
1296
|
+
async run({ io, options, positionals }) {
|
|
1297
|
+
const [action, ...args] = positionals;
|
|
1298
|
+
const object = () => {
|
|
1299
|
+
const o = listOpt(options, "object")?.[0];
|
|
1300
|
+
if (!o) usage("--object is required.", "Objects: `agentcrm objects list`.");
|
|
1301
|
+
return o;
|
|
1302
|
+
};
|
|
1303
|
+
switch (action) {
|
|
1304
|
+
case "list": {
|
|
1305
|
+
const objectId = object();
|
|
1306
|
+
const columns = listOpt(options, "columns");
|
|
1307
|
+
const filters = parseFilters(multiOpt(options, "filter"), FILTER_OPS);
|
|
1308
|
+
const sorts = parseSorts(multiOpt(options, "sort"));
|
|
1309
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
1310
|
+
const { listRecords } = await import("./chunks/records-DoqiT9xR.mjs");
|
|
1311
|
+
const page = listRecords(ctx, objectId, {
|
|
1312
|
+
filters,
|
|
1313
|
+
sorts,
|
|
1314
|
+
search: strOpt(options, "search"),
|
|
1315
|
+
viewId: strOpt(options, "view"),
|
|
1316
|
+
ownerId: strOpt(options, "owner"),
|
|
1317
|
+
offset: numOpt(options, "offset") ?? 0,
|
|
1318
|
+
limit: Math.min(500, numOpt(options, "limit") ?? 50),
|
|
1319
|
+
onlyDeleted: boolOpt(options, "deleted")
|
|
1320
|
+
});
|
|
1321
|
+
return { object: objectId, total: page.total, offset: page.offset, hasMore: page.hasMore, records: await Promise.all(page.items.map((r) => recordOut(ctx, r, columns))) };
|
|
1322
|
+
}, (r) => `${r.total} record(s)${r.hasMore ? ` (showing ${r.records.length} from ${r.offset})` : ""}
|
|
1323
|
+
${table(r.records, ["id", "label", "updatedAt"])}`);
|
|
1324
|
+
}
|
|
1325
|
+
case "get": {
|
|
1326
|
+
const id = positional(args, 0, "record id", U.get);
|
|
1327
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
1328
|
+
const { getRecordDetails } = await import("./chunks/records-DoqiT9xR.mjs");
|
|
1329
|
+
const d = getRecordDetails(ctx, id);
|
|
1330
|
+
return { ...await recordOut(ctx, d.record), relations: d.relations, related: d.inverse.filter((i) => i.records.length), activityCount: d.activityCount };
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
case "create": {
|
|
1334
|
+
const objectId = object();
|
|
1335
|
+
const fields = fieldsInput(options);
|
|
1336
|
+
if (!Object.keys(fields).length) usage("records create needs --fields or --set.", `Usage: agentcrm ${U.create}`);
|
|
1337
|
+
const owner = strOpt(options, "owner");
|
|
1338
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => {
|
|
1339
|
+
const { createRecord } = await import("./chunks/records-DoqiT9xR.mjs");
|
|
1340
|
+
return recordOut(ctx, await createRecord(ctx, objectId, fields, owner ? { ownerId: owner } : {}));
|
|
1341
|
+
}, (r) => `Created ${r.object} ${r.id} (${String(r.label)})`);
|
|
1342
|
+
}
|
|
1343
|
+
case "update": {
|
|
1344
|
+
const id = positional(args, 0, "record id", U.update);
|
|
1345
|
+
const fields = fieldsInput(options);
|
|
1346
|
+
const owner = strOpt(options, "owner");
|
|
1347
|
+
if (!Object.keys(fields).length && owner === void 0) usage("records update needs --fields, --set or --owner.");
|
|
1348
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => {
|
|
1349
|
+
const { updateRecord } = await import("./chunks/records-DoqiT9xR.mjs");
|
|
1350
|
+
return recordOut(ctx, await updateRecord(ctx, id, fields, owner !== void 0 ? { ownerId: owner || null } : {}));
|
|
1351
|
+
}, (r) => `Updated ${r.id} (${String(r.label)})`);
|
|
1352
|
+
}
|
|
1353
|
+
case "delete": {
|
|
1354
|
+
if (!args.length) usage("records delete needs at least one id.", `Usage: agentcrm ${U.delete}`);
|
|
1355
|
+
const hard = boolOpt(options, "hard");
|
|
1356
|
+
return withSession(io, options, { lock: options.yes === true }, async ({ ctx }) => {
|
|
1357
|
+
const { getRecord, deleteRecord, bulkDelete } = await import("./chunks/records-DoqiT9xR.mjs");
|
|
1358
|
+
const { recordLabel } = await import("./chunks/fields-B0bitgmr.mjs");
|
|
1359
|
+
const { objectOf } = await import("./chunks/store-5gqRSKXg.mjs");
|
|
1360
|
+
const targets2 = args.map((id) => getRecord(ctx, id, { includeDeleted: true }));
|
|
1361
|
+
const plan = { delete: targets2.map((r) => ({ id: r.id, object: r.object, label: recordLabel(objectOf(ctx.store, r.object), r) })), hard, restorable: !hard };
|
|
1362
|
+
if (options.yes !== true) return { needsConfirmation: true, plan };
|
|
1363
|
+
if (args.length === 1) return { deleted: [await deleteRecord(ctx, args[0], { hard })], hard };
|
|
1364
|
+
return { ...await bulkDelete(ctx, args, { hard }), hard };
|
|
1365
|
+
}, (r) => "needsConfirmation" in r && r.plan ? `Would delete ${r.plan.delete.length} record(s)${hard ? " permanently" : ""}. Re-run with --yes once the user agrees.` : `Deleted ${JSON.stringify(r.deleted)}`);
|
|
1366
|
+
}
|
|
1367
|
+
case "restore": {
|
|
1368
|
+
const id = positional(args, 0, "record id", U.restore);
|
|
1369
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => {
|
|
1370
|
+
const { restoreRecord } = await import("./chunks/records-DoqiT9xR.mjs");
|
|
1371
|
+
return recordOut(ctx, await restoreRecord(ctx, id));
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
case "search": {
|
|
1375
|
+
const query = args.join(" ").trim();
|
|
1376
|
+
if (!query) usage("records search needs a query.", `Usage: agentcrm ${U.search}`);
|
|
1377
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
1378
|
+
const { searchRecords } = await import("./chunks/records-DoqiT9xR.mjs");
|
|
1379
|
+
return searchRecords(ctx, query, { objects: listOpt(options, "object"), limit: Math.min(100, numOpt(options, "limit") ?? 20) });
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
case "duplicates": {
|
|
1383
|
+
const objectId = object();
|
|
1384
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
1385
|
+
const { findDuplicates } = await import("./chunks/dedupe-BepmsaHv.mjs");
|
|
1386
|
+
const by = listOpt(options, "by");
|
|
1387
|
+
const groups = findDuplicates(ctx, objectId, by ? { by } : {});
|
|
1388
|
+
return { object: objectId, groups: groups.length, duplicates: groups };
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
case "merge": {
|
|
1392
|
+
const [primary, ...dups] = args;
|
|
1393
|
+
if (!primary || !dups.length) usage("records merge needs <primaryId> <duplicateId>….", `Usage: agentcrm ${U.merge}`);
|
|
1394
|
+
return withSession(io, options, { lock: options.yes === true }, async ({ ctx }) => {
|
|
1395
|
+
const { getRecord } = await import("./chunks/records-DoqiT9xR.mjs");
|
|
1396
|
+
const all = [primary, ...dups].map((id) => getRecord(ctx, id));
|
|
1397
|
+
const plan = { primary: await recordOut(ctx, all[0]), duplicates: await Promise.all(all.slice(1).map((r) => recordOut(ctx, r))) };
|
|
1398
|
+
if (options.yes !== true) return { needsConfirmation: true, plan };
|
|
1399
|
+
const { mergeRecords } = await import("./chunks/dedupe-BepmsaHv.mjs");
|
|
1400
|
+
const res = await mergeRecords(ctx, primary, dups);
|
|
1401
|
+
return { record: await recordOut(ctx, res.record), merged: res.merged, movedActivities: res.movedActivities };
|
|
1402
|
+
}, (r) => "plan" in r && r.plan ? `Would merge ${r.plan.duplicates.length} duplicate(s) into ${r.plan.primary.id}. Re-run with --yes once the user agrees.` : "merged" in r ? `Merged ${r.merged.length} into ${r.record.id}` : "");
|
|
1403
|
+
}
|
|
1404
|
+
case "image":
|
|
1405
|
+
return (await Promise.resolve().then(() => files)).recordImageAction(io, options, args);
|
|
1406
|
+
default:
|
|
1407
|
+
usage(`Unknown records action "${action ?? ""}".`, "Use list, get, create, update, delete, restore, search, duplicates, merge or image.");
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
const pipelinesCommand = {
|
|
1412
|
+
name: "pipelines",
|
|
1413
|
+
usage: "pipelines <list|move> …",
|
|
1414
|
+
summary: "Pipelines and their stages (probability, open / won / lost); move a deal to a stage (updates probability; lostReason for lost stages).",
|
|
1415
|
+
subcommands: [
|
|
1416
|
+
{ name: "list", usage: "pipelines list [--object <id>]", summary: "Pipelines with stages." },
|
|
1417
|
+
{ name: "move", usage: "pipelines move <recordId> <stage id or label> [--lost-reason <text>]", summary: "Move a record to a stage.", writes: true }
|
|
1418
|
+
],
|
|
1419
|
+
options: [SITE_OPTION, AS_OPTION, { name: "object", type: "string", description: "list: only this object's pipelines." }, { name: "lost-reason", type: "string", description: "move: why the deal was lost." }],
|
|
1420
|
+
async run({ io, options, positionals }) {
|
|
1421
|
+
const [action = "list", id, ...stage] = positionals;
|
|
1422
|
+
if (action === "list") {
|
|
1423
|
+
return withSession(
|
|
1424
|
+
io,
|
|
1425
|
+
options,
|
|
1426
|
+
{},
|
|
1427
|
+
async ({ ctx }) => (await import("./chunks/pipelines-DCdRBNBV.mjs")).listPipelines(ctx, strOpt(options, "object")),
|
|
1428
|
+
(ps) => ps.map((p) => `${p.id} (${p.name}, ${p.object}): ${p.stages.map((s) => `${s.label} ${Math.round(s.probability * (s.probability <= 1 ? 100 : 1))}%`).join(" → ")}`).join("\n")
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
if (action === "move") {
|
|
1432
|
+
if (!id || !stage.length) usage("pipelines move needs <recordId> <stage>.");
|
|
1433
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => recordOut(ctx, await (await import("./chunks/pipelines-DCdRBNBV.mjs")).moveStage(ctx, id, stage.join(" "), { lostReason: strOpt(options, "lost-reason") })));
|
|
1434
|
+
}
|
|
1435
|
+
usage(`Unknown pipelines action "${action}".`, "Use list or move.");
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
const viewsCommand = {
|
|
1439
|
+
name: "views",
|
|
1440
|
+
usage: "views <list|create|delete> …",
|
|
1441
|
+
summary: "Saved views (table / kanban) of an object: list, create with filters / sorts / columns, delete.",
|
|
1442
|
+
subcommands: [
|
|
1443
|
+
{ name: "list", usage: "views list [--object <id>]", summary: "Views visible to the CLI user." },
|
|
1444
|
+
{ name: "create", usage: "views create --object <id> --name <n> [--type table|kanban] [--columns a,b] [--filter f:op:v]… [--sort f[:desc]] [--group-by <field>] [--shared]", summary: "Create a view.", writes: true },
|
|
1445
|
+
{ name: "delete", usage: "views delete <id> [--yes]", summary: "Delete a view. Plan unless --yes.", writes: true, destructive: true }
|
|
1446
|
+
],
|
|
1447
|
+
options: [
|
|
1448
|
+
SITE_OPTION,
|
|
1449
|
+
AS_OPTION,
|
|
1450
|
+
YES_OPTION,
|
|
1451
|
+
{ name: "object", type: "string", description: "Object id." },
|
|
1452
|
+
{ name: "name", type: "string", description: "View name." },
|
|
1453
|
+
{ name: "type", type: "string", description: "table (default) or kanban." },
|
|
1454
|
+
{ name: "columns", type: "string", description: "Column field ids, comma-separated." },
|
|
1455
|
+
{ name: "filter", type: "string", description: "field:op:value (repeatable).", multiple: true },
|
|
1456
|
+
{ name: "sort", type: "string", description: "field[:asc|desc] (repeatable).", multiple: true },
|
|
1457
|
+
{ name: "group-by", type: "string", description: "kanban: select field to group by." },
|
|
1458
|
+
{ name: "pipeline", type: "string", description: "kanban: pipeline id." },
|
|
1459
|
+
{ name: "shared", type: "boolean", description: "Visible to every user (admin)." }
|
|
1460
|
+
],
|
|
1461
|
+
async run({ io, options, positionals }) {
|
|
1462
|
+
const [action = "list", id] = positionals;
|
|
1463
|
+
switch (action) {
|
|
1464
|
+
case "list":
|
|
1465
|
+
return withSession(io, options, {}, async ({ ctx }) => (await import("./chunks/views-fO_BM5U_.mjs")).listViews(ctx, strOpt(options, "object")), (vs) => table(vs, ["id", "object", "name", "type", "shared"]));
|
|
1466
|
+
case "create": {
|
|
1467
|
+
const type2 = strOpt(options, "type") ?? "table";
|
|
1468
|
+
if (type2 !== "table" && type2 !== "kanban") usage("--type must be table or kanban.");
|
|
1469
|
+
const input = {
|
|
1470
|
+
object: requireStr(options, "object"),
|
|
1471
|
+
name: requireStr(options, "name"),
|
|
1472
|
+
type: type2,
|
|
1473
|
+
...listOpt(options, "columns") ? { columns: listOpt(options, "columns") } : {},
|
|
1474
|
+
filters: parseFilters(multiOpt(options, "filter"), FILTER_OPS),
|
|
1475
|
+
sorts: parseSorts(multiOpt(options, "sort")),
|
|
1476
|
+
...strOpt(options, "group-by") ? { groupBy: strOpt(options, "group-by") } : {},
|
|
1477
|
+
...strOpt(options, "pipeline") ? { pipelineId: strOpt(options, "pipeline") } : {},
|
|
1478
|
+
shared: boolOpt(options, "shared")
|
|
1479
|
+
};
|
|
1480
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => (await import("./chunks/views-fO_BM5U_.mjs")).createView(ctx, input));
|
|
1481
|
+
}
|
|
1482
|
+
case "delete":
|
|
1483
|
+
if (!id) usage("views delete needs a view id.");
|
|
1484
|
+
return withSession(io, options, { lock: options.yes === true }, async ({ ctx }) => {
|
|
1485
|
+
const { getView, deleteView } = await import("./chunks/views-fO_BM5U_.mjs");
|
|
1486
|
+
const v = getView(ctx, id);
|
|
1487
|
+
if (options.yes !== true) return { needsConfirmation: true, plan: { delete: { id: v.id, name: v.name, object: v.object, shared: v.shared } } };
|
|
1488
|
+
return deleteView(ctx, id);
|
|
1489
|
+
});
|
|
1490
|
+
default:
|
|
1491
|
+
usage(`Unknown views action "${action}".`, "Use list, create or delete.");
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
const usersCommand = {
|
|
1496
|
+
name: "users",
|
|
1497
|
+
usage: "users <list|add|role|disable> …",
|
|
1498
|
+
summary: `CRM users and roles (${USER_ROLES.join(", ")}): list, add, change role, disable / re-enable. Admin only for changes. (Firebase / git logins are created on that service.)`,
|
|
1499
|
+
subcommands: [
|
|
1500
|
+
{ name: "list", usage: "users list [--all]", summary: "Users (--all includes disabled)." },
|
|
1501
|
+
{ name: "add", usage: "users add --email <e> [--name <n>] [--role admin|member|viewer]", summary: "Add a user.", writes: true },
|
|
1502
|
+
{ name: "role", usage: "users role <id|email> <admin|member|viewer>", summary: "Change a user's role.", writes: true },
|
|
1503
|
+
{ name: "disable", usage: "users disable <id|email> [--enable]", summary: "Disable (or re-enable) a user.", writes: true }
|
|
1504
|
+
],
|
|
1505
|
+
options: [SITE_OPTION, AS_OPTION, { name: "email", type: "string", description: "add: email." }, { name: "name", type: "string", description: "add: display name." }, { name: "role", type: "string", description: "add: role (default member)." }, { name: "all", type: "boolean", description: "list: include disabled users." }, { name: "enable", type: "boolean", description: "disable: re-enable instead." }],
|
|
1506
|
+
async run({ io, options, positionals }) {
|
|
1507
|
+
const [action = "list", ref, role] = positionals;
|
|
1508
|
+
const resolveUser = async (ctx, r) => {
|
|
1509
|
+
const { listUsers } = await import("./chunks/users-DI1BvS0f.mjs");
|
|
1510
|
+
const u = listUsers(ctx, { includeDisabled: true }).find((x) => x.id === r || x.email.toLowerCase() === r.toLowerCase());
|
|
1511
|
+
if (!u) usage(`No CRM user "${r}".`, "`agentcrm users list --all` lists them.");
|
|
1512
|
+
return u;
|
|
1513
|
+
};
|
|
1514
|
+
switch (action) {
|
|
1515
|
+
case "list":
|
|
1516
|
+
return withSession(io, options, {}, async ({ ctx }) => (await import("./chunks/users-DI1BvS0f.mjs")).listUsers(ctx, { includeDisabled: boolOpt(options, "all") }), (us) => table(us, ["id", "email", "name", "role", "disabled"]));
|
|
1517
|
+
case "add": {
|
|
1518
|
+
const r = strOpt(options, "role") ?? "member";
|
|
1519
|
+
if (!USER_ROLES.includes(r)) usage(`--role must be one of ${USER_ROLES.join(", ")}.`);
|
|
1520
|
+
const input = { email: requireStr(options, "email"), name: strOpt(options, "name"), role: r };
|
|
1521
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => (await import("./chunks/users-DI1BvS0f.mjs")).createUser(ctx, input));
|
|
1522
|
+
}
|
|
1523
|
+
case "role": {
|
|
1524
|
+
if (!ref || !role) usage("users role needs <id|email> <role>.");
|
|
1525
|
+
if (!USER_ROLES.includes(role)) usage(`Role must be one of ${USER_ROLES.join(", ")}.`);
|
|
1526
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => (await import("./chunks/users-DI1BvS0f.mjs")).setUserRole(ctx, (await resolveUser(ctx, ref)).id, role));
|
|
1527
|
+
}
|
|
1528
|
+
case "disable": {
|
|
1529
|
+
const target = positional(positionals, 1, "user id or email", "users disable <id|email>");
|
|
1530
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => (await import("./chunks/users-DI1BvS0f.mjs")).updateUser(ctx, (await resolveUser(ctx, target)).id, { disabled: !boolOpt(options, "enable") }));
|
|
1531
|
+
}
|
|
1532
|
+
default:
|
|
1533
|
+
usage(`Unknown users action "${action}".`, "Use list, add, role or disable.");
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
const settingsCommand = {
|
|
1538
|
+
name: "settings",
|
|
1539
|
+
usage: "settings <get|set> …",
|
|
1540
|
+
summary: "CRM settings (name, default currency, locale, timezone, week start, stale deal days).",
|
|
1541
|
+
subcommands: [
|
|
1542
|
+
{ name: "get", usage: "settings get", summary: "Current settings." },
|
|
1543
|
+
{ name: "set", usage: "settings set [--set key=value]… [--data <json>]", summary: "Change settings (admin).", writes: true }
|
|
1544
|
+
],
|
|
1545
|
+
options: [SITE_OPTION, AS_OPTION, { name: "set", type: "string", description: "key=value (repeatable), e.g. --set staleDealDays=21 --set defaultCurrency=EUR.", multiple: true }, { name: "data", type: "string", description: "JSON object of settings, or @file." }],
|
|
1546
|
+
async run({ io, options, positionals }) {
|
|
1547
|
+
const [action = "get"] = positionals;
|
|
1548
|
+
if (action === "get") return withSession(io, options, {}, async ({ ctx }) => (await import("./chunks/settings-BtvR_jdm.mjs")).getSettings(ctx));
|
|
1549
|
+
if (action === "set") {
|
|
1550
|
+
const patch = fieldsInput(options, "data");
|
|
1551
|
+
if (!Object.keys(patch).length) usage("settings set needs --set key=value or --data.");
|
|
1552
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => (await import("./chunks/settings-BtvR_jdm.mjs")).updateSettings(ctx, patch));
|
|
1553
|
+
}
|
|
1554
|
+
usage(`Unknown settings action "${action}".`, "Use get or set.");
|
|
1555
|
+
}
|
|
1556
|
+
};
|
|
1557
|
+
function fromEnv(names, fields) {
|
|
1558
|
+
if (names.length !== fields.length) {
|
|
1559
|
+
throw new CliError("usage", `--from-env needs ${fields.length} variable name(s) (${fields.join(", ")}), comma-separated.`, void 0, 2);
|
|
1560
|
+
}
|
|
1561
|
+
return Object.fromEntries(
|
|
1562
|
+
fields.map((field, i) => {
|
|
1563
|
+
const value = process.env[names[i]];
|
|
1564
|
+
if (!value) throw new CliError("env-missing", `Environment variable ${names[i]} is empty or not set.`);
|
|
1565
|
+
return [field, value];
|
|
1566
|
+
})
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
function fromGh(host) {
|
|
1570
|
+
try {
|
|
1571
|
+
const args = ["auth", "token", ...host ? ["--hostname", host] : []];
|
|
1572
|
+
const token = execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
1573
|
+
if (!token) throw new Error("empty");
|
|
1574
|
+
return { token };
|
|
1575
|
+
} catch {
|
|
1576
|
+
throw new CliError(
|
|
1577
|
+
"gh-unavailable",
|
|
1578
|
+
"Could not read a token from the GitHub CLI.",
|
|
1579
|
+
"Install gh and run `gh auth login`, or create a fine-grained token and use `credentials set` without --from-gh."
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
function parseIni(text) {
|
|
1584
|
+
const out = {};
|
|
1585
|
+
let section = null;
|
|
1586
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
1587
|
+
const line = raw.trim();
|
|
1588
|
+
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
|
|
1589
|
+
const s = /^\[([^\]]+)\]$/.exec(line);
|
|
1590
|
+
if (s) {
|
|
1591
|
+
section = out[s[1].trim()] = {};
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
const kv = /^([^=]+?)\s*=\s*(.*)$/.exec(line);
|
|
1595
|
+
if (kv && section) section[kv[1].trim()] = kv[2].trim();
|
|
1596
|
+
}
|
|
1597
|
+
return out;
|
|
1598
|
+
}
|
|
1599
|
+
function fromAwsProfile(profile) {
|
|
1600
|
+
const file2 = process.env.AWS_SHARED_CREDENTIALS_FILE || join(homedir(), ".aws", "credentials");
|
|
1601
|
+
if (!existsSync(file2)) throw new CliError("aws-missing", `No aws credentials file at ${file2}.`, "Run `aws configure` first.");
|
|
1602
|
+
const section = parseIni(readFileSync(file2, "utf8"))[profile];
|
|
1603
|
+
if (!section?.aws_access_key_id || !section.aws_secret_access_key) {
|
|
1604
|
+
throw new CliError("aws-profile-missing", `Profile "${profile}" has no access key pair in ${file2}.`);
|
|
1605
|
+
}
|
|
1606
|
+
return { accessKeyId: section.aws_access_key_id, secretAccessKey: section.aws_secret_access_key };
|
|
1607
|
+
}
|
|
1608
|
+
async function promptHidden(question) {
|
|
1609
|
+
const stdin = process.stdin;
|
|
1610
|
+
if (!stdin.isTTY) {
|
|
1611
|
+
throw new CliError(
|
|
1612
|
+
"needs-terminal",
|
|
1613
|
+
"No terminal to type the secret in.",
|
|
1614
|
+
"Ask the user to run this command themselves in their terminal (in Claude Code: prefix it with `!`), or pass --from-env / --from-gh / --from-aws-profile."
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
process.stderr.write(question);
|
|
1618
|
+
stdin.setRawMode(true);
|
|
1619
|
+
stdin.resume();
|
|
1620
|
+
stdin.setEncoding("utf8");
|
|
1621
|
+
return new Promise((resolve2, reject) => {
|
|
1622
|
+
let value = "";
|
|
1623
|
+
const done = (err) => {
|
|
1624
|
+
stdin.setRawMode(false);
|
|
1625
|
+
stdin.pause();
|
|
1626
|
+
stdin.off("data", onData);
|
|
1627
|
+
process.stderr.write("\n");
|
|
1628
|
+
if (err) reject(err);
|
|
1629
|
+
else resolve2(value);
|
|
1630
|
+
};
|
|
1631
|
+
const onData = (chunk) => {
|
|
1632
|
+
for (const ch of chunk) {
|
|
1633
|
+
if (ch === "\r" || ch === "\n") return done();
|
|
1634
|
+
if (ch === "") return done(new CliError("cancelled", "Cancelled."));
|
|
1635
|
+
if (ch === "" || ch === "\b") value = value.slice(0, -1);
|
|
1636
|
+
else value += ch;
|
|
1637
|
+
}
|
|
1638
|
+
};
|
|
1639
|
+
stdin.on("data", onData);
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
function copyToClipboard(text) {
|
|
1643
|
+
const candidates = process.platform === "darwin" ? [["pbcopy", []]] : process.platform === "win32" ? [["clip", []]] : [
|
|
1644
|
+
["wl-copy", []],
|
|
1645
|
+
["xclip", ["-selection", "clipboard"]],
|
|
1646
|
+
["xsel", ["--clipboard", "--input"]]
|
|
1647
|
+
];
|
|
1648
|
+
for (const [cmd, args] of candidates) {
|
|
1649
|
+
const res = spawnSync(cmd, args, { input: text, stdio: ["pipe", "ignore", "ignore"] });
|
|
1650
|
+
if (res.status === 0) return cmd;
|
|
1651
|
+
}
|
|
1652
|
+
throw new CliError("no-clipboard", "No clipboard tool found (pbcopy, clip, wl-copy, xclip or xsel).", "Install one, or read the value from ~/.agentcrm/credentials.json yourself.");
|
|
1653
|
+
}
|
|
1654
|
+
const credentialsCommand = {
|
|
1655
|
+
name: "credentials",
|
|
1656
|
+
usage: "credentials <set|list|remove|copy|verify|types> [<type>:<name>] [--from-env VAR[,VAR]] [--from-gh] [--from-aws-profile <name>] [--generate]",
|
|
1657
|
+
summary: "Store, list, copy, verify or remove access keys per account (<type>:<name>; types: s3, github, gitlab, firebase, passphrase, anthropic) in ~/.agentcrm/credentials.json (0600). `set` reads the secret from --from-env, --from-gh, --from-aws-profile, --generate (passphrases) or a hidden prompt the USER types in.",
|
|
1658
|
+
subcommands: [
|
|
1659
|
+
{ name: "set", usage: "credentials set <type>:<name> [--from-env VAR[,VAR]] [--from-gh] [--from-aws-profile <p>] [--generate]", summary: "Store a credential (hidden prompt unless a --from-* source is given).", writes: true },
|
|
1660
|
+
{ name: "list", usage: "credentials list", summary: "Accounts stored on this computer (field names only) and the sites using them." },
|
|
1661
|
+
{ name: "remove", usage: "credentials remove <type>:<name>", summary: "Delete a stored credential.", writes: true },
|
|
1662
|
+
{ name: "copy", usage: "credentials copy <type>:<name> [--field <f>]", summary: "Put a secret on the clipboard without printing it (save the passphrase in a password manager)." },
|
|
1663
|
+
{ name: "verify", usage: "credentials verify <type>:<name> [--field <f>]", summary: "The USER pastes the value from their password manager at a hidden prompt: proves the saved copy is right." },
|
|
1664
|
+
{ name: "types", usage: "credentials types", summary: "Credential types, their fields and where to create them." }
|
|
1665
|
+
],
|
|
1666
|
+
options: [
|
|
1667
|
+
{ name: "from-env", type: "string", description: "Read the value(s) from these environment variables (comma-separated, in field order — s3: KEY_ID,SECRET)." },
|
|
1668
|
+
{ name: "from-gh", type: "boolean", description: "github: take the token of the signed-in GitHub CLI (gh auth token)." },
|
|
1669
|
+
{ name: "from-aws-profile", type: "string", description: "s3: import the key pair of this aws CLI profile." },
|
|
1670
|
+
{ name: "generate", type: "boolean", description: "passphrase: generate a strong random passphrase." },
|
|
1671
|
+
{ name: "field", type: "string", description: "copy / verify: which field (default: the first)." }
|
|
1672
|
+
],
|
|
1673
|
+
async run({ io, options, positionals }) {
|
|
1674
|
+
const [action = "list", account] = positionals;
|
|
1675
|
+
if (action === "types") {
|
|
1676
|
+
printResult(io, CREDENTIAL_TYPES, () => Object.entries(CREDENTIAL_TYPES).map(([type22, c]) => `${type22}: ${c.label} (${c.fields.join(", ")})
|
|
1677
|
+
${c.howTo}`).join("\n"));
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
if (action === "list") {
|
|
1681
|
+
const { sites } = listSites();
|
|
1682
|
+
const list = listCredentials().map((c) => ({
|
|
1683
|
+
...c,
|
|
1684
|
+
usedBy: sites.filter((s) => Object.values(s.credentials).includes(c.account)).map((s) => s.alias)
|
|
1685
|
+
}));
|
|
1686
|
+
printResult(io, list, () => list.length ? list.map((c) => `${c.account} (${c.fields.join(", ")})${c.usedBy.length ? ` used by ${c.usedBy.join(", ")}` : ""}`).join("\n") : "No stored credentials.");
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
if (!account) usage(`credentials ${action} needs an account, e.g. s3:main.`);
|
|
1690
|
+
const { type: type2 } = parseAccount(account);
|
|
1691
|
+
const spec = CREDENTIAL_TYPES[type2];
|
|
1692
|
+
if (action === "remove") {
|
|
1693
|
+
const removed = removeCredential(account);
|
|
1694
|
+
printResult(io, { account, removed }, () => removed ? `Removed ${account}.` : `${account} was not stored.`);
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
if (action === "copy" || action === "verify") {
|
|
1698
|
+
const cred = requireCredential(account);
|
|
1699
|
+
const field = strOpt(options, "field") ?? spec.fields[0];
|
|
1700
|
+
const stored = cred.values[field];
|
|
1701
|
+
if (!stored) usage(`${account} has no field "${field}".`, `Fields: ${Object.keys(cred.values).join(", ")}.`);
|
|
1702
|
+
if (action === "copy") {
|
|
1703
|
+
const tool = copyToClipboard(stored);
|
|
1704
|
+
printResult(io, { account, field, copied: true, tool }, () => `Copied ${field} of ${account} to the clipboard.`);
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
if (!process.stdin.isTTY) {
|
|
1708
|
+
throw new CliError("needs-terminal", "verify reads the value the USER pastes in their own terminal.", `Ask the user to run it themselves (in Claude Code: \`! npx -y @fleox-agent-crm/agentcrm credentials verify ${account}\`).`);
|
|
1709
|
+
}
|
|
1710
|
+
try {
|
|
1711
|
+
copyToClipboard("");
|
|
1712
|
+
} catch {
|
|
1713
|
+
}
|
|
1714
|
+
io.err(`Paste the ${field} of ${account} from where you saved it (password manager), then press Enter.`);
|
|
1715
|
+
const typed = Buffer.from((await promptHidden(`${field}: `)).trim());
|
|
1716
|
+
const expected = Buffer.from(stored);
|
|
1717
|
+
if (typed.length !== expected.length || !timingSafeEqual(typed, expected)) {
|
|
1718
|
+
throw new CliError("mismatch", `That is not the ${field} of ${account}.`, `Copy it again (\`agentcrm credentials copy ${account}\`), save it again, and re-run verify.`);
|
|
1719
|
+
}
|
|
1720
|
+
printResult(io, { account, field, verified: true }, () => `${field} of ${account} verified.`);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
if (action !== "set") usage(`Unknown action "${action}".`, "Use set, list, remove, copy, verify or types.");
|
|
1724
|
+
let values;
|
|
1725
|
+
if (options.generate === true) {
|
|
1726
|
+
if (type2 !== "passphrase") usage("--generate only applies to passphrase accounts.");
|
|
1727
|
+
const { generatePassphrase } = await import("./chunks/index-CWrrF1wl.mjs");
|
|
1728
|
+
values = { passphrase: generatePassphrase() };
|
|
1729
|
+
} else if (typeof options["from-env"] === "string") {
|
|
1730
|
+
values = fromEnv(options["from-env"].split(",").map((s) => s.trim()), spec.fields);
|
|
1731
|
+
} else if (options["from-gh"] === true) {
|
|
1732
|
+
if (type2 !== "github") usage("--from-gh only applies to github accounts.");
|
|
1733
|
+
values = fromGh();
|
|
1734
|
+
} else if (typeof options["from-aws-profile"] === "string") {
|
|
1735
|
+
if (type2 !== "s3") usage("--from-aws-profile only applies to s3 accounts.");
|
|
1736
|
+
values = fromAwsProfile(options["from-aws-profile"]);
|
|
1737
|
+
} else {
|
|
1738
|
+
io.err(`${spec.label} for ${account}. ${spec.howTo}`);
|
|
1739
|
+
values = {};
|
|
1740
|
+
for (const field of spec.fields) values[field] = await promptHidden(`${field}: `);
|
|
1741
|
+
}
|
|
1742
|
+
const saved = setCredential(account, values);
|
|
1743
|
+
printResult(io, { account, type: type2, fields: Object.keys(saved.values) }, () => `Stored ${account} (${Object.keys(saved.values).join(", ")}).`);
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
async function dryRun(ctx, work) {
|
|
1747
|
+
const [{ createMemoryStore }, { COLLECTIONS }, { createOpsContext }, { createPluginRegistry }] = await Promise.all([
|
|
1748
|
+
import("./chunks/memory-WNR3VHUJ.mjs"),
|
|
1749
|
+
import("./chunks/types-BOy9PUl7.mjs"),
|
|
1750
|
+
import("./chunks/context-CxFfqS4D.mjs"),
|
|
1751
|
+
import("./chunks/pluginRegistry-DyHtlkcr.mjs")
|
|
1752
|
+
]);
|
|
1753
|
+
const initial = Object.fromEntries(COLLECTIONS.map((c) => [c, ctx.store.list(c).map((d) => structuredClone(d))]));
|
|
1754
|
+
const store = createMemoryStore({ initial });
|
|
1755
|
+
await store.ready();
|
|
1756
|
+
const copy = createOpsContext(store, ctx.user, { now: ctx.now, plugins: createPluginRegistry(() => void 0) });
|
|
1757
|
+
return { result: await work(copy), store };
|
|
1758
|
+
}
|
|
1759
|
+
const CASCADE_OPTION = { name: "cascade", type: "boolean", description: "enable / disable: also enable the dependencies / disable the dependents." };
|
|
1760
|
+
function parseJsonValue(raw, name2) {
|
|
1761
|
+
const text = readTextValue(raw, name2);
|
|
1762
|
+
try {
|
|
1763
|
+
return JSON.parse(text);
|
|
1764
|
+
} catch (err) {
|
|
1765
|
+
usage(`--${name2} is not valid JSON: ${err.message}`, `Example: --${name2} '{"targets":{"owner":50000}}' or --${name2} @config.json`);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
const modulesCommand = {
|
|
1769
|
+
name: "modules",
|
|
1770
|
+
usage: "modules <list|get|enable|disable|config> …",
|
|
1771
|
+
summary: "Workspace modules (Settings → Modules): built-in features (dashboard, deals, reports, forms, tasks, automations, integrations, assistant…) and installed extension modules. Disabling hides pages, navigation, widgets and owned objects for everyone without deleting data. enable / disable / config set print a plan unless --yes.",
|
|
1772
|
+
subcommands: [
|
|
1773
|
+
{ name: "list", usage: "modules list", summary: "Modules with category, source, enabled / required state and dependencies." },
|
|
1774
|
+
{ name: "get", usage: "modules get <id>", summary: "One module (dependents, missing dependencies, owned objects, settings section)." },
|
|
1775
|
+
{ name: "enable", usage: "modules enable <id> [--cascade] [--yes]", summary: "Enable a module (--cascade also enables its dependencies).", writes: true },
|
|
1776
|
+
{ name: "disable", usage: "modules disable <id> [--cascade] [--yes]", summary: "Disable a module (--cascade also disables the modules depending on it).", writes: true, destructive: true },
|
|
1777
|
+
{ name: "config", usage: "modules config get <id> | modules config set <id> --value <json|@file|-> [--yes] | modules config set <id> --clear [--yes]", summary: "Read or replace a module's configuration (JSON ≤ 256 KB, never secrets).", writes: true }
|
|
1778
|
+
],
|
|
1779
|
+
options: [SITE_OPTION, AS_OPTION, YES_OPTION, CASCADE_OPTION, { name: "value", type: "string", description: "config set: the new configuration as JSON, @file or - (stdin)." }, { name: "clear", type: "boolean", description: "config set: remove the module's configuration." }],
|
|
1780
|
+
async run({ io, options, positionals }) {
|
|
1781
|
+
const [action = "list"] = positionals;
|
|
1782
|
+
const apply = options.yes === true;
|
|
1783
|
+
switch (action) {
|
|
1784
|
+
case "list":
|
|
1785
|
+
return withSession(io, options, {}, async ({ ctx }) => (await import("./chunks/modules-CqdVQVJb.mjs")).listModules(ctx), (rows) => table(
|
|
1786
|
+
rows.map((m) => ({ ...m, enabled: m.enabled ? "yes" : "no", required: m.required ? "yes" : "", dependsOn: m.dependsOn.join(",") })),
|
|
1787
|
+
["id", "name", "category", "source", "enabled", "required", "dependsOn"]
|
|
1788
|
+
));
|
|
1789
|
+
case "get": {
|
|
1790
|
+
const id = positional(positionals, 1, "module id", "modules get <id>");
|
|
1791
|
+
return withSession(io, options, {}, async ({ ctx }) => (await import("./chunks/modules-CqdVQVJb.mjs")).getModule(ctx, id));
|
|
1792
|
+
}
|
|
1793
|
+
case "enable":
|
|
1794
|
+
case "disable": {
|
|
1795
|
+
const id = positional(positionals, 1, "module id", `modules ${action} <id> [--cascade] [--yes]`);
|
|
1796
|
+
const enabled = action === "enable";
|
|
1797
|
+
const cascade = boolOpt(options, "cascade");
|
|
1798
|
+
return withSession(io, options, { lock: apply }, async ({ ctx }) => {
|
|
1799
|
+
const { setModuleEnabled } = await import("./chunks/modules-CqdVQVJb.mjs");
|
|
1800
|
+
if (apply) return setModuleEnabled(ctx, id, enabled, { cascade });
|
|
1801
|
+
const { result } = await dryRun(ctx, (copy) => setModuleEnabled(copy, id, enabled, { cascade }));
|
|
1802
|
+
return { needsConfirmation: true, plan: result };
|
|
1803
|
+
}, (r) => "needsConfirmation" in r ? `${r.plan.changed.length ? `Would ${action}: ${r.plan.changed.map((c) => `${c.id} → ${c.enabled ? "on" : "off"}`).join(", ")}` : `${id} is already ${enabled ? "enabled" : "disabled"}`}.
|
|
1804
|
+
Nothing changed — re-run with --yes once the user agrees.` : r.changed.length ? `Changed: ${r.changed.map((c) => `${c.id} → ${c.enabled ? "on" : "off"}`).join(", ")}` : `${id} was already ${enabled ? "enabled" : "disabled"}.`);
|
|
1805
|
+
}
|
|
1806
|
+
case "config": {
|
|
1807
|
+
const sub = positionals[1];
|
|
1808
|
+
const id = positional(positionals, 2, "module id", "modules config <get|set> <id>");
|
|
1809
|
+
if (sub === "get") return withSession(io, options, {}, async ({ ctx }) => ({ id, config: (await import("./chunks/modules-CqdVQVJb.mjs")).getModuleConfig(ctx, id) ?? null }));
|
|
1810
|
+
if (sub !== "set") usage(`Unknown modules config action "${sub ?? ""}".`, "Use modules config get <id> or modules config set <id> --value <json>.");
|
|
1811
|
+
const clear = boolOpt(options, "clear");
|
|
1812
|
+
const raw = strOpt(options, "value");
|
|
1813
|
+
if (!clear && raw === void 0) usage("modules config set needs --value <json|@file|-> (or --clear).");
|
|
1814
|
+
const value = clear ? null : parseJsonValue(raw, "value");
|
|
1815
|
+
return withSession(io, options, { lock: apply }, async ({ ctx }) => {
|
|
1816
|
+
const mod = await import("./chunks/modules-CqdVQVJb.mjs");
|
|
1817
|
+
mod.getModule(ctx, id);
|
|
1818
|
+
const before = mod.getModuleConfig(ctx, id) ?? null;
|
|
1819
|
+
if (apply) return { id, config: await mod.setModuleConfig(ctx, id, value) ?? null, before };
|
|
1820
|
+
const { result } = await dryRun(ctx, (copy) => mod.setModuleConfig(copy, id, value));
|
|
1821
|
+
return { needsConfirmation: true, plan: { id, before, after: result ?? null } };
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
default:
|
|
1825
|
+
usage(`Unknown modules action "${action}".`, "Use list, get, enable, disable or config.");
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
};
|
|
1829
|
+
const IMAGE_TYPES = {
|
|
1830
|
+
".png": "image/png",
|
|
1831
|
+
".jpg": "image/jpeg",
|
|
1832
|
+
".jpeg": "image/jpeg",
|
|
1833
|
+
".gif": "image/gif",
|
|
1834
|
+
".webp": "image/webp",
|
|
1835
|
+
".svg": "image/svg+xml",
|
|
1836
|
+
".ico": "image/x-icon"
|
|
1837
|
+
};
|
|
1838
|
+
function imageDataUrl(file2) {
|
|
1839
|
+
const abs = resolve(file2);
|
|
1840
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) usage(`${file2} does not exist.`);
|
|
1841
|
+
const type2 = IMAGE_TYPES[extname(abs).toLowerCase()];
|
|
1842
|
+
if (!type2) usage(`${file2}: unsupported image type.`, `Use ${Object.keys(IMAGE_TYPES).join(", ")} (≤ 200 KB).`);
|
|
1843
|
+
return `data:${type2};base64,${readFileSync(abs).toString("base64")}`;
|
|
1844
|
+
}
|
|
1845
|
+
function view(a, full) {
|
|
1846
|
+
if (full) return { ...a };
|
|
1847
|
+
const { logo, favicon, customCss, ...rest } = a;
|
|
1848
|
+
const kb2 = (s) => Math.round((s.length - s.indexOf(",") - 1) * 3 / 4 / 1024);
|
|
1849
|
+
return {
|
|
1850
|
+
...rest,
|
|
1851
|
+
logo: logo ? `${logo.slice(5, logo.indexOf(";"))}, ${kb2(logo)} KB` : null,
|
|
1852
|
+
favicon: favicon ? `${favicon.slice(5, favicon.indexOf(";"))}, ${kb2(favicon)} KB` : null,
|
|
1853
|
+
customCss: customCss ? `${new TextEncoder().encode(customCss).length} bytes` : null
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
function diff(before, after) {
|
|
1857
|
+
const out = {};
|
|
1858
|
+
for (const k of /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
1859
|
+
if (JSON.stringify(before[k] ?? null) !== JSON.stringify(after[k] ?? null)) out[k] = { from: before[k] ?? null, to: after[k] ?? null };
|
|
1860
|
+
}
|
|
1861
|
+
return out;
|
|
1862
|
+
}
|
|
1863
|
+
const SET_FLAGS = [
|
|
1864
|
+
["accent", "accent"],
|
|
1865
|
+
["neutral", "neutral"],
|
|
1866
|
+
["radius", "radius"],
|
|
1867
|
+
["density", "density"],
|
|
1868
|
+
["font", "font"],
|
|
1869
|
+
["sidebar", "sidebar"],
|
|
1870
|
+
["mode", "defaultMode"],
|
|
1871
|
+
["buttons", "buttons"],
|
|
1872
|
+
["contrast", "contrast"],
|
|
1873
|
+
["theme", "themeId"]
|
|
1874
|
+
];
|
|
1875
|
+
function setPatch(options) {
|
|
1876
|
+
const patch = {};
|
|
1877
|
+
for (const [opt, key] of SET_FLAGS) {
|
|
1878
|
+
const v = strOpt(options, opt);
|
|
1879
|
+
if (v === void 0) continue;
|
|
1880
|
+
patch[key] = v === "null" || v === "" ? null : v;
|
|
1881
|
+
}
|
|
1882
|
+
if (!Object.keys(patch).length) usage("appearance set needs at least one value.", `Options: ${SET_FLAGS.map(([o]) => `--${o}`).join(", ")} ("null" resets one to its default).`);
|
|
1883
|
+
return patch;
|
|
1884
|
+
}
|
|
1885
|
+
const appearanceCommand = {
|
|
1886
|
+
name: "appearance",
|
|
1887
|
+
usage: "appearance <get|presets|set|preset|logo|favicon|css|reset> …",
|
|
1888
|
+
summary: "Workspace appearance (Settings → Appearance) for every user: accent color (a full scale is generated from one hex), neutral grays, radius, density, font, sidebar style, default light / dark mode, buttons, contrast, presets and theme extensions, logo, favicon, custom CSS. Changes print a plan (before → after) unless --yes.",
|
|
1889
|
+
subcommands: [
|
|
1890
|
+
{ name: "get", usage: "appearance get [--full]", summary: "The effective appearance (defaults ← selected theme ← workspace values)." },
|
|
1891
|
+
{ name: "presets", usage: "appearance presets", summary: "Built-in presets and installed theme extensions." },
|
|
1892
|
+
{ name: "set", usage: "appearance set [--accent <#hex>] [--neutral zinc|slate|stone|gray|neutral] [--radius none|sm|md|lg|xl] [--density compact|comfortable] [--font <id>] [--sidebar default|dark|accent] [--mode system|light|dark] [--buttons neutral|accent] [--contrast normal|high] [--theme <id>] [--yes]", summary: 'Change some values ("null" resets one).', writes: true },
|
|
1893
|
+
{ name: "preset", usage: "appearance preset <id> [--yes]", summary: "Apply a preset (default, ocean, forest, sunset, graphite, rose, high-contrast) or an installed theme; logo, favicon and custom CSS are kept.", writes: true },
|
|
1894
|
+
{ name: "logo", usage: "appearance logo <file.png|svg|jpg> [--yes] | appearance logo --remove [--yes]", summary: "Set or remove the workspace logo (≤ 200 KB).", writes: true },
|
|
1895
|
+
{ name: "favicon", usage: "appearance favicon <file> [--yes] | appearance favicon --remove [--yes]", summary: "Set or remove the favicon (≤ 200 KB).", writes: true },
|
|
1896
|
+
{ name: "css", usage: "appearance css <file.css|-> [--yes] | appearance css --remove [--yes]", summary: "Replace the custom CSS of the workspace (≤ 50 KB, plain CSS).", writes: true },
|
|
1897
|
+
{ name: "reset", usage: "appearance reset [--branding] [--yes]", summary: "Back to the defaults (keeps logo / favicon unless --branding).", writes: true, destructive: true }
|
|
1898
|
+
],
|
|
1899
|
+
options: [
|
|
1900
|
+
SITE_OPTION,
|
|
1901
|
+
AS_OPTION,
|
|
1902
|
+
YES_OPTION,
|
|
1903
|
+
{ name: "accent", type: "string", description: "set: accent color #rrggbb." },
|
|
1904
|
+
{ name: "neutral", type: "string", description: "set: zinc, slate, stone, gray or neutral." },
|
|
1905
|
+
{ name: "radius", type: "string", description: "set: none, sm, md, lg or xl." },
|
|
1906
|
+
{ name: "density", type: "string", description: "set: compact or comfortable." },
|
|
1907
|
+
{ name: "font", type: "string", description: "set: system (no network) or a Google font id (inter, geist, roboto, ibm-plex-sans…)." },
|
|
1908
|
+
{ name: "sidebar", type: "string", description: "set: default, dark or accent." },
|
|
1909
|
+
{ name: "mode", type: "string", description: "set: default color mode — system, light or dark (users who never chose)." },
|
|
1910
|
+
{ name: "buttons", type: "string", description: "set: primary buttons neutral or accent." },
|
|
1911
|
+
{ name: "contrast", type: "string", description: "set: normal or high." },
|
|
1912
|
+
{ name: "theme", type: "string", description: 'set: installed theme extension id to use ("null" clears it).' },
|
|
1913
|
+
{ name: "remove", type: "boolean", description: "logo / favicon / css: remove it." },
|
|
1914
|
+
{ name: "branding", type: "boolean", description: "reset: also remove the logo and favicon." },
|
|
1915
|
+
{ name: "full", type: "boolean", description: "get: include the logo / favicon data URLs and the custom CSS." }
|
|
1916
|
+
],
|
|
1917
|
+
async run({ io, options, positionals }) {
|
|
1918
|
+
const [action = "get"] = positionals;
|
|
1919
|
+
const apply = options.yes === true;
|
|
1920
|
+
const full = boolOpt(options, "full");
|
|
1921
|
+
const appearance = () => import("./chunks/appearance-BaShelzt.mjs");
|
|
1922
|
+
const change = (describe, op) => withSession(io, options, { lock: apply }, async ({ ctx }) => {
|
|
1923
|
+
const before = view((await appearance()).getAppearance(ctx), full);
|
|
1924
|
+
if (apply) {
|
|
1925
|
+
const after = view(await op(ctx), full);
|
|
1926
|
+
return { applied: describe, appearance: after, changes: diff(before, after) };
|
|
1927
|
+
}
|
|
1928
|
+
const { result } = await dryRun(ctx, op);
|
|
1929
|
+
return { needsConfirmation: true, plan: { action: describe, changes: diff(before, view(result, full)) } };
|
|
1930
|
+
}, (r) => {
|
|
1931
|
+
const changes = r.plan ? r.plan.changes : r.changes ?? {};
|
|
1932
|
+
const lines = Object.entries(changes).map(([k, c]) => ` ${k}: ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`);
|
|
1933
|
+
return [`${r.plan ? "Would apply" : "Applied"}: ${describe}`, ...lines.length ? lines : [" (no visible change)"], ...r.plan ? ["Nothing changed — re-run with --yes once the user agrees."] : []].join("\n");
|
|
1934
|
+
});
|
|
1935
|
+
switch (action) {
|
|
1936
|
+
case "get":
|
|
1937
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
1938
|
+
const ops = await appearance();
|
|
1939
|
+
const doc = ops.getAppearanceDoc(ctx);
|
|
1940
|
+
const explicit = doc ? Object.keys(doc).filter((k) => !["id", "updatedAt", "updatedBy"].includes(k)) : [];
|
|
1941
|
+
return { appearance: view(ops.getAppearance(ctx), full), explicit };
|
|
1942
|
+
}, (r) => Object.entries(r.appearance).map(([k, v]) => `${k.padEnd(12)} ${v ?? "—"}${r.explicit.includes(k) ? "" : " (default)"}`).join("\n"));
|
|
1943
|
+
case "presets":
|
|
1944
|
+
return withSession(io, options, {}, async ({ ctx }) => (await appearance()).listPresets(ctx), (rows) => table(rows.map((p) => ({ id: p.id, name: p.name, source: p.source, accent: p.values.accent ?? "", enabled: p.enabled === void 0 ? "" : p.enabled ? "yes" : "no", description: p.description })), ["id", "name", "source", "accent", "enabled", "description"]));
|
|
1945
|
+
case "set": {
|
|
1946
|
+
const patch = setPatch(options);
|
|
1947
|
+
return change(`set ${Object.entries(patch).map(([k, v]) => `${k}=${v ?? "default"}`).join(", ")}`, async (ctx) => (await appearance()).setAppearance(ctx, patch));
|
|
1948
|
+
}
|
|
1949
|
+
case "preset": {
|
|
1950
|
+
const id = positional(positionals, 1, "preset or theme id", "appearance preset <id>");
|
|
1951
|
+
return change(`preset ${id}`, async (ctx) => (await appearance()).applyPreset(ctx, id));
|
|
1952
|
+
}
|
|
1953
|
+
case "logo":
|
|
1954
|
+
case "favicon": {
|
|
1955
|
+
const remove = boolOpt(options, "remove");
|
|
1956
|
+
const file2 = positionals[1];
|
|
1957
|
+
if (!remove && !file2) usage(`appearance ${action} needs an image file (or --remove).`, `Usage: agentcrm appearance ${action} <file.png|svg|jpg>`);
|
|
1958
|
+
const dataUrl = remove ? null : imageDataUrl(file2);
|
|
1959
|
+
return change(remove ? `remove the ${action}` : `${action} ${file2}`, async (ctx) => (await appearance()).setLogo(ctx, dataUrl, { kind: action }));
|
|
1960
|
+
}
|
|
1961
|
+
case "css": {
|
|
1962
|
+
const remove = boolOpt(options, "remove");
|
|
1963
|
+
const source = positionals[1];
|
|
1964
|
+
if (!remove && !source) usage("appearance css needs a CSS file or - (stdin), or --remove.");
|
|
1965
|
+
const css = remove ? null : readTextValue(source === "-" ? "-" : `@${source}`, "file");
|
|
1966
|
+
return change(remove ? "remove the custom CSS" : `custom CSS from ${source === "-" ? "stdin" : source}`, async (ctx) => (await appearance()).setAppearance(ctx, { customCss: css }));
|
|
1967
|
+
}
|
|
1968
|
+
case "reset": {
|
|
1969
|
+
const branding = boolOpt(options, "branding");
|
|
1970
|
+
return change(`reset${branding ? " (including logo and favicon)" : ""}`, async (ctx) => (await appearance()).resetAppearance(ctx, { branding }));
|
|
1971
|
+
}
|
|
1972
|
+
default:
|
|
1973
|
+
usage(`Unknown appearance action "${action}".`, "Use get, presets, set, preset, logo, favicon, css or reset.");
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
};
|
|
1977
|
+
const TRUST_NOTE = "Installing runs this extension's code in the admin (and in CLI / MCP sessions) with the rights of the user who opens it — admins included. Only install code from an author you trust.";
|
|
1978
|
+
function kb(n) {
|
|
1979
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
1980
|
+
}
|
|
1981
|
+
async function scaffold({ io, options, positionals }) {
|
|
1982
|
+
const [, kind, id] = positionals;
|
|
1983
|
+
if (kind !== "module" && kind !== "theme") usage("ext new needs module or theme, then an id.", "e.g. agentcrm ext new module my-module");
|
|
1984
|
+
if (!id) usage(`ext new ${kind} needs an id (lowercase, dashes), e.g. ext new ${kind} my-${kind}.`);
|
|
1985
|
+
const dir = resolve(strOpt(options, "dir") ?? id);
|
|
1986
|
+
if (existsSync(dir) && readdirSync(dir).length > 0 && !boolOpt(options, "force")) {
|
|
1987
|
+
throw new CliError("dir-not-empty", `${dir} already exists and is not empty.`, "Pick another --dir (or --force to write into it).", 2);
|
|
1988
|
+
}
|
|
1989
|
+
const { scaffoldExtension } = await import("./chunks/extScaffold-DqxI7R1e.mjs");
|
|
1990
|
+
const res = scaffoldExtension({ kind, id, dir, name: strOpt(options, "name"), description: strOpt(options, "description"), author: strOpt(options, "author") });
|
|
1991
|
+
printResult(io, res, () => [`Created ${kind} "${id}" in ${dir}:`, ...res.files.map((f) => ` ${f}`), "", "Next:", ...res.next.map((n) => ` ${n}`)].join("\n"));
|
|
1992
|
+
}
|
|
1993
|
+
async function check({ io, options, positionals }) {
|
|
1994
|
+
const target = positionals[1] ?? ".";
|
|
1995
|
+
const kind = strOpt(options, "kind");
|
|
1996
|
+
if (kind && kind !== "module" && kind !== "theme") usage("--kind is module or theme.");
|
|
1997
|
+
const { readRawPackage } = await import("./chunks/extPackage-BGl5Oqs8.mjs");
|
|
1998
|
+
const { checkExtension } = await import("./chunks/extCheck--7lmVvl7.mjs");
|
|
1999
|
+
const raw = await readRawPackage(target);
|
|
2000
|
+
const report = await checkExtension(raw, { kind, callTools: !boolOpt(options, "no-call") });
|
|
2001
|
+
printResult(io, report, () => {
|
|
2002
|
+
const lines = [
|
|
2003
|
+
`${report.valid ? "PASS" : "FAIL"} ${report.kind ?? "?"} ${report.id ?? "?"} ${report.version ?? ""} (apiVersion ${report.apiVersion ?? "?"}, admin API ${report.hostApi.min} – ${report.hostApi.version}) — bundle ${kb(report.size.bundle)}${report.size.style ? `, style ${kb(report.size.style)}` : ""}`,
|
|
2004
|
+
...report.errors.map((e) => ` error [${e.code}] ${e.message}${e.hint ? `
|
|
2005
|
+
→ ${e.hint}` : ""}`),
|
|
2006
|
+
...report.warnings.map((w) => ` warn [${w.code}] ${w.message}${w.hint ? `
|
|
2007
|
+
→ ${w.hint}` : ""}`)
|
|
2008
|
+
];
|
|
2009
|
+
const r = report.registered;
|
|
2010
|
+
if (r) {
|
|
2011
|
+
if (r.routes.length) lines.push(` routes: ${r.routes.join(", ")}`);
|
|
2012
|
+
if (r.navItems.length) lines.push(` navigation: ${r.navItems.map((n) => `${n.id} → ${n.to}`).join(", ")}`);
|
|
2013
|
+
for (const [slot, ids] of Object.entries(r.slots)) lines.push(` slot ${slot}: ${ids.join(", ")}`);
|
|
2014
|
+
if (r.settingsPage) lines.push(" settings page: Settings → Modules → " + report.name);
|
|
2015
|
+
if (r.aiTools.length) lines.push(` AI tools: ${r.aiTools.map((t) => `${t.name}${t.mutating ? " (writes)" : ""}`).join(", ")}`);
|
|
2016
|
+
if (r.automationActions.length) lines.push(` automation actions: ${r.automationActions.map((a) => a.id).join(", ")}`);
|
|
2017
|
+
if (r.hooks.actions.length || r.hooks.filters.length) lines.push(` hooks: ${[...r.hooks.actions, ...r.hooks.filters].join(", ")}`);
|
|
2018
|
+
for (const c of report.toolCalls ?? []) lines.push(` call ${c.name}({}): ${c.ok ? "ok" : `failed — ${c.error}`}`);
|
|
2019
|
+
}
|
|
2020
|
+
if (report.theme) lines.push(` theme "${report.theme.name}": ${Object.entries(report.theme.appearance).map(([k, v]) => `${k}=${String(v)}`).join(", ")}${report.theme.css ? ` + ${kb(report.theme.css)} CSS` : ""}`);
|
|
2021
|
+
return lines.join("\n");
|
|
2022
|
+
});
|
|
2023
|
+
return report.valid ? 0 : 1;
|
|
2024
|
+
}
|
|
2025
|
+
async function pack({ io, options, positionals }) {
|
|
2026
|
+
const target = positionals[1] ?? ".";
|
|
2027
|
+
const { readPackage } = await import("./chunks/extPackage-BGl5Oqs8.mjs");
|
|
2028
|
+
const { raw, pkg: pkg2 } = await readPackage(target);
|
|
2029
|
+
if (raw.sourceType === "zip") usage("ext pack takes a project or an unpacked folder, not a .zip.");
|
|
2030
|
+
const { packExtension } = await import("./chunks/package-DWbn3t2R.mjs");
|
|
2031
|
+
const bytes = await packExtension(pkg2);
|
|
2032
|
+
const out = resolve(strOpt(options, "out") ?? join(raw.source, `${pkg2.manifest.id}-${pkg2.manifest.version}.zip`));
|
|
2033
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
2034
|
+
writeFileSync(out, bytes);
|
|
2035
|
+
const files2 = ["manifest.json", ...Object.keys(pkg2.files)];
|
|
2036
|
+
printResult(io, { zip: out, bytes: bytes.length, id: pkg2.manifest.id, kind: pkg2.manifest.kind, version: pkg2.manifest.version, files: files2 }, () => `Packed ${basename(out)} (${files2.join(", ")}, ${kb(bytes.length)})`);
|
|
2037
|
+
}
|
|
2038
|
+
async function install({ io, options, positionals }) {
|
|
2039
|
+
const target = positional(positionals, 1, "ZIP or folder", "ext install <zip|dir> [--site <alias>] [--yes]");
|
|
2040
|
+
const { readPackage } = await import("./chunks/extPackage-BGl5Oqs8.mjs");
|
|
2041
|
+
const { pkg: pkg2, raw } = await readPackage(target);
|
|
2042
|
+
const apply = options.yes === true;
|
|
2043
|
+
const disabled = boolOpt(options, "disabled");
|
|
2044
|
+
return withSession(io, options, { lock: apply }, async (s) => {
|
|
2045
|
+
const ext = await import("./chunks/extensions-BP1k60md.mjs");
|
|
2046
|
+
const existing = s.store.get("extensions", pkg2.manifest.id);
|
|
2047
|
+
const m = pkg2.manifest;
|
|
2048
|
+
const plan = {
|
|
2049
|
+
id: m.id,
|
|
2050
|
+
kind: m.kind,
|
|
2051
|
+
name: m.name,
|
|
2052
|
+
version: m.version,
|
|
2053
|
+
apiVersion: m.apiVersion,
|
|
2054
|
+
description: m.description,
|
|
2055
|
+
author: m.author ?? null,
|
|
2056
|
+
homepage: m.homepage ?? null,
|
|
2057
|
+
dependsOn: m.dependsOn ?? [],
|
|
2058
|
+
mode: existing ? "upgrade" : "install",
|
|
2059
|
+
...existing?.manifest ? { from: existing.manifest.version, currentlyEnabled: !!existing.enabled } : {},
|
|
2060
|
+
size: { bundle: new TextEncoder().encode(pkg2.files["bundle.js"]).length, style: pkg2.files["style.css"] ? new TextEncoder().encode(pkg2.files["style.css"]).length : 0, ...raw.zipBytes ? { zip: raw.zipBytes } : {} },
|
|
2061
|
+
source: raw.source,
|
|
2062
|
+
warning: TRUST_NOTE
|
|
2063
|
+
};
|
|
2064
|
+
if (!apply) {
|
|
2065
|
+
const { result: result2 } = await dryRun(s.ctx, (copy) => ext.installExtension(copy, pkg2, disabled ? { enabled: false } : {}));
|
|
2066
|
+
return { needsConfirmation: true, plan: { ...plan, enabled: result2.enabled, warnings: result2.warnings } };
|
|
2067
|
+
}
|
|
2068
|
+
const result = await ext.installExtension(s.ctx, pkg2, disabled ? { enabled: false } : {});
|
|
2069
|
+
const report = await s.extensions.sync();
|
|
2070
|
+
const { getLoadedExtension } = await import("./chunks/registry-Ceexawxl.mjs");
|
|
2071
|
+
const loaded = getLoadedExtension(m.id);
|
|
2072
|
+
const failure = report?.failed.find((f) => f.id === m.id || f.id === "*");
|
|
2073
|
+
if (failure && !loaded) return { ...result, author: m.author ?? null, load: { status: "error", error: failure.error } };
|
|
2074
|
+
return { ...result, author: m.author ?? null, load: loaded ? { status: loaded.status, ...loaded.error ? { error: loaded.error } : {} } : { status: result.enabled ? s.extensions.safeMode ? "skipped" : "unknown" : "disabled" } };
|
|
2075
|
+
}, (r) => r.needsConfirmation ? [
|
|
2076
|
+
`Would ${r.plan.mode} ${r.plan.kind} "${r.plan.name}" (${r.plan.id}) ${r.plan.mode === "upgrade" ? `${r.plan.from} → ` : ""}${r.plan.version}${r.plan.author ? ` by ${r.plan.author}` : ""}${r.plan.enabled ? "" : " (disabled)"}`,
|
|
2077
|
+
` apiVersion ${r.plan.apiVersion}, bundle ${kb(r.plan.size.bundle)}${r.plan.dependsOn.length ? `, needs ${r.plan.dependsOn.join(", ")}` : ""}`,
|
|
2078
|
+
...r.plan.warnings.map((w) => ` note: ${w}`),
|
|
2079
|
+
` ${r.plan.warning}`,
|
|
2080
|
+
"Nothing changed — re-run with --yes once the user agrees."
|
|
2081
|
+
].join("\n") : `${r.mode === "upgrade" ? `Upgraded ${r.previousVersion} → ` : "Installed "}${r.id} ${r.version} (${r.enabled ? "enabled" : "disabled"}; load: ${r.load.status}${"error" in r.load ? ` — ${r.load.error}` : ""})${r.warnings.length ? `
|
|
2082
|
+
${r.warnings.join("\n ")}` : ""}`);
|
|
2083
|
+
}
|
|
2084
|
+
const extCommand = {
|
|
2085
|
+
name: "ext",
|
|
2086
|
+
usage: "ext <new|check|pack|install|list|enable|disable|uninstall|export> …",
|
|
2087
|
+
summary: "Extensions (modules and themes loaded at runtime, no rebuild): new module|theme <id> scaffolds a project (Vite, typings, i18n en/fr), check <dir|zip> validates and loads the bundle like the admin and lists what it registers (exit 1 on errors), pack <dir> builds the ZIP, install <zip|dir> adds it to a CRM (plan first; installing runs code with admin rights), list, enable / disable, uninstall, export (download as ZIP).",
|
|
2088
|
+
subcommands: [
|
|
2089
|
+
{ name: "new", usage: "ext new <module|theme> <id> [--dir <path>] [--name <name>] [--description <text>] [--author <name>] [--force]", summary: "Scaffold a standalone extension project (package.json, vite.config.ts, tsconfig + runtime typings, src/index.tsx, README).", writes: true },
|
|
2090
|
+
{ name: "check", usage: "ext check [<dir|zip>] [--kind module|theme] [--no-call]", summary: "Validate manifest, ids, apiVersion, sizes, imports; load the bundle in a throw-away CRM and report routes, nav, slots, AI tools, automation actions, theme values." },
|
|
2091
|
+
{ name: "pack", usage: "ext pack [<dir>] [--out <file.zip>]", summary: "ZIP manifest.json + bundle.js (+ style.css) for Settings → Modules → Install or `ext install`.", writes: true },
|
|
2092
|
+
{ name: "install", usage: "ext install <zip|dir> [--site <alias>] [--disabled] [--yes]", summary: "Install or upgrade an extension on a CRM. Prints the plan (id, version, author, upgrade, size) unless --yes.", writes: true, destructive: true },
|
|
2093
|
+
{ name: "list", usage: "ext list [--kind module|theme]", summary: "Installed extensions with version, enabled state and load status in this process." },
|
|
2094
|
+
{ name: "enable", usage: "ext enable <id> [--cascade] [--yes]", summary: "Enable an installed extension (plan unless --yes).", writes: true },
|
|
2095
|
+
{ name: "disable", usage: "ext disable <id> [--cascade] [--yes]", summary: "Disable it without uninstalling (plan unless --yes).", writes: true },
|
|
2096
|
+
{ name: "uninstall", usage: "ext uninstall <id> [--force] [--yes]", summary: "Remove an extension (its module configuration is kept). Plan unless --yes.", writes: true, destructive: true },
|
|
2097
|
+
{ name: "export", usage: "ext export <id> [--out <file.zip>]", summary: "Download an installed extension as its ZIP." }
|
|
2098
|
+
],
|
|
2099
|
+
options: [
|
|
2100
|
+
SITE_OPTION,
|
|
2101
|
+
AS_OPTION,
|
|
2102
|
+
YES_OPTION,
|
|
2103
|
+
{ name: "dir", type: "string", description: "new: target folder (default: ./<id>)." },
|
|
2104
|
+
{ name: "name", type: "string", description: "new: display name." },
|
|
2105
|
+
{ name: "description", type: "string", description: "new: one-sentence description." },
|
|
2106
|
+
{ name: "author", type: "string", description: "new: author shown in the install plan and Settings → Modules." },
|
|
2107
|
+
{ name: "force", type: "boolean", description: "new: write into a non-empty folder. uninstall: even if enabled modules depend on it." },
|
|
2108
|
+
{ name: "kind", type: "string", description: "check: expected kind. list: only modules or themes." },
|
|
2109
|
+
{ name: "no-call", type: "boolean", description: "check: don't smoke-call the AI tools on the demo data." },
|
|
2110
|
+
{ name: "out", type: "string", description: "pack / export: ZIP path (default: <dir>/<id>-<version>.zip, ./<id>-<version>.zip)." },
|
|
2111
|
+
{ name: "disabled", type: "boolean", description: "install: install it disabled." },
|
|
2112
|
+
{ name: "cascade", type: "boolean", description: "enable / disable: also enable the dependencies / disable the dependents." }
|
|
2113
|
+
],
|
|
2114
|
+
async run(ctx) {
|
|
2115
|
+
const { io, options, positionals } = ctx;
|
|
2116
|
+
const [action] = positionals;
|
|
2117
|
+
const apply = options.yes === true;
|
|
2118
|
+
switch (action) {
|
|
2119
|
+
case "new":
|
|
2120
|
+
return scaffold(ctx);
|
|
2121
|
+
case "check":
|
|
2122
|
+
return check(ctx);
|
|
2123
|
+
case "pack":
|
|
2124
|
+
return pack(ctx);
|
|
2125
|
+
case "install":
|
|
2126
|
+
return install(ctx);
|
|
2127
|
+
case "list": {
|
|
2128
|
+
const kind = strOpt(options, "kind");
|
|
2129
|
+
if (kind && kind !== "module" && kind !== "theme") usage("--kind is module or theme.");
|
|
2130
|
+
return withSession(io, options, {}, async (s) => {
|
|
2131
|
+
const { listExtensions } = await import("./chunks/extensions-BP1k60md.mjs");
|
|
2132
|
+
const { getLoadedExtension } = await import("./chunks/registry-Ceexawxl.mjs");
|
|
2133
|
+
return listExtensions(s.ctx, kind ? { kind } : {}).map((e) => ({ ...e, load: s.extensions.safeMode ? "skipped" : getLoadedExtension(e.id)?.status ?? (e.enabled ? "not loaded" : "disabled"), ...getLoadedExtension(e.id)?.error ? { loadError: getLoadedExtension(e.id)?.error } : {} }));
|
|
2134
|
+
}, (rows) => table(rows.map((r) => ({ ...r, enabled: r.enabled ? "yes" : "no" })), ["id", "kind", "name", "version", "enabled", "load", "author"]));
|
|
2135
|
+
}
|
|
2136
|
+
case "enable":
|
|
2137
|
+
case "disable": {
|
|
2138
|
+
const id = positional(positionals, 1, "extension id", `ext ${action} <id> [--yes]`);
|
|
2139
|
+
const enabled = action === "enable";
|
|
2140
|
+
const cascade = boolOpt(options, "cascade");
|
|
2141
|
+
return withSession(io, options, { lock: apply }, async (s) => {
|
|
2142
|
+
const ext = await import("./chunks/extensions-BP1k60md.mjs");
|
|
2143
|
+
const before = ext.getExtension(s.ctx, id);
|
|
2144
|
+
if (!apply) {
|
|
2145
|
+
const { result } = await dryRun(s.ctx, async (copy) => {
|
|
2146
|
+
await ext.setExtensionEnabled(copy, id, enabled, { cascade });
|
|
2147
|
+
return (await import("./chunks/modules-CqdVQVJb.mjs")).moduleStates(copy.store);
|
|
2148
|
+
});
|
|
2149
|
+
const now = new Map(result.map((m) => [m.id, m.enabled]));
|
|
2150
|
+
const changed = (await import("./chunks/modules-CqdVQVJb.mjs")).moduleStates(s.store).filter((m) => now.has(m.id) && now.get(m.id) !== m.enabled).map((m) => ({ id: m.id, enabled: now.get(m.id) }));
|
|
2151
|
+
if (before.kind === "theme" && before.enabled !== enabled) changed.push({ id, enabled });
|
|
2152
|
+
return { needsConfirmation: true, plan: { id, kind: before.kind, from: before.enabled, to: enabled, changed } };
|
|
2153
|
+
}
|
|
2154
|
+
const summary = await ext.setExtensionEnabled(s.ctx, id, enabled, { cascade });
|
|
2155
|
+
await s.extensions.sync();
|
|
2156
|
+
return summary;
|
|
2157
|
+
}, (r) => "needsConfirmation" in r ? `Would ${action} ${id}${r.plan.changed.length > 1 ? ` (also: ${r.plan.changed.filter((c) => c.id !== id).map((c) => `${c.id} → ${c.enabled ? "on" : "off"}`).join(", ")})` : ""}.
|
|
2158
|
+
Nothing changed — re-run with --yes once the user agrees.` : `${r.id} is now ${r.enabled ? "enabled" : "disabled"}.`);
|
|
2159
|
+
}
|
|
2160
|
+
case "uninstall": {
|
|
2161
|
+
const id = positional(positionals, 1, "extension id", "ext uninstall <id> [--yes]");
|
|
2162
|
+
const force = boolOpt(options, "force");
|
|
2163
|
+
return withSession(io, options, { lock: apply }, async (s) => {
|
|
2164
|
+
const ext = await import("./chunks/extensions-BP1k60md.mjs");
|
|
2165
|
+
const summary = ext.getExtension(s.ctx, id);
|
|
2166
|
+
if (!apply) {
|
|
2167
|
+
await dryRun(s.ctx, (copy) => ext.uninstallExtension(copy, id, { force }));
|
|
2168
|
+
return { needsConfirmation: true, plan: { id, kind: summary.kind, name: summary.name, version: summary.version, enabled: summary.enabled, note: "The module configuration is kept; re-installing restores it." } };
|
|
2169
|
+
}
|
|
2170
|
+
const res = await ext.uninstallExtension(s.ctx, id, { force });
|
|
2171
|
+
await s.extensions.sync();
|
|
2172
|
+
return { ...res, name: summary.name, version: summary.version };
|
|
2173
|
+
}, (r) => r.needsConfirmation ? `Would uninstall ${r.plan.kind} "${r.plan.name}" ${r.plan.version}.
|
|
2174
|
+
Nothing changed — re-run with --yes once the user agrees.` : `Uninstalled ${r.id} ${r.version}.`);
|
|
2175
|
+
}
|
|
2176
|
+
case "export": {
|
|
2177
|
+
const id = positional(positionals, 1, "extension id", "ext export <id> [--out <file.zip>]");
|
|
2178
|
+
return withSession(io, options, {}, async (s) => {
|
|
2179
|
+
const { getExtensionPackage } = await import("./chunks/extensions-BP1k60md.mjs");
|
|
2180
|
+
const { packExtension } = await import("./chunks/package-DWbn3t2R.mjs");
|
|
2181
|
+
const pkg2 = getExtensionPackage(s.ctx, id);
|
|
2182
|
+
const bytes = await packExtension(pkg2);
|
|
2183
|
+
const out = resolve(strOpt(options, "out") ?? `${id}-${pkg2.manifest.version}.zip`);
|
|
2184
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
2185
|
+
writeFileSync(out, bytes);
|
|
2186
|
+
return { id, version: pkg2.manifest.version, zip: out, bytes: bytes.length };
|
|
2187
|
+
}, (r) => `Wrote ${r.zip} (${kb(r.bytes)})`);
|
|
2188
|
+
}
|
|
2189
|
+
default:
|
|
2190
|
+
usage(action ? `Unknown ext action "${action}".` : "ext needs an action.", "Actions: new, check, pack, install, list, enable, disable, uninstall, export.");
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
function printPlan(io, plan) {
|
|
2195
|
+
printResult(io, plan, () => `Would restore ${String(plan.plan.restore)} (${String(plan.plan.mode)}). Re-run with --yes once the user agrees.`);
|
|
2196
|
+
}
|
|
2197
|
+
function mappingOpt(options) {
|
|
2198
|
+
const raw = typeof options.map === "string" ? readTextValue(options.map, "map").trim() : void 0;
|
|
2199
|
+
if (!raw) return void 0;
|
|
2200
|
+
if (raw.startsWith("{")) {
|
|
2201
|
+
try {
|
|
2202
|
+
return JSON.parse(raw);
|
|
2203
|
+
} catch (err) {
|
|
2204
|
+
usage(`--map is not valid JSON: ${err.message}`);
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
return Object.fromEntries(
|
|
2208
|
+
raw.split(",").map((pair) => {
|
|
2209
|
+
const i = pair.lastIndexOf("=");
|
|
2210
|
+
if (i <= 0) usage(`--map expects Header=field pairs, got "${pair}".`);
|
|
2211
|
+
return [pair.slice(0, i).trim(), pair.slice(i + 1).trim()];
|
|
2212
|
+
})
|
|
2213
|
+
);
|
|
2214
|
+
}
|
|
2215
|
+
const importCommand = {
|
|
2216
|
+
name: "import",
|
|
2217
|
+
usage: "import csv <file|-> --object <id> [--map <json|Header=field,…>] [--dedupe update|skip|create] [--delimiter ;] [--day-first] [--yes]",
|
|
2218
|
+
summary: "Import a CSV into an object: columns mapped to fields automatically (or with --map), values coerced, duplicates (people by email, companies by domain, else by name) updated / skipped. DRY RUN by default — the report shows every row's outcome; --yes applies.",
|
|
2219
|
+
subcommands: [
|
|
2220
|
+
{ name: "csv", usage: "import csv <file|-> --object <id> [--map …] [--dedupe update|skip|create] [--yes]", summary: "Dry run (default) or apply (--yes).", writes: true },
|
|
2221
|
+
{ name: "zip", usage: "import zip <backup.zip> [--replace] [--yes]", summary: "Restore a backup ZIP (data + files; merge, or --replace). Plan unless --yes.", writes: true, destructive: true }
|
|
2222
|
+
],
|
|
2223
|
+
options: [
|
|
2224
|
+
SITE_OPTION,
|
|
2225
|
+
AS_OPTION,
|
|
2226
|
+
YES_OPTION,
|
|
2227
|
+
{ name: "object", type: "string", description: "Target object id." },
|
|
2228
|
+
{ name: "map", type: "string", description: 'Column mapping: JSON {"Header":"fieldId"} or "Header=fieldId,…" ("" skips a column, $owner / $id special).' },
|
|
2229
|
+
{ name: "dedupe", type: "string", description: "Existing duplicate: update (default), skip, or create anyway." },
|
|
2230
|
+
{ name: "delimiter", type: "string", description: "Column delimiter (default: detected)." },
|
|
2231
|
+
{ name: "day-first", type: "boolean", description: "Ambiguous dates are DD/MM/YYYY." },
|
|
2232
|
+
{ name: "no-create-options", type: "boolean", description: "Don't add unknown select values as new options." },
|
|
2233
|
+
{ name: "dry-run", type: "boolean", description: "Explicit dry run (the default without --yes)." },
|
|
2234
|
+
{ name: "rows", type: "string", description: "Rows shown in the report (default 100)." },
|
|
2235
|
+
{ name: "replace", type: "boolean", description: "zip: also delete documents absent from the backup." }
|
|
2236
|
+
],
|
|
2237
|
+
async run({ io, options, positionals }) {
|
|
2238
|
+
const [kind, file2] = positionals;
|
|
2239
|
+
if (kind === "zip") {
|
|
2240
|
+
const path = positional(positionals, 1, "backup ZIP", "import zip <backup.zip> [--yes]");
|
|
2241
|
+
const { existsSync: existsSync2, readFileSync: readFileSync2 } = await import("node:fs");
|
|
2242
|
+
if (!existsSync2(path)) usage(`${path} does not exist.`);
|
|
2243
|
+
const bytes = new Uint8Array(readFileSync2(path));
|
|
2244
|
+
if (options.yes !== true) {
|
|
2245
|
+
printPlan(io, { needsConfirmation: true, plan: { restore: resolve(path), bytes: bytes.length, mode: options.replace === true ? "replace" : "merge" } });
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => (await import("./chunks/backup-ClSmxmFZ.mjs")).importBackupZip(ctx, bytes, { mode: options.replace === true ? "replace" : "merge" }), (r) => `Restored ${r.written} document(s) and ${r.files} file(s)`);
|
|
2249
|
+
}
|
|
2250
|
+
if (kind !== "csv") usage('Only "import csv" and "import zip" are supported.', "Usage: agentcrm import csv <file> --object <id>");
|
|
2251
|
+
const source = positional(positionals, 1, "CSV file (or -)", "import csv <file|-> --object <id>");
|
|
2252
|
+
const csv = readTextValue(source === "-" ? "-" : `@${source}`, "file");
|
|
2253
|
+
const objectId = requireStr(options, "object");
|
|
2254
|
+
const dedupe = strOpt(options, "dedupe") ?? "update";
|
|
2255
|
+
if (!["update", "skip", "create"].includes(dedupe)) usage("--dedupe must be update, skip or create.");
|
|
2256
|
+
const apply = options.yes === true && options["dry-run"] !== true;
|
|
2257
|
+
const shown = numOpt(options, "rows") ?? 100;
|
|
2258
|
+
return withSession(io, options, { lock: apply }, async ({ ctx }) => {
|
|
2259
|
+
const { importCsv } = await import("./chunks/importCsv-DmE_sBch.mjs");
|
|
2260
|
+
const report = await importCsv(ctx, {
|
|
2261
|
+
object: objectId,
|
|
2262
|
+
csv,
|
|
2263
|
+
mapping: mappingOpt(options),
|
|
2264
|
+
onDuplicate: dedupe,
|
|
2265
|
+
dryRun: !apply,
|
|
2266
|
+
dayFirst: boolOpt(options, "day-first"),
|
|
2267
|
+
createOptions: !boolOpt(options, "no-create-options"),
|
|
2268
|
+
delimiter: strOpt(options, "delimiter")
|
|
2269
|
+
});
|
|
2270
|
+
return { ...report, ...apply ? {} : { needsConfirmation: true }, rows: report.rows.slice(0, shown), rowsTruncated: report.rows.length > shown };
|
|
2271
|
+
}, (r) => [
|
|
2272
|
+
`${r.dryRun ? "DRY RUN — " : ""}${r.total} row(s): ${r.created} create, ${r.updated} update, ${r.skipped} skip, ${r.failed} error`,
|
|
2273
|
+
`Mapping: ${Object.entries(r.mapping).map(([h, f]) => `${h} → ${f || "(skip)"}`).join(", ")}`,
|
|
2274
|
+
...r.rows.filter((x) => x.action === "error").map((x) => ` row ${x.row}: ${(x.messages ?? []).join("; ")}`),
|
|
2275
|
+
...r.dryRun ? ["Nothing changed — re-run with --yes once the user agrees."] : []
|
|
2276
|
+
].join("\n"));
|
|
2277
|
+
}
|
|
2278
|
+
};
|
|
2279
|
+
const exportCommand = {
|
|
2280
|
+
name: "export",
|
|
2281
|
+
usage: "export <csv|json|zip> …",
|
|
2282
|
+
summary: "Export one object as CSV (labels or raw values, filters, columns), the whole dataset as JSON (backup / migration, re-importable), or a ZIP backup with data + files.",
|
|
2283
|
+
subcommands: [
|
|
2284
|
+
{ name: "csv", usage: "export csv --object <id> [--out <file>] [--filter f:op:v]… [--sort f] [--columns a,b] [--view <id>] [--raw] [--deleted]", summary: "CSV of one object." },
|
|
2285
|
+
{ name: "json", usage: "export json [--out <file>] [--collections records,activities,…]", summary: "Whole dataset (or some collections) as JSON." },
|
|
2286
|
+
{ name: "zip", usage: "export zip --out <file.zip> [--no-files]", summary: "Backup ZIP: data.json + the (decrypted) bytes of every file. Not encrypted: keep it safe." }
|
|
2287
|
+
],
|
|
2288
|
+
options: [
|
|
2289
|
+
SITE_OPTION,
|
|
2290
|
+
AS_OPTION,
|
|
2291
|
+
{ name: "object", type: "string", description: "csv: object id." },
|
|
2292
|
+
{ name: "out", type: "string", description: "Write to this file (default: stdout / the JSON result)." },
|
|
2293
|
+
{ name: "filter", type: "string", description: "field:op:value (repeatable).", multiple: true },
|
|
2294
|
+
{ name: "sort", type: "string", description: "field[:desc] (repeatable).", multiple: true },
|
|
2295
|
+
{ name: "columns", type: "string", description: "Field ids to export." },
|
|
2296
|
+
{ name: "view", type: "string", description: "Apply a saved view." },
|
|
2297
|
+
{ name: "raw", type: "boolean", description: "Stored values (ids, ISO dates) instead of labels." },
|
|
2298
|
+
{ name: "deleted", type: "boolean", description: "Include soft-deleted records." },
|
|
2299
|
+
{ name: "collections", type: "string", description: "json: collections to export." },
|
|
2300
|
+
{ name: "no-files", type: "boolean", description: "zip: data only (no file bytes)." }
|
|
2301
|
+
],
|
|
2302
|
+
async run({ io, options, positionals }) {
|
|
2303
|
+
const [kind] = positionals;
|
|
2304
|
+
if (kind === "zip") {
|
|
2305
|
+
const file2 = strOpt(options, "out");
|
|
2306
|
+
if (!file2) usage("export zip needs --out <file.zip>.");
|
|
2307
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
2308
|
+
const { exportBackupZip } = await import("./chunks/backup-ClSmxmFZ.mjs");
|
|
2309
|
+
const { zip, report } = await exportBackupZip(ctx, { includeFiles: options["no-files"] !== true });
|
|
2310
|
+
writeFileSync(resolve(file2), zip);
|
|
2311
|
+
return { file: resolve(file2), zipBytes: zip.length, files: report.files, missing: report.missing, fileBytes: report.bytes };
|
|
2312
|
+
}, (r) => `Wrote ${r.zipBytes} bytes (${r.files} file(s)${r.missing.length ? `, ${r.missing.length} missing` : ""}) to ${r.file}`);
|
|
2313
|
+
}
|
|
2314
|
+
const out = strOpt(options, "out");
|
|
2315
|
+
const deliver = (text, extra) => {
|
|
2316
|
+
if (out) {
|
|
2317
|
+
writeFileSync(resolve(out), text);
|
|
2318
|
+
return { file: resolve(out), bytes: Buffer.byteLength(text), ...extra };
|
|
2319
|
+
}
|
|
2320
|
+
return { ...extra, content: text };
|
|
2321
|
+
};
|
|
2322
|
+
if (kind === "csv") {
|
|
2323
|
+
const objectId = requireStr(options, "object");
|
|
2324
|
+
const filters = parseFilters(multiOpt(options, "filter"), FILTER_OPS);
|
|
2325
|
+
const sorts = parseSorts(multiOpt(options, "sort"));
|
|
2326
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
2327
|
+
const { exportCsv } = await import("./chunks/exportCsv-B5hRvkNm.mjs");
|
|
2328
|
+
const csv = exportCsv(ctx, objectId, {
|
|
2329
|
+
viewId: strOpt(options, "view"),
|
|
2330
|
+
filters,
|
|
2331
|
+
sorts,
|
|
2332
|
+
fields: listOpt(options, "columns"),
|
|
2333
|
+
values: boolOpt(options, "raw") ? "raw" : "labels",
|
|
2334
|
+
includeDeleted: boolOpt(options, "deleted")
|
|
2335
|
+
});
|
|
2336
|
+
return deliver(csv, { object: objectId, rows: Math.max(0, csv.split(/\r?\n/).filter(Boolean).length - 1) });
|
|
2337
|
+
}, (r) => "content" in r ? String(r.content) : `Wrote ${String(r.rows)} row(s) to ${String(r.file)}`);
|
|
2338
|
+
}
|
|
2339
|
+
if (kind === "json") {
|
|
2340
|
+
return withSession(io, options, {}, async ({ ctx }) => {
|
|
2341
|
+
const { exportJson } = await import("./chunks/exportCsv-B5hRvkNm.mjs");
|
|
2342
|
+
const data = exportJson(ctx, { collections: listOpt(options, "collections") });
|
|
2343
|
+
const text = JSON.stringify(data, null, 2);
|
|
2344
|
+
if (out) return deliver(text, {});
|
|
2345
|
+
return { export: data };
|
|
2346
|
+
}, (r) => "export" in r ? JSON.stringify(r.export, null, 2) : `Wrote ${String(r.bytes)} bytes to ${String(r.file)}`);
|
|
2347
|
+
}
|
|
2348
|
+
usage(`Unknown export kind "${kind ?? ""}".`, "Use export csv, export json or export zip.");
|
|
2349
|
+
}
|
|
2350
|
+
};
|
|
2351
|
+
const REPORT_ALIASES = {
|
|
2352
|
+
pipeline: "pipelineByStage",
|
|
2353
|
+
forecast: "forecast",
|
|
2354
|
+
"won-lost": "wonLost",
|
|
2355
|
+
activity: "activityByUser",
|
|
2356
|
+
"lead-sources": "leadSources",
|
|
2357
|
+
stale: "staleDeals",
|
|
2358
|
+
conversion: "conversion"
|
|
2359
|
+
};
|
|
2360
|
+
const reportCommand = {
|
|
2361
|
+
name: "report",
|
|
2362
|
+
usage: "report <pipeline|forecast|won-lost|activity|lead-sources|stale|conversion> [--months n] [--days n] [--pipeline <id>] [--owner <userId>] [--params <json>]",
|
|
2363
|
+
summary: "Sales reports: pipeline by stage, weighted forecast, won / lost per month (win rate), activity per user, lead sources, stale deals, lifecycle conversion funnel.",
|
|
2364
|
+
subcommands: Object.keys(REPORT_ALIASES).map((name2) => ({ name: name2, usage: `report ${name2}`, summary: `The ${name2} report.` })),
|
|
2365
|
+
options: [
|
|
2366
|
+
SITE_OPTION,
|
|
2367
|
+
AS_OPTION,
|
|
2368
|
+
{ name: "months", type: "string", description: "won-lost: months back (default 6)." },
|
|
2369
|
+
{ name: "days", type: "string", description: "activity / stale: days (default 30 / settings.staleDealDays)." },
|
|
2370
|
+
{ name: "pipeline", type: "string", description: "Pipeline id." },
|
|
2371
|
+
{ name: "owner", type: "string", description: "Only deals owned by this user id." },
|
|
2372
|
+
{ name: "params", type: "string", description: "Raw report parameters (JSON)." }
|
|
2373
|
+
],
|
|
2374
|
+
async run({ io, options, positionals }) {
|
|
2375
|
+
const name2 = positional(positionals, 0, "report name", "report <name>");
|
|
2376
|
+
const internal = REPORT_ALIASES[name2] ?? name2;
|
|
2377
|
+
const params = {
|
|
2378
|
+
...jsonObjectOpt(options, "params") ?? {},
|
|
2379
|
+
...numOpt(options, "months") !== void 0 ? { months: numOpt(options, "months") } : {},
|
|
2380
|
+
...numOpt(options, "days") !== void 0 ? { days: numOpt(options, "days") } : {},
|
|
2381
|
+
...strOpt(options, "pipeline") ? { pipelineId: strOpt(options, "pipeline") } : {},
|
|
2382
|
+
...strOpt(options, "owner") ? { ownerId: strOpt(options, "owner") } : {}
|
|
2383
|
+
};
|
|
2384
|
+
return withSession(io, options, {}, async ({ ctx }) => ({ report: name2, data: (await import("./chunks/reports-BB59N6ke.mjs")).runReport(ctx, internal, params) }));
|
|
2385
|
+
}
|
|
2386
|
+
};
|
|
2387
|
+
const statsCommand = {
|
|
2388
|
+
name: "stats",
|
|
2389
|
+
usage: "stats [--site <alias>]",
|
|
2390
|
+
summary: "Dataset overview: records per object, activities by type, open / overdue tasks, users, open pipeline (+ journal segments).",
|
|
2391
|
+
options: [SITE_OPTION, AS_OPTION],
|
|
2392
|
+
async run({ io, options }) {
|
|
2393
|
+
return withSession(io, options, {}, async ({ ctx, store }) => {
|
|
2394
|
+
const stats = (await import("./chunks/stats-CfeJssSS.mjs")).getStats(ctx);
|
|
2395
|
+
const journal = store.stats;
|
|
2396
|
+
return { ...stats, ...journal ? { journal: await journal.call(store) } : {} };
|
|
2397
|
+
}, (s) => [`backend: ${s.backend}`, ...s.objects.map((o) => ` ${o.label}: ${o.count}${o.deleted ? ` (+${o.deleted} deleted)` : ""}`), `open tasks: ${s.openTasks} (${s.overdueTasks} overdue)`, `users: ${s.users}`].join("\n"));
|
|
2398
|
+
}
|
|
2399
|
+
};
|
|
2400
|
+
const compactCommand = {
|
|
2401
|
+
name: "compact",
|
|
2402
|
+
usage: "compact [--older-than <days>] [--keep-runs <n>] [--yes]",
|
|
2403
|
+
summary: "Physical purge (GDPR erasure): hard-deletes soft-deleted records and activities and old automation runs, deletes the bytes of deleted / orphan files, then rewrites the storage without history (journal: new snapshot, merged segments deleted). Plan unless --yes.",
|
|
2404
|
+
options: [SITE_OPTION, AS_OPTION, YES_OPTION, { name: "older-than", type: "string", description: "Only items deleted more than N days ago (default: all)." }, { name: "keep-runs", type: "string", description: "Automation runs kept (default 500)." }],
|
|
2405
|
+
async run({ io, options }) {
|
|
2406
|
+
const apply = options.yes === true;
|
|
2407
|
+
return withSession(io, options, { lock: apply }, async ({ ctx }) => {
|
|
2408
|
+
const { compactData } = await import("./chunks/compact-CsFPaAAq.mjs");
|
|
2409
|
+
const report = await compactData(ctx, { olderThanDays: numOpt(options, "older-than"), keepRuns: numOpt(options, "keep-runs"), dryRun: !apply });
|
|
2410
|
+
return apply ? report : { needsConfirmation: true, plan: report };
|
|
2411
|
+
}, (r) => "plan" in r ? `Would purge ${r.plan.purgedRecords.length} record(s), ${r.plan.purgedActivities} activit(ies), ${r.plan.purgedRuns} run(s), ${r.plan.purgedFiles} file(s). Re-run with --yes once the user agrees.` : `Purged ${r.purgedRecords.length} record(s), ${r.purgedFiles} file(s); storage compacted: ${r.storageCompacted}`);
|
|
2412
|
+
}
|
|
2413
|
+
};
|
|
2414
|
+
const automationsCommand = {
|
|
2415
|
+
name: "automations",
|
|
2416
|
+
usage: "automations <list|enable|disable|run> …",
|
|
2417
|
+
summary: "Automations (event triggers run in-process on every write, also from the CLI): list, enable / disable, run the scheduled & inactivity ones (cron / GitHub Actions). `run` is a dry run unless --yes.",
|
|
2418
|
+
subcommands: [
|
|
2419
|
+
{ name: "list", usage: "automations list", summary: "Automations with trigger, enabled state and last run." },
|
|
2420
|
+
{ name: "enable", usage: "automations enable <id>", summary: "Enable an automation.", writes: true },
|
|
2421
|
+
{ name: "disable", usage: "automations disable <id>", summary: "Disable an automation.", writes: true },
|
|
2422
|
+
{ name: "run", usage: "automations run [--id <automationId>] [--force] [--yes]", summary: "Evaluate schedule / inactivity triggers now (runScheduled). Dry run unless --yes.", writes: true }
|
|
2423
|
+
],
|
|
2424
|
+
options: [SITE_OPTION, AS_OPTION, YES_OPTION, { name: "id", type: "string", description: "run: only this automation." }, { name: "force", type: "boolean", description: "run: ignore the everyHours spacing." }],
|
|
2425
|
+
async run({ io, options, positionals }) {
|
|
2426
|
+
const [action = "list", id] = positionals;
|
|
2427
|
+
switch (action) {
|
|
2428
|
+
case "list":
|
|
2429
|
+
return withSession(io, options, {}, async ({ ctx }) => (await import("./chunks/automations-BWrvLqP6.mjs")).listAutomations(ctx).map((a) => ({ id: a.id, name: a.name, enabled: a.enabled, object: a.object, trigger: a.trigger, actions: a.actions.map((x) => x.type), lastRunAt: a.lastRunAt ? new Date(a.lastRunAt).toISOString() : null })), (rows) => table(rows, ["id", "name", "enabled", "object", "lastRunAt"]));
|
|
2430
|
+
case "enable":
|
|
2431
|
+
case "disable": {
|
|
2432
|
+
if (!id) usage(`automations ${action} needs an id.`);
|
|
2433
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => {
|
|
2434
|
+
const mod = await import("./chunks/automations-BWrvLqP6.mjs");
|
|
2435
|
+
return mod.setAutomationEnabled(ctx, id, action === "enable");
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
case "run": {
|
|
2439
|
+
const apply = options.yes === true;
|
|
2440
|
+
return withSession(io, options, { lock: apply }, async ({ ctx }) => {
|
|
2441
|
+
const { runScheduled } = await import("./chunks/automations-BWrvLqP6.mjs");
|
|
2442
|
+
const report = await runScheduled(ctx, { dryRun: !apply, automationId: strOpt(options, "id"), force: boolOpt(options, "force") });
|
|
2443
|
+
return apply ? report : { needsConfirmation: true, ...report };
|
|
2444
|
+
}, (r) => `${"needsConfirmation" in r ? "DRY RUN — " : ""}ran ${r.ran}, skipped ${r.skipped}, errors ${r.errors}`);
|
|
2445
|
+
}
|
|
2446
|
+
default:
|
|
2447
|
+
usage(`Unknown automations action "${action}".`, "Use list, enable, disable or run.");
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
};
|
|
2451
|
+
const formsCommand = {
|
|
2452
|
+
name: "forms",
|
|
2453
|
+
usage: "forms <list|publish|ingest> …",
|
|
2454
|
+
summary: "Lead capture forms: list, publish the static form page to the hosting, ingest pending submissions from the inbox (records created / deduped, activity logged). publish and ingest print a plan / dry run unless --yes.",
|
|
2455
|
+
subcommands: [
|
|
2456
|
+
{ name: "list", usage: "forms list", summary: "Forms with their inbox and published URL." },
|
|
2457
|
+
{ name: "publish", usage: "forms publish <formId> [--yes]", summary: "Upload the form page (+ print the embed snippet).", writes: true },
|
|
2458
|
+
{ name: "ingest", usage: "forms ingest [--form <id>] [--limit n] [--yes]", summary: "Turn inbox submissions into records. Dry run unless --yes.", writes: true }
|
|
2459
|
+
],
|
|
2460
|
+
options: [SITE_OPTION, AS_OPTION, YES_OPTION, { name: "form", type: "string", description: "ingest: only this form." }, { name: "limit", type: "string", description: "ingest: max submissions." }],
|
|
2461
|
+
async run({ io, options, positionals }) {
|
|
2462
|
+
const [action = "list", id] = positionals;
|
|
2463
|
+
switch (action) {
|
|
2464
|
+
case "list":
|
|
2465
|
+
return withSession(io, options, {}, async ({ ctx }) => (await import("./chunks/forms-BKU5F9WF.mjs")).listForms(ctx), (fs) => table(fs, ["id", "name", "object", "publishedPath"]));
|
|
2466
|
+
case "publish": {
|
|
2467
|
+
if (!id) usage("forms publish needs a form id.");
|
|
2468
|
+
return withSession(io, options, { lock: options.yes === true }, async (s) => {
|
|
2469
|
+
const forms = await import("./chunks/forms-BKU5F9WF.mjs");
|
|
2470
|
+
const built = await forms.buildFormHtml(s.ctx, id);
|
|
2471
|
+
const target = s.hostingTarget();
|
|
2472
|
+
if (!target) usage(`${s.site.alias} has no hosting to publish the form to.`);
|
|
2473
|
+
if (options.yes !== true) return { needsConfirmation: true, plan: { form: id, path: built.path, hosting: s.site.hosting?.type, bytes: built.html.length } };
|
|
2474
|
+
const { buildConnector } = await import("./chunks/hosting-Bme7LxPc.mjs");
|
|
2475
|
+
const connector = buildConnector(target);
|
|
2476
|
+
const siteUrl = typeof s.site.hosting?.settings.siteUrl === "string" ? s.site.hosting.settings.siteUrl.replace(/\/+$/, "") : "";
|
|
2477
|
+
return forms.publishForm(s.ctx, id, { put: (input) => connector.put(input), publicUrl: async (path) => siteUrl ? `${siteUrl}/${path}` : connector.publicUrl(path) });
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
case "ingest": {
|
|
2481
|
+
const apply = options.yes === true;
|
|
2482
|
+
return withSession(io, options, { lock: apply }, async ({ ctx }) => {
|
|
2483
|
+
const { ingestInbox } = await import("./chunks/forms-BKU5F9WF.mjs");
|
|
2484
|
+
const report = await ingestInbox(ctx, strOpt(options, "form"), { dryRun: !apply, limit: numOpt(options, "limit") });
|
|
2485
|
+
return apply ? report : { needsConfirmation: true, ...report };
|
|
2486
|
+
}, (r) => `${"needsConfirmation" in r ? "DRY RUN — " : ""}processed ${r.processed}: ${r.created} created, ${r.updated} updated, ${r.failed} failed`);
|
|
2487
|
+
}
|
|
2488
|
+
default:
|
|
2489
|
+
usage(`Unknown forms action "${action}".`, "Use list, publish or ingest.");
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
};
|
|
2493
|
+
function parse(v) {
|
|
2494
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v.trim());
|
|
2495
|
+
if (!m) throw new Error(`Invalid version: ${v}`);
|
|
2496
|
+
return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] ?? ""];
|
|
2497
|
+
}
|
|
2498
|
+
function compareVersions(a, b) {
|
|
2499
|
+
const pa = parse(a);
|
|
2500
|
+
const pb = parse(b);
|
|
2501
|
+
for (let i = 0; i < 3; i++) {
|
|
2502
|
+
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
2503
|
+
}
|
|
2504
|
+
if (pa[3] === pb[3]) return 0;
|
|
2505
|
+
if (!pa[3]) return 1;
|
|
2506
|
+
if (!pb[3]) return -1;
|
|
2507
|
+
return pa[3] < pb[3] ? -1 : 1;
|
|
2508
|
+
}
|
|
2509
|
+
async function fetchText(url, init = {}, fetchImpl = fetch) {
|
|
2510
|
+
try {
|
|
2511
|
+
const u = new URL(url);
|
|
2512
|
+
if (!init.method || init.method === "GET") u.searchParams.set("agentcrm", String(Date.now()));
|
|
2513
|
+
const res = await fetchImpl(u.href, { cache: "no-store", redirect: "follow", signal: AbortSignal.timeout(15e3), ...init });
|
|
2514
|
+
const text = await res.text();
|
|
2515
|
+
return { ok: res.ok, status: res.status, contentType: res.headers.get("content-type") ?? "", text, headers: res.headers };
|
|
2516
|
+
} catch (err) {
|
|
2517
|
+
return { ok: false, status: 0, contentType: "", text: "", headers: null, error: err.message };
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
function parseConfigJs(source) {
|
|
2521
|
+
const m = /window\.__AGENTCRM_CONFIG__\s*=\s*([\s\S]*?);?\s*$/.exec(source.trim());
|
|
2522
|
+
if (!m) throw new Error("config.js does not set window.__AGENTCRM_CONFIG__");
|
|
2523
|
+
const value = JSON.parse(m[1].trim().replace(/;$/, ""));
|
|
2524
|
+
return value && typeof value === "object" ? value : null;
|
|
2525
|
+
}
|
|
2526
|
+
function findSecretKeys(value, path = "") {
|
|
2527
|
+
if (!value || typeof value !== "object") return [];
|
|
2528
|
+
return Object.entries(value).flatMap(([k, v]) => {
|
|
2529
|
+
const here = path ? `${path}.${k}` : k;
|
|
2530
|
+
if (/^(token|apiKey|secretAccessKey|accessKeyId|accessToken|password|passphrase|refreshToken|clientSecret)$/.test(k) && typeof v === "string" && v) {
|
|
2531
|
+
if (k === "apiKey" && path === "firebase") return [];
|
|
2532
|
+
return [here];
|
|
2533
|
+
}
|
|
2534
|
+
return findSecretKeys(v, here);
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
async function fetchHostedAdmin(adminUrl, fetchImpl = fetch) {
|
|
2538
|
+
const base = adminUrl.replace(/\/?$/, "/");
|
|
2539
|
+
let res = await fetchText(`${base}version.json`, {}, fetchImpl);
|
|
2540
|
+
if (!res.ok) {
|
|
2541
|
+
const manifest = await fetchText(`${base}admin-files.json`, {}, fetchImpl);
|
|
2542
|
+
if (manifest.ok) res = manifest;
|
|
2543
|
+
}
|
|
2544
|
+
if (!res.ok) return { version: null, dataSchema: null, error: res.error ?? `HTTP ${res.status}` };
|
|
2545
|
+
try {
|
|
2546
|
+
const m = JSON.parse(res.text);
|
|
2547
|
+
return { version: m.version ?? null, dataSchema: m.dataSchema ?? null };
|
|
2548
|
+
} catch {
|
|
2549
|
+
return { version: null, dataSchema: null, error: "version.json is not valid JSON" };
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
function readChangelog() {
|
|
2553
|
+
try {
|
|
2554
|
+
const data = JSON.parse(readFileSync(join(packageRoot(), "changelog.json"), "utf8"));
|
|
2555
|
+
return data.versions ?? [];
|
|
2556
|
+
} catch {
|
|
2557
|
+
return [];
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
function changesBetween(entries, from, to) {
|
|
2561
|
+
const list = entries.filter((e) => compareVersions(e.version, from) > 0 && compareVersions(e.version, to) <= 0);
|
|
2562
|
+
return {
|
|
2563
|
+
versions: list.map((e) => e.version),
|
|
2564
|
+
breaking: list.some((e) => e.breaking),
|
|
2565
|
+
changes: list.flatMap((e) => e.changes.map((c) => `${e.version}: ${c}`)),
|
|
2566
|
+
actions: [...new Set(list.flatMap((e) => e.actions))]
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
async function fetchLatestVersion(pkg2, fetchImpl = fetch) {
|
|
2570
|
+
try {
|
|
2571
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${pkg2.replace("/", "%2F")}/latest`, { signal: AbortSignal.timeout(5e3) });
|
|
2572
|
+
if (!res.ok) return null;
|
|
2573
|
+
const v = await res.json();
|
|
2574
|
+
return typeof v.version === "string" ? v.version : null;
|
|
2575
|
+
} catch {
|
|
2576
|
+
return null;
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
const deployAdminCommand = {
|
|
2580
|
+
name: "deploy-admin",
|
|
2581
|
+
usage: "deploy-admin [--site <alias> | --all] [--yes] [--allow-downgrade]",
|
|
2582
|
+
summary: "Upload this release's admin (+ version.json + config.js, never secrets) to a CRM's hosting. Prints the plan unless --yes; refuses downgrades and admins that store a newer data format.",
|
|
2583
|
+
options: [
|
|
2584
|
+
{ name: "site", type: "string", description: "Site alias." },
|
|
2585
|
+
{ name: "all", type: "boolean", description: "Every registered site with a hosting." },
|
|
2586
|
+
{ name: "yes", type: "boolean", description: "Apply — only after the user confirmed the plan." },
|
|
2587
|
+
{ name: "allow-downgrade", type: "boolean", description: "Allow installing an older admin than the hosted one." }
|
|
2588
|
+
],
|
|
2589
|
+
async run({ io, options }) {
|
|
2590
|
+
const info = readVersionInfo();
|
|
2591
|
+
const sites = options.all === true ? listSites().sites.filter((s) => s.hosting) : [resolveSite(strOpt(options, "site"))];
|
|
2592
|
+
if (!sites.length) throw new CliError("no-sites", "No registered CRM has a hosting.");
|
|
2593
|
+
installBrowserEnv();
|
|
2594
|
+
const { adminOps, applyOps, buildConnector } = await import("./chunks/hosting-Bme7LxPc.mjs");
|
|
2595
|
+
const { hostingTargetOf: hostingTargetOf2 } = await Promise.resolve().then(() => session);
|
|
2596
|
+
const plans = [];
|
|
2597
|
+
for (const site of sites) {
|
|
2598
|
+
if (!site.hosting) throw new CliError("not-hosted", `${site.alias} has no hosting recorded.`, "Register it with `site add --admin-url …` from a CRM whose admin is hosted, or reinstall with a hosting.");
|
|
2599
|
+
const hosted = site.adminUrl ? await fetchHostedAdmin(site.adminUrl) : { version: null, dataSchema: null };
|
|
2600
|
+
const plan = {
|
|
2601
|
+
alias: site.alias,
|
|
2602
|
+
adminUrl: site.adminUrl,
|
|
2603
|
+
hosting: site.hosting.type,
|
|
2604
|
+
from: hosted.version,
|
|
2605
|
+
to: info.version,
|
|
2606
|
+
files: adminOps(site.hosting.folder, site.runtime).ops.length,
|
|
2607
|
+
changes: hosted.version ? changesBetween(readChangelog(), hosted.version, info.version) : null
|
|
2608
|
+
};
|
|
2609
|
+
if (hosted.dataSchema !== null && hosted.dataSchema > info.dataSchema) plan.refused = `the hosted admin stores data format ${hosted.dataSchema}, this release only knows ${info.dataSchema} — update agentcrm instead`;
|
|
2610
|
+
else if (hosted.version && compareVersions(hosted.version, info.version) > 0 && options["allow-downgrade"] !== true) plan.refused = `the hosted admin (${hosted.version}) is newer than this release — update agentcrm, or pass --allow-downgrade`;
|
|
2611
|
+
plans.push(plan);
|
|
2612
|
+
}
|
|
2613
|
+
if (options.yes !== true) {
|
|
2614
|
+
printResult(
|
|
2615
|
+
io,
|
|
2616
|
+
{ needsConfirmation: true, plans },
|
|
2617
|
+
() => [...plans.map((p) => p.refused ? `${p.alias}: refused — ${p.refused}` : `${p.alias}: admin ${p.from ?? "(none)"} → ${p.to} on ${p.hosting}, ${p.files} files`), "Nothing was changed. Re-run with --yes once the user agrees."].join("\n")
|
|
2618
|
+
);
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
const results = [];
|
|
2622
|
+
for (const [i, site] of sites.entries()) {
|
|
2623
|
+
const plan = plans[i];
|
|
2624
|
+
if (plan.refused) {
|
|
2625
|
+
results.push({ alias: site.alias, deployed: false, reason: plan.refused });
|
|
2626
|
+
continue;
|
|
2627
|
+
}
|
|
2628
|
+
const unlock = lockSite(site.alias);
|
|
2629
|
+
try {
|
|
2630
|
+
const { ops } = adminOps(site.hosting.folder, site.runtime);
|
|
2631
|
+
io.err(`• ${site.alias}: uploading ${ops.length} files…`);
|
|
2632
|
+
await applyOps(buildConnector(hostingTargetOf2(site)), ops);
|
|
2633
|
+
putSite({ ...site, adminVersion: info.version });
|
|
2634
|
+
results.push({ alias: site.alias, deployed: true, from: plan.from, to: info.version, files: ops.length });
|
|
2635
|
+
} catch (err) {
|
|
2636
|
+
results.push({ alias: site.alias, deployed: false, reason: err.message });
|
|
2637
|
+
} finally {
|
|
2638
|
+
unlock();
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
printResult(io, { results }, () => results.map((r) => r.deployed ? `${r.alias}: admin ${r.from ?? "(none)"} → ${r.to}` : `${r.alias}: not deployed — ${r.reason}`).join("\n"));
|
|
2642
|
+
if (results.some((r) => !r.deployed)) return 1;
|
|
2643
|
+
}
|
|
2644
|
+
};
|
|
2645
|
+
function corsRule(origin) {
|
|
2646
|
+
return [
|
|
2647
|
+
{
|
|
2648
|
+
AllowedOrigins: [origin],
|
|
2649
|
+
AllowedMethods: ["GET", "PUT", "DELETE", "HEAD"],
|
|
2650
|
+
AllowedHeaders: ["authorization", "content-type", "cache-control", "x-amz-*"],
|
|
2651
|
+
ExposeHeaders: ["ETag"],
|
|
2652
|
+
MaxAgeSeconds: 3600
|
|
2653
|
+
}
|
|
2654
|
+
];
|
|
2655
|
+
}
|
|
2656
|
+
function s3BucketUrl(settings) {
|
|
2657
|
+
const bucket = typeof settings.bucket === "string" ? settings.bucket : "";
|
|
2658
|
+
if (!bucket) return null;
|
|
2659
|
+
const region = typeof settings.region === "string" && settings.region ? settings.region : "us-east-1";
|
|
2660
|
+
const endpoint = (typeof settings.endpoint === "string" && settings.endpoint ? settings.endpoint : `https://s3.${region}.amazonaws.com`).replace(/\/+$/, "");
|
|
2661
|
+
const pathStyle = settings.forcePathStyle ?? !new URL(endpoint).hostname.endsWith("amazonaws.com");
|
|
2662
|
+
return pathStyle ? `${endpoint}/${bucket}/` : endpoint.replace("://", `://${bucket}.`) + "/";
|
|
2663
|
+
}
|
|
2664
|
+
async function corsCheck(id, what, settings, origin) {
|
|
2665
|
+
const url = s3BucketUrl(settings);
|
|
2666
|
+
if (!url || !origin) return { id, status: "skip", message: `${what}: no admin origin to check CORS for (admin not hosted).` };
|
|
2667
|
+
const res = await fetchText(url, { method: "OPTIONS", headers: { Origin: origin, "Access-Control-Request-Method": "PUT", "Access-Control-Request-Headers": "authorization,content-type" } });
|
|
2668
|
+
const allowed = res.headers?.get("access-control-allow-origin");
|
|
2669
|
+
if (allowed === "*" || allowed === origin) return { id, status: "ok", message: `${what}: CORS allows ${origin}.` };
|
|
2670
|
+
return {
|
|
2671
|
+
id,
|
|
2672
|
+
status: "warn",
|
|
2673
|
+
message: `${what}: the bucket did not confirm a CORS rule for ${origin}${res.status ? ` (preflight HTTP ${res.status})` : res.error ? ` (${res.error})` : ""} — the browser admin can't reach it without one.`,
|
|
2674
|
+
fix: `Add this CORS rule to bucket "${String(settings.bucket)}" (AWS console → Permissions → CORS, or \`aws s3api put-bucket-cors\`; R2 / OVH / Scaleway have the same JSON): ${JSON.stringify(corsRule(origin))}`
|
|
2675
|
+
};
|
|
2676
|
+
}
|
|
2677
|
+
async function runDoctor(site, opts = {}) {
|
|
2678
|
+
const checks = [];
|
|
2679
|
+
const add = (c) => checks.push(c);
|
|
2680
|
+
const info = readVersionInfo();
|
|
2681
|
+
let repaired = null;
|
|
2682
|
+
for (const role of ["data", "hosting", "passphrase"]) {
|
|
2683
|
+
const account = site.credentials[role];
|
|
2684
|
+
if (!account) continue;
|
|
2685
|
+
add(
|
|
2686
|
+
getCredential(account) ? { id: `credential-${role}`, status: "ok", message: `${role} credential ${account} stored.` } : { id: `credential-${role}`, status: "fail", message: `${role} credential ${account} is not stored on this computer.`, fix: `The user runs \`agentcrm credentials set ${account}\` in their own terminal (hidden input) or with --from-env.` }
|
|
2687
|
+
);
|
|
2688
|
+
}
|
|
2689
|
+
if (site.runtime.backend === "journal" && site.runtime.journal.encrypted && !site.credentials.passphrase) {
|
|
2690
|
+
add({ id: "credential-passphrase", status: "fail", message: "The dataset is encrypted and no passphrase account is attached.", fix: `\`agentcrm credentials set passphrase:${site.alias}\`, then \`agentcrm site credentials ${site.alias} --passphrase-credential passphrase:${site.alias}\`.` });
|
|
2691
|
+
}
|
|
2692
|
+
if (site.runtime.backend === "journal" && !site.runtime.journal.encrypted) {
|
|
2693
|
+
add({ id: "encryption", status: "warn", message: "The dataset is stored UNENCRYPTED: anyone who can read the storage reads every contact.", fix: "Keep the location strictly private; encryption can be enabled by exporting (`export json`) and re-importing into a new encrypted install." });
|
|
2694
|
+
}
|
|
2695
|
+
installBrowserEnv();
|
|
2696
|
+
try {
|
|
2697
|
+
if (site.runtime.backend === "journal" && site.runtime.journal.connectorId === "git-pages") {
|
|
2698
|
+
const { probeTarget } = await import("./chunks/install-CpHoUFtL.mjs");
|
|
2699
|
+
const { dataTargetOf: dataTargetOf2 } = await Promise.resolve().then(() => session);
|
|
2700
|
+
const probe = await probeTarget(dataTargetOf2(site), { requireSiteUrl: false, requirePrivate: true });
|
|
2701
|
+
add(probe.ok ? { id: "data-repo", status: "ok", message: probe.detail } : { id: "data-repo", status: "fail", message: probe.detail, fix: "Make the data repository private and check the token's Contents permission." });
|
|
2702
|
+
}
|
|
2703
|
+
const { openSession: openSession2 } = await Promise.resolve().then(() => session);
|
|
2704
|
+
const session$1 = await openSession2(site.alias);
|
|
2705
|
+
try {
|
|
2706
|
+
const store = session$1.store;
|
|
2707
|
+
add({ id: "data-access", status: "ok", message: `Dataset opened (${store.kind}${session$1.user ? `, acting as ${session$1.user.email} (${session$1.user.role})` : ""}).` });
|
|
2708
|
+
const { usersOf, objectsOf, pipelinesOf, settingsDoc, recordsOf } = await import("./chunks/store-5gqRSKXg.mjs");
|
|
2709
|
+
const missing = [];
|
|
2710
|
+
for (const id of ["companies", "people", "deals"]) if (!objectsOf(store).some((o) => o.id === id)) missing.push(`object ${id}`);
|
|
2711
|
+
if (!pipelinesOf(store).length) missing.push("a pipeline");
|
|
2712
|
+
if (!settingsDoc(store)) missing.push("settings");
|
|
2713
|
+
if (!usersOf(store).some((u) => u.role === "admin" && !u.disabled)) missing.push("an active admin user");
|
|
2714
|
+
if (missing.length && opts.repair) {
|
|
2715
|
+
const { seedCrm } = await import("./chunks/seed-CugPGYZL.mjs");
|
|
2716
|
+
repaired = await seedCrm(session$1.system, { admin: site.user ? { name: site.user.split("@")[0], email: site.user } : void 0 });
|
|
2717
|
+
add({ id: "dataset", status: "ok", message: `Repaired: seeded ${missing.join(", ")}.` });
|
|
2718
|
+
} else {
|
|
2719
|
+
add(
|
|
2720
|
+
missing.length ? { id: "dataset", status: "fail", message: `The dataset lacks ${missing.join(", ")}.`, fix: `\`agentcrm doctor --site ${site.alias} --repair\` seeds what's missing (idempotent, keeps existing data).` } : { id: "dataset", status: "ok", message: `${objectsOf(store).length} objects, ${recordsOf(store).length} records, ${usersOf(store).length} users.` }
|
|
2721
|
+
);
|
|
2722
|
+
}
|
|
2723
|
+
if (session$1.user && session$1.user.role !== "admin") {
|
|
2724
|
+
add({ id: "cli-user", status: "warn", message: `The CLI acts as ${session$1.user.email}, a ${session$1.user.role}: schema, settings and compaction will be refused.`, fix: `\`agentcrm site credentials ${site.alias} --user <admin email>\`.` });
|
|
2725
|
+
}
|
|
2726
|
+
const deleted = recordsOf(store, void 0, { includeDeleted: true }).filter((r) => r.deletedAt).length;
|
|
2727
|
+
if (deleted) add({ id: "purge", status: "warn", message: `${deleted} deleted record(s) are still stored (restorable) until compaction.`, fix: `When the user wants them erased for good (GDPR): \`agentcrm compact --site ${site.alias}\` (plan), then with --yes.` });
|
|
2728
|
+
const stats = store.stats;
|
|
2729
|
+
if (stats) {
|
|
2730
|
+
const s = await stats.call(store);
|
|
2731
|
+
add(
|
|
2732
|
+
s.pendingSegments > 300 ? { id: "journal", status: "warn", message: `${s.pendingSegments} journal segments since the last snapshot: loading gets slower.`, fix: `\`agentcrm compact --site ${site.alias} --yes\` (run it from one place).` } : { id: "journal", status: "ok", message: `Journal: ${s.segments} segment(s), ${s.pendingSegments} since the last snapshot.` }
|
|
2733
|
+
);
|
|
2734
|
+
}
|
|
2735
|
+
} finally {
|
|
2736
|
+
await session$1.close();
|
|
2737
|
+
}
|
|
2738
|
+
} catch (err) {
|
|
2739
|
+
const e = toCliError(err);
|
|
2740
|
+
add({ id: "data-access", status: "fail", message: e.message, fix: e.hint });
|
|
2741
|
+
}
|
|
2742
|
+
const adminUrl = site.adminUrl;
|
|
2743
|
+
let origin = null;
|
|
2744
|
+
if (!adminUrl) {
|
|
2745
|
+
add({ id: "admin", status: "skip", message: "Admin not hosted (use `agentcrm serve`)." });
|
|
2746
|
+
} else {
|
|
2747
|
+
origin = new URL(adminUrl).origin;
|
|
2748
|
+
add(
|
|
2749
|
+
adminUrl.startsWith("https://") || /^http:\/\/(localhost|127\.0\.0\.1)/.test(adminUrl) ? { id: "https", status: "ok", message: "Admin served over HTTPS (or localhost)." } : { id: "https", status: "fail", message: `${adminUrl} is plain HTTP: browsers refuse WebCrypto (signing, encryption) there.`, fix: "Serve the admin over HTTPS (CDN / custom domain with TLS, the bucket's https endpoint, GitHub Pages)." }
|
|
2750
|
+
);
|
|
2751
|
+
const index = await fetchText(`${adminUrl}index.html`);
|
|
2752
|
+
if (!index.ok) {
|
|
2753
|
+
add({ id: "admin", status: "fail", message: `Admin page not served (${index.error ?? `HTTP ${index.status}`}).`, fix: `\`agentcrm deploy-admin --site ${site.alias} --yes\` (Pages hosts take a minute after a commit).` });
|
|
2754
|
+
} else {
|
|
2755
|
+
add({ id: "admin", status: "ok", message: `Admin served at ${adminUrl}.` });
|
|
2756
|
+
const hosted = await fetchHostedAdmin(adminUrl);
|
|
2757
|
+
add(
|
|
2758
|
+
!hosted.version ? { id: "admin-version", status: "warn", message: "version.json missing next to the admin.", fix: `\`agentcrm deploy-admin --site ${site.alias} --yes\`.` } : compareVersions(hosted.version, info.version) >= 0 ? { id: "admin-version", status: "ok", message: `Admin ${hosted.version} is up to date.` } : { id: "admin-version", status: "warn", message: `Admin ${hosted.version} is older than ${info.version}.`, fix: `Offer \`agentcrm deploy-admin --site ${site.alias}\`.` }
|
|
2759
|
+
);
|
|
2760
|
+
const cfg = await fetchText(`${adminUrl}config.js`);
|
|
2761
|
+
try {
|
|
2762
|
+
if (!cfg.ok) throw new Error(cfg.error ?? `HTTP ${cfg.status}`);
|
|
2763
|
+
const runtime = parseConfigJs(cfg.text);
|
|
2764
|
+
const secrets = findSecretKeys(runtime);
|
|
2765
|
+
if (secrets.length) add({ id: "config-js", status: "fail", message: `config.js publishes secrets: ${secrets.join(", ")}.`, fix: "Revoke those keys NOW, then redeploy the admin (config.js is regenerated without secrets)." });
|
|
2766
|
+
else if (!runtime || runtime.backend !== site.runtime.backend) add({ id: "config-js", status: "fail", message: `config.js backend is ${String(runtime?.backend ?? "empty")}, expected ${site.runtime.backend}.`, fix: `\`agentcrm deploy-admin --site ${site.alias} --yes\`.` });
|
|
2767
|
+
else add({ id: "config-js", status: "ok", message: `config.js: ${site.runtime.backend}, no secrets.` });
|
|
2768
|
+
} catch (err) {
|
|
2769
|
+
add({ id: "config-js", status: "fail", message: `config.js unreadable: ${err.message}.`, fix: `\`agentcrm deploy-admin --site ${site.alias} --yes\`.` });
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
if (site.runtime.backend === "journal" && site.runtime.journal.connectorId === "s3") {
|
|
2774
|
+
const loc = site.runtime.journal.settings;
|
|
2775
|
+
add(await corsCheck("cors-data", "Data bucket", loc, origin));
|
|
2776
|
+
const hs = site.hosting?.connectorId === "s3" ? site.hosting.settings : null;
|
|
2777
|
+
if (hs && hs.bucket === loc.bucket && (hs.endpoint ?? "") === (loc.endpoint ?? "") && typeof hs.siteUrl === "string") {
|
|
2778
|
+
const rel = [String(loc.prefix ?? ""), site.runtime.journal.root].map((p) => p.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/");
|
|
2779
|
+
const stripped = String(hs.prefix ?? "").replace(/^\/+|\/+$/g, "");
|
|
2780
|
+
const path = stripped && rel.startsWith(`${stripped}/`) ? rel.slice(stripped.length + 1) : rel;
|
|
2781
|
+
const probe = await fetchText(`${hs.siteUrl.replace(/\/+$/, "")}/${path ? `${path}/` : ""}keyinfo.json`);
|
|
2782
|
+
if (probe.ok && /agentcrm-journal/.test(probe.text)) {
|
|
2783
|
+
add(
|
|
2784
|
+
site.runtime.journal.encrypted ? { id: "data-exposure", status: "warn", message: "The encrypted data files are downloadable from the public site.", fix: "Move the dataset to a separate private bucket (export json → new install → import)." } : { id: "data-exposure", status: "fail", message: "The UNENCRYPTED dataset is downloadable from the public site: every contact is exposed.", fix: "Make the files private now (bucket policy), then move the data to a private bucket with encryption." }
|
|
2785
|
+
);
|
|
2786
|
+
} else add({ id: "data-exposure", status: "ok", message: "The dataset is not served by the public site." });
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
if (site.hosting?.connectorId === "s3" && !(site.runtime.backend === "journal" && site.runtime.journal.connectorId === "s3" && site.runtime.journal.settings.bucket === site.hosting.settings.bucket)) {
|
|
2790
|
+
add({ id: "cors-hosting", status: "skip", message: "Hosting bucket: only read by browsers (no CORS needed unless forms post to it)." });
|
|
2791
|
+
}
|
|
2792
|
+
return { checks, repaired };
|
|
2793
|
+
}
|
|
2794
|
+
const doctorCommand = {
|
|
2795
|
+
name: "doctor",
|
|
2796
|
+
usage: "doctor [--site <alias>] [--repair]",
|
|
2797
|
+
summary: "Health check of a CRM: credentials, data access (passphrase), dataset health, journal size, pending purge, hosted admin (HTTPS, version, config.js without secrets), bucket CORS rules, public exposure of the data. Every problem has a `fix`. Exit 1 when a check fails.",
|
|
2798
|
+
options: [SITE_OPTION, { name: "repair", type: "boolean", description: "Seed what the dataset lacks (standard objects, pipeline, settings, admin) — idempotent." }],
|
|
2799
|
+
async run({ io, options }) {
|
|
2800
|
+
const site = resolveSite(strOpt(options, "site"));
|
|
2801
|
+
const { checks, repaired } = await runDoctor(site, { repair: options.repair === true });
|
|
2802
|
+
const failed = checks.filter((c) => c.status === "fail").length;
|
|
2803
|
+
const warned = checks.filter((c) => c.status === "warn").length;
|
|
2804
|
+
printResult(
|
|
2805
|
+
io,
|
|
2806
|
+
{ site: site.alias, ok: failed === 0, failed, warnings: warned, checks, ...repaired ? { repaired } : {} },
|
|
2807
|
+
() => [
|
|
2808
|
+
`${site.alias}: ${failed ? `${failed} problem(s)` : "healthy"}${warned ? `, ${warned} warning(s)` : ""}`,
|
|
2809
|
+
...checks.map((c) => ` [${c.status}] ${c.id}: ${c.message}${c.fix ? `
|
|
2810
|
+
fix: ${c.fix}` : ""}`)
|
|
2811
|
+
].join("\n")
|
|
2812
|
+
);
|
|
2813
|
+
return failed ? 1 : 0;
|
|
2814
|
+
}
|
|
2815
|
+
};
|
|
2816
|
+
function readPlan(source) {
|
|
2817
|
+
let text;
|
|
2818
|
+
try {
|
|
2819
|
+
text = source === "-" ? readFileSync(0, "utf8") : readFileSync(source, "utf8");
|
|
2820
|
+
} catch (err) {
|
|
2821
|
+
throw new CliError("invalid-plan", `Could not read the plan: ${err.message}`, void 0, 2);
|
|
2822
|
+
}
|
|
2823
|
+
try {
|
|
2824
|
+
return JSON.parse(text);
|
|
2825
|
+
} catch (err) {
|
|
2826
|
+
throw new CliError("invalid-plan", `The plan is not valid JSON: ${err.message}`, void 0, 2);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
const installCommand = {
|
|
2830
|
+
name: "install",
|
|
2831
|
+
usage: "install --plan <file|-> [--dry-run]",
|
|
2832
|
+
summary: "Install a CRM from a JSON plan (see `options`): checks access, creates the (encrypted) dataset on the chosen location, seeds the standard objects, pipeline, views and first admin (+ optional demo data), uploads the admin + config.js to the hosting, registers the site. --dry-run only checks.",
|
|
2833
|
+
options: [
|
|
2834
|
+
{ name: "plan", type: "string", description: "Plan file path, or - for stdin." },
|
|
2835
|
+
{ name: "dry-run", type: "boolean", description: "Validate the plan and test every access without writing anything." }
|
|
2836
|
+
],
|
|
2837
|
+
async run({ io, options }) {
|
|
2838
|
+
const planPath = strOpt(options, "plan");
|
|
2839
|
+
if (!planPath) usage("install needs --plan <file|->.", "Run `agentcrm options` for the format.");
|
|
2840
|
+
installBrowserEnv();
|
|
2841
|
+
const { resolvePlan } = await import("./chunks/plan-C0TBrU92.mjs");
|
|
2842
|
+
const plan = resolvePlan(readPlan(planPath));
|
|
2843
|
+
if (getSite(plan.alias)) throw new CliError("site-exists", `A site named "${plan.alias}" is already registered.`, "Use another alias, or `agentcrm site forget` it first.");
|
|
2844
|
+
const { checkPlan, runInstall } = await import("./chunks/install-CpHoUFtL.mjs");
|
|
2845
|
+
io.err("Checking access…");
|
|
2846
|
+
const checks = await checkPlan(plan);
|
|
2847
|
+
const summary = {
|
|
2848
|
+
alias: plan.alias,
|
|
2849
|
+
name: plan.name,
|
|
2850
|
+
backend: plan.backend,
|
|
2851
|
+
location: plan.dataType ?? null,
|
|
2852
|
+
hosting: plan.hosting?.type ?? null,
|
|
2853
|
+
adminUrl: plan.adminUrl,
|
|
2854
|
+
owner: plan.owner,
|
|
2855
|
+
credentials: plan.credentials,
|
|
2856
|
+
encrypted: plan.runtime.backend === "journal" ? plan.runtime.journal.encrypted : null,
|
|
2857
|
+
checks,
|
|
2858
|
+
warnings: plan.warnings
|
|
2859
|
+
};
|
|
2860
|
+
if (!checks.data.ok || checks.hosting && !checks.hosting.ok) {
|
|
2861
|
+
throw new CliError(
|
|
2862
|
+
"check-failed",
|
|
2863
|
+
[!checks.data.ok && `Data: ${checks.data.detail}`, checks.hosting && !checks.hosting.ok && `Hosting: ${checks.hosting.detail}`].filter(Boolean).join(" — "),
|
|
2864
|
+
"Fix the access (key / token permissions, bucket / repo names, URL, CORS) and retry the dry run."
|
|
2865
|
+
);
|
|
2866
|
+
}
|
|
2867
|
+
if (options["dry-run"] === true) {
|
|
2868
|
+
printResult(io, { ...summary, dryRun: true, existingData: checks.data.existing }, () => `Plan OK for ${plan.alias}: ${plan.backend}${plan.dataType ? ` on ${plan.dataType}` : ""} → ${plan.hosting?.type ?? "not hosted"}. Nothing was written.`);
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
const unlock = lockSite(plan.alias);
|
|
2872
|
+
try {
|
|
2873
|
+
const outcome = await runInstall(plan, (label) => io.err(`• ${label}`));
|
|
2874
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2875
|
+
putSite({
|
|
2876
|
+
alias: plan.alias,
|
|
2877
|
+
name: plan.name,
|
|
2878
|
+
runtime: plan.runtime,
|
|
2879
|
+
...plan.dataPath ? { dataPath: plan.dataPath } : {},
|
|
2880
|
+
hosting: plan.hosting,
|
|
2881
|
+
adminUrl: plan.adminUrl,
|
|
2882
|
+
credentials: plan.credentials,
|
|
2883
|
+
user: plan.owner.email,
|
|
2884
|
+
...outcome.adminVersion ? { adminVersion: outcome.adminVersion } : {},
|
|
2885
|
+
installedAt: now,
|
|
2886
|
+
updatedAt: now
|
|
2887
|
+
});
|
|
2888
|
+
const nextSteps = [];
|
|
2889
|
+
if (plan.adminUrl) nextSteps.push(`Open the admin: ${plan.adminUrl}`);
|
|
2890
|
+
else nextSteps.push("The admin was not uploaded: run `agentcrm serve` to use it on this computer.");
|
|
2891
|
+
if (plan.backend === "journal") {
|
|
2892
|
+
nextSteps.push(`Each new browser asks once for the data location keys${plan.passphraseAccount ? " and the passphrase" : ""} (they stay in that browser).`);
|
|
2893
|
+
} else if (plan.backend === "git-json") {
|
|
2894
|
+
nextSteps.push("Each user signs in to the admin with their own git token (Contents read/write on the data repository).");
|
|
2895
|
+
} else {
|
|
2896
|
+
nextSteps.push("Users sign in with their Firebase accounts; add them with `agentcrm users add` and create their logins in the Firebase console.");
|
|
2897
|
+
}
|
|
2898
|
+
for (const w of plan.warnings) nextSteps.push(`Warning: ${w}`);
|
|
2899
|
+
nextSteps.push(`Check everything with \`agentcrm doctor --site ${plan.alias}\`.`);
|
|
2900
|
+
const savePassphrase = plan.passphraseAccount ? {
|
|
2901
|
+
account: plan.passphraseAccount,
|
|
2902
|
+
generated: outcome.generatedPassphrase,
|
|
2903
|
+
why: "The CRM data is encrypted with this passphrase. It is stored only on this computer: without it no browser or computer can open the data, and nobody can recover it.",
|
|
2904
|
+
steps: [
|
|
2905
|
+
`\`agentcrm credentials copy ${plan.passphraseAccount}\` puts it on the clipboard (never print it).`,
|
|
2906
|
+
"The user pastes it into their password manager NOW (entry: the CRM admin URL) and confirms.",
|
|
2907
|
+
`The user runs \`agentcrm credentials verify ${plan.passphraseAccount}\` in their own terminal and pastes it FROM the password manager: it must say "verified".`
|
|
2908
|
+
]
|
|
2909
|
+
} : null;
|
|
2910
|
+
printResult(
|
|
2911
|
+
io,
|
|
2912
|
+
{ ...summary, installed: true, seed: outcome.seed, files: outcome.files, adminVersion: outcome.adminVersion, notices: outcome.notices, savePassphrase, nextSteps },
|
|
2913
|
+
() => [
|
|
2914
|
+
`Installed ${plan.alias}.`,
|
|
2915
|
+
...savePassphrase ? ["", "!!! ACTION REQUIRED — SAVE THE PASSPHRASE NOW !!!", savePassphrase.why, ...savePassphrase.steps.map((s, i) => ` ${i + 1}. ${s}`), ""] : [],
|
|
2916
|
+
...nextSteps.map((s) => `- ${s}`)
|
|
2917
|
+
].join("\n")
|
|
2918
|
+
);
|
|
2919
|
+
} finally {
|
|
2920
|
+
unlock();
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
};
|
|
2924
|
+
const DATA_BACKENDS = [
|
|
2925
|
+
{
|
|
2926
|
+
id: "journal",
|
|
2927
|
+
summary: "Encrypted files (snapshot + append-only journal) on storage you own: an S3-compatible bucket, a private GitHub / GitLab repo, or a folder of this computer.",
|
|
2928
|
+
goodFor: "Default. Personal data stays encrypted (AES-256-GCM) on your own storage; several users / computers work at the same time without conflicts.",
|
|
2929
|
+
cli: true,
|
|
2930
|
+
fields: [
|
|
2931
|
+
{ key: "location", required: true, description: "Where the files live: { type, settings, credential } — see `locations`." },
|
|
2932
|
+
{ key: "root", required: false, description: "Folder of the dataset on the location.", default: "agentcrm" },
|
|
2933
|
+
{
|
|
2934
|
+
key: "encryption",
|
|
2935
|
+
required: false,
|
|
2936
|
+
description: 'Default: encrypted with a passphrase generated at install and stored as passphrase:<alias>. { "credential": "passphrase:<name>" } uses an existing one; false stores plain JSON (not recommended: personal data).'
|
|
2937
|
+
}
|
|
2938
|
+
],
|
|
2939
|
+
credentialTypes: ["s3", "github", "gitlab", "passphrase"],
|
|
2940
|
+
notes: [
|
|
2941
|
+
"Losing the passphrase means losing the data: right after install the user saves it in a password manager (`agentcrm credentials copy passphrase:<alias>`).",
|
|
2942
|
+
"No login screen: anyone with the storage keys and the passphrase can open the CRM; each browser asks for them once.",
|
|
2943
|
+
"`agentcrm compact` rewrites the snapshot and physically purges deleted records (GDPR erasure)."
|
|
2944
|
+
]
|
|
2945
|
+
},
|
|
2946
|
+
{
|
|
2947
|
+
id: "git-json",
|
|
2948
|
+
summary: "One JSON file per record in a PRIVATE GitHub / GitLab repository; every change is a commit.",
|
|
2949
|
+
goodFor: "Small teams who want a readable history; each user signs in with their own token (repo permission = role).",
|
|
2950
|
+
cli: true,
|
|
2951
|
+
fields: [
|
|
2952
|
+
{ key: "git.provider", required: true, description: "github | gitlab", example: "github" },
|
|
2953
|
+
{ key: "git.owner", required: false, description: "GitHub user or organisation (github).", example: "acme" },
|
|
2954
|
+
{ key: "git.repo", required: false, description: "GitHub repository (github), private.", example: "acme-crm-data" },
|
|
2955
|
+
{ key: "git.project", required: false, description: "GitLab project path (gitlab).", example: "acme/crm-data" },
|
|
2956
|
+
{ key: "git.branch", required: false, description: "Branch.", default: "main" },
|
|
2957
|
+
{ key: "git.dir", required: false, description: 'Folder inside the repo ("" = root).', default: "" },
|
|
2958
|
+
{ key: "credential", required: true, description: "github:<name> / gitlab:<name> token account." }
|
|
2959
|
+
],
|
|
2960
|
+
credentialTypes: ["github", "gitlab"],
|
|
2961
|
+
notes: ["Git history keeps every past value: deleting a record does not erase it from the history (warn the user about personal data)."]
|
|
2962
|
+
},
|
|
2963
|
+
{
|
|
2964
|
+
id: "firebase",
|
|
2965
|
+
summary: "Firestore + Firebase Auth in the user's own Firebase project.",
|
|
2966
|
+
goodFor: "Real-time teams with email / Google logins and per-user roles enforced by Firestore rules.",
|
|
2967
|
+
cli: true,
|
|
2968
|
+
fields: [
|
|
2969
|
+
{ key: "firebase", required: true, description: "The web app config (apiKey, authDomain, projectId, appId…) from Firebase console → Project settings → Your apps." },
|
|
2970
|
+
{ key: "credential", required: true, description: "firebase:<name> — email + password of the first admin (Authentication → Users)." },
|
|
2971
|
+
{ key: "signInMethods", required: false, description: 'Login buttons: ["password"] (default) and/or "google".' }
|
|
2972
|
+
],
|
|
2973
|
+
credentialTypes: ["firebase"],
|
|
2974
|
+
notes: ["Publish the security rules first (see docs/firebase.md)."]
|
|
2975
|
+
},
|
|
2976
|
+
{
|
|
2977
|
+
id: "local",
|
|
2978
|
+
summary: "This browser only (IndexedDB), single user, no login.",
|
|
2979
|
+
goodFor: "Trying AgentCRM. Not reachable from the CLI.",
|
|
2980
|
+
cli: false,
|
|
2981
|
+
fields: [],
|
|
2982
|
+
credentialTypes: [],
|
|
2983
|
+
notes: ["Use `agentcrm serve` and pick “This browser” in the setup wizard."]
|
|
2984
|
+
}
|
|
2985
|
+
];
|
|
2986
|
+
const LOCATIONS = [
|
|
2987
|
+
{
|
|
2988
|
+
id: "s3",
|
|
2989
|
+
connectorId: "s3",
|
|
2990
|
+
summary: "Any S3-compatible bucket (AWS S3, Cloudflare R2, OVHcloud, Scaleway, Backblaze B2, MinIO). Use a PRIVATE bucket.",
|
|
2991
|
+
fields: [
|
|
2992
|
+
{ key: "endpoint", required: false, description: "S3 API endpoint (omit for AWS).", example: "https://s3.gra.io.cloud.ovh.net" },
|
|
2993
|
+
{ key: "region", required: true, description: "Region.", example: "eu-west-3" },
|
|
2994
|
+
{ key: "bucket", required: true, description: "Bucket name.", example: "acme-crm-data" },
|
|
2995
|
+
{ key: "prefix", required: false, description: "Key prefix inside the bucket.", default: "" },
|
|
2996
|
+
{ key: "forcePathStyle", required: false, description: "Path-style URLs (default: on outside AWS)." }
|
|
2997
|
+
],
|
|
2998
|
+
credentialTypes: ["s3"],
|
|
2999
|
+
notes: ["The bucket needs a CORS rule for the admin's origin (GET, PUT, DELETE, HEAD; headers authorization, content-type, x-amz-*): `agentcrm doctor` prints it."]
|
|
3000
|
+
},
|
|
3001
|
+
{
|
|
3002
|
+
id: "github",
|
|
3003
|
+
connectorId: "git-pages",
|
|
3004
|
+
summary: "A PRIVATE GitHub repository (files committed with [skip ci], never published).",
|
|
3005
|
+
fields: [
|
|
3006
|
+
{ key: "owner", required: true, description: "User or organisation.", example: "acme" },
|
|
3007
|
+
{ key: "repo", required: true, description: "Private repository.", example: "acme-crm-data" },
|
|
3008
|
+
{ key: "branch", required: false, description: "Branch.", default: "main" },
|
|
3009
|
+
{ key: "apiBaseUrl", required: false, description: "GitHub Enterprise API URL." }
|
|
3010
|
+
],
|
|
3011
|
+
credentialTypes: ["github"],
|
|
3012
|
+
notes: ["One commit per write: fine for small teams; a bucket is faster."]
|
|
3013
|
+
},
|
|
3014
|
+
{
|
|
3015
|
+
id: "gitlab",
|
|
3016
|
+
connectorId: "git-pages",
|
|
3017
|
+
summary: "A PRIVATE GitLab project.",
|
|
3018
|
+
fields: [
|
|
3019
|
+
{ key: "project", required: true, description: "Project path.", example: "acme/crm-data" },
|
|
3020
|
+
{ key: "branch", required: false, description: "Branch.", default: "main" },
|
|
3021
|
+
{ key: "apiBaseUrl", required: false, description: "Self-hosted GitLab API URL." }
|
|
3022
|
+
],
|
|
3023
|
+
credentialTypes: ["gitlab"],
|
|
3024
|
+
notes: []
|
|
3025
|
+
},
|
|
3026
|
+
{
|
|
3027
|
+
id: "local-folder",
|
|
3028
|
+
connectorId: "local-folder",
|
|
3029
|
+
summary: "A folder on this computer (absolute path). The browser admin opens the same folder with its folder picker (Chrome / Edge).",
|
|
3030
|
+
fields: [{ key: "path", required: true, description: "Absolute folder path (created if missing).", example: "/Users/me/Documents/acme-crm" }],
|
|
3031
|
+
credentialTypes: [],
|
|
3032
|
+
notes: ["Back the folder up (or put it in a synced drive): it is the only copy."]
|
|
3033
|
+
}
|
|
3034
|
+
];
|
|
3035
|
+
const HOSTINGS = [
|
|
3036
|
+
{
|
|
3037
|
+
id: "s3",
|
|
3038
|
+
connectorId: "s3",
|
|
3039
|
+
summary: "An S3-compatible bucket with static website hosting (or a CDN in front).",
|
|
3040
|
+
cli: true,
|
|
3041
|
+
fields: [
|
|
3042
|
+
{ key: "endpoint", required: false, description: "S3 API endpoint (omit for AWS)." },
|
|
3043
|
+
{ key: "region", required: true, description: "Region." },
|
|
3044
|
+
{ key: "bucket", required: true, description: "Bucket serving the admin." },
|
|
3045
|
+
{ key: "prefix", required: false, description: "Key prefix.", default: "" },
|
|
3046
|
+
{ key: "siteUrl", required: true, description: "Public HTTPS URL of the bucket / CDN root.", example: "https://crm.acme.com" },
|
|
3047
|
+
{ key: "publicRead", required: false, description: "Send x-amz-acl: public-read (OVH / Scaleway buckets without a bucket policy)." }
|
|
3048
|
+
],
|
|
3049
|
+
credentialTypes: ["s3"],
|
|
3050
|
+
notes: ["Use HTTPS: the admin signs storage requests with WebCrypto, which browsers only allow on secure pages."]
|
|
3051
|
+
},
|
|
3052
|
+
{
|
|
3053
|
+
id: "github-pages",
|
|
3054
|
+
connectorId: "git-pages",
|
|
3055
|
+
summary: "GitHub Pages (a public repo on free plans).",
|
|
3056
|
+
cli: true,
|
|
3057
|
+
fields: [
|
|
3058
|
+
{ key: "owner", required: true, description: "User or organisation." },
|
|
3059
|
+
{ key: "repo", required: true, description: "Repository served by Pages." },
|
|
3060
|
+
{ key: "branch", required: false, description: "Branch.", default: "main" },
|
|
3061
|
+
{ key: "dir", required: false, description: 'Folder served ("" or docs).', default: "" },
|
|
3062
|
+
{ key: "siteUrl", required: false, description: "Pages URL.", default: "https://<owner>.github.io/<repo>" }
|
|
3063
|
+
],
|
|
3064
|
+
credentialTypes: ["github"],
|
|
3065
|
+
notes: ["The admin holds no data: a public repo only exposes the app code and config.js (no secrets)."]
|
|
3066
|
+
},
|
|
3067
|
+
{
|
|
3068
|
+
id: "gitlab-pages",
|
|
3069
|
+
connectorId: "git-pages",
|
|
3070
|
+
summary: "GitLab Pages (a .gitlab-ci.yml is added when missing).",
|
|
3071
|
+
cli: true,
|
|
3072
|
+
fields: [
|
|
3073
|
+
{ key: "project", required: true, description: "Project path." },
|
|
3074
|
+
{ key: "branch", required: false, description: "Branch.", default: "main" },
|
|
3075
|
+
{ key: "dir", required: false, description: "Folder published.", default: "public" },
|
|
3076
|
+
{ key: "siteUrl", required: true, description: "Pages URL." }
|
|
3077
|
+
],
|
|
3078
|
+
credentialTypes: ["gitlab"],
|
|
3079
|
+
notes: []
|
|
3080
|
+
},
|
|
3081
|
+
{
|
|
3082
|
+
id: "cloudflare-pages",
|
|
3083
|
+
connectorId: "git-pages",
|
|
3084
|
+
summary: "Cloudflare Pages connected to a GitHub / GitLab repo (works with private repos on the free plan).",
|
|
3085
|
+
cli: true,
|
|
3086
|
+
fields: [
|
|
3087
|
+
{ key: "provider", required: false, description: "github | gitlab", default: "github" },
|
|
3088
|
+
{ key: "owner", required: false, description: "GitHub owner." },
|
|
3089
|
+
{ key: "repo", required: false, description: "GitHub repo." },
|
|
3090
|
+
{ key: "project", required: false, description: "GitLab project." },
|
|
3091
|
+
{ key: "branch", required: false, description: "Branch.", default: "main" },
|
|
3092
|
+
{ key: "siteUrl", required: true, description: "The *.pages.dev or custom domain URL." }
|
|
3093
|
+
],
|
|
3094
|
+
credentialTypes: ["github", "gitlab"],
|
|
3095
|
+
notes: ["Create the Pages project in the Cloudflare dashboard (no build command, output directory = the repo root)."]
|
|
3096
|
+
},
|
|
3097
|
+
{
|
|
3098
|
+
id: "local-folder",
|
|
3099
|
+
connectorId: "local-folder",
|
|
3100
|
+
summary: "A folder on this computer (serve it with `agentcrm serve` or any static server).",
|
|
3101
|
+
cli: true,
|
|
3102
|
+
fields: [
|
|
3103
|
+
{ key: "path", required: true, description: "Absolute folder path." },
|
|
3104
|
+
{ key: "siteUrl", required: false, description: "URL the folder is served at, if any.", example: "http://localhost:4173" }
|
|
3105
|
+
],
|
|
3106
|
+
credentialTypes: [],
|
|
3107
|
+
notes: []
|
|
3108
|
+
},
|
|
3109
|
+
{
|
|
3110
|
+
id: "zip",
|
|
3111
|
+
connectorId: "browser-mirror",
|
|
3112
|
+
summary: "Download the admin as a ZIP and upload it anywhere by hand.",
|
|
3113
|
+
cli: false,
|
|
3114
|
+
fields: [],
|
|
3115
|
+
credentialTypes: [],
|
|
3116
|
+
notes: ["Setup wizard only (`agentcrm serve`). From the CLI, use `hosting: false` and copy dist/admin yourself."]
|
|
3117
|
+
}
|
|
3118
|
+
];
|
|
3119
|
+
function findBackend(id) {
|
|
3120
|
+
return DATA_BACKENDS.find((b) => b.id === id);
|
|
3121
|
+
}
|
|
3122
|
+
function findLocation(id) {
|
|
3123
|
+
return LOCATIONS.find((l) => l.id === id);
|
|
3124
|
+
}
|
|
3125
|
+
function findHosting(id) {
|
|
3126
|
+
return HOSTINGS.find((h) => h.id === id);
|
|
3127
|
+
}
|
|
3128
|
+
const versionCommand = {
|
|
3129
|
+
name: "version",
|
|
3130
|
+
usage: "version",
|
|
3131
|
+
summary: "Print the AgentCRM version (CLI, minimum skills version, data schema).",
|
|
3132
|
+
options: [],
|
|
3133
|
+
async run({ io }) {
|
|
3134
|
+
const info = readVersionInfo();
|
|
3135
|
+
printResult(io, { ...info, bundled: isBundled(), node: process.versions.node }, () => info.version);
|
|
3136
|
+
}
|
|
3137
|
+
};
|
|
3138
|
+
async function siteStatus(site, version2, dataSchema) {
|
|
3139
|
+
const base = { alias: site.alias, adminUrl: site.adminUrl };
|
|
3140
|
+
if (!site.adminUrl) return { ...base, adminVersion: null, state: "not-hosted" };
|
|
3141
|
+
const hosted = await fetchHostedAdmin(site.adminUrl);
|
|
3142
|
+
if (!hosted.version) return { ...base, adminVersion: null, state: "unreachable", error: hosted.error };
|
|
3143
|
+
if (hosted.version !== site.adminVersion) putSite({ ...site, adminVersion: hosted.version });
|
|
3144
|
+
const cmp = compareVersions(hosted.version, version2);
|
|
3145
|
+
if (cmp === 0) return { ...base, adminVersion: hosted.version, state: "current" };
|
|
3146
|
+
if (cmp > 0) return { ...base, adminVersion: hosted.version, state: "newer" };
|
|
3147
|
+
return {
|
|
3148
|
+
...base,
|
|
3149
|
+
adminVersion: hosted.version,
|
|
3150
|
+
state: "update",
|
|
3151
|
+
update: changesBetween(readChangelog(), hosted.version, version2),
|
|
3152
|
+
blockedByDataSchema: hosted.dataSchema !== null && hosted.dataSchema > dataSchema
|
|
3153
|
+
};
|
|
3154
|
+
}
|
|
3155
|
+
const statusCommand = {
|
|
3156
|
+
name: "status",
|
|
3157
|
+
usage: "status [--site <alias>]",
|
|
3158
|
+
summary: "Versions overview — run it first: this tool (= the skills) vs the latest npm release, and every registered CRM's hosted admin vs this release.",
|
|
3159
|
+
options: [{ name: "site", type: "string", description: "Only this site (default: every registered site)." }],
|
|
3160
|
+
async run({ io, options }) {
|
|
3161
|
+
const info = readVersionInfo();
|
|
3162
|
+
const alias = strOpt(options, "site");
|
|
3163
|
+
const sites = alias ? [resolveSite(alias)] : listSites().sites;
|
|
3164
|
+
const [latest, statuses] = await Promise.all([fetchLatestVersion(PACKAGE_NAME), Promise.all(sites.map((s) => siteStatus(s, info.version, info.dataSchema)))]);
|
|
3165
|
+
const updateAvailable = latest ? compareVersions(latest, info.version) > 0 : null;
|
|
3166
|
+
const data = {
|
|
3167
|
+
tool: { version: info.version, latest, updateAvailable, minSkillsVersion: info.minSkillsVersion, dataSchema: info.dataSchema },
|
|
3168
|
+
sites: statuses,
|
|
3169
|
+
advice: [
|
|
3170
|
+
...updateAvailable ? [`agentcrm ${latest} is available (this is ${info.version}): offer to update the skills first (Claude Code: /plugin marketplace update agentcrm, then /reload-plugins).`] : [],
|
|
3171
|
+
...statuses.filter((s) => s.state === "update").map(
|
|
3172
|
+
(s) => s.blockedByDataSchema ? `${s.alias}: the hosted admin stores data in a newer format than this tool — update the tool, never downgrade.` : `${s.alias}: admin ${s.adminVersion} → ${info.version} available${s.update?.breaking ? " (BREAKING — read the changes)" : ""}: offer \`agentcrm deploy-admin --site ${s.alias}\`.`
|
|
3173
|
+
),
|
|
3174
|
+
...statuses.filter((s) => s.state === "newer").map((s) => `${s.alias}: hosted admin ${s.adminVersion} is newer than this tool — update the tool before deploying.`),
|
|
3175
|
+
...statuses.filter((s) => s.state === "unreachable").map((s) => `${s.alias}: admin not reachable (${s.error}) — run \`agentcrm doctor --site ${s.alias}\`.`)
|
|
3176
|
+
]
|
|
3177
|
+
};
|
|
3178
|
+
printResult(
|
|
3179
|
+
io,
|
|
3180
|
+
data,
|
|
3181
|
+
() => [
|
|
3182
|
+
`agentcrm ${info.version}${updateAvailable ? ` — ${latest} available` : latest ? " (latest)" : ""}`,
|
|
3183
|
+
...statuses.map((s) => ` ${s.alias.padEnd(20)} admin ${s.adminVersion ?? "-"} ${s.state}${s.error ? ` (${s.error})` : ""}`),
|
|
3184
|
+
...data.advice.length ? ["", ...data.advice.map((a) => `- ${a}`)] : []
|
|
3185
|
+
].join("\n")
|
|
3186
|
+
);
|
|
3187
|
+
}
|
|
3188
|
+
};
|
|
3189
|
+
const optionsCommand = {
|
|
3190
|
+
name: "options",
|
|
3191
|
+
usage: "options",
|
|
3192
|
+
summary: "Data backends, data locations, admin hostings, credential types and the install plan format — ask the user about the data first, then the hosting. --firebase-rules <admin email> prints the Firestore security rules to publish.",
|
|
3193
|
+
options: [{ name: "firebase-rules", type: "string", description: "Print the Firestore rules for a Firebase CRM with this email as bootstrap admin." }],
|
|
3194
|
+
async run({ io, options }) {
|
|
3195
|
+
const rulesEmail = strOpt(options, "firebase-rules");
|
|
3196
|
+
if (rulesEmail) {
|
|
3197
|
+
const { buildFirestoreRules } = await import("./chunks/firebaseRules-iori-s7U.mjs");
|
|
3198
|
+
const rules = buildFirestoreRules({ adminEmail: rulesEmail });
|
|
3199
|
+
printResult(io, { email: rulesEmail, rules, where: "Firebase console → Firestore Database → Rules → paste → Publish (or `firebase deploy --only firestore:rules`)." }, () => rules);
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
3202
|
+
const { PLAN_EXAMPLE } = await import("./chunks/plan-C0TBrU92.mjs");
|
|
3203
|
+
const data = {
|
|
3204
|
+
order: ["data backend", "data location", "admin hosting"],
|
|
3205
|
+
backends: DATA_BACKENDS,
|
|
3206
|
+
locations: LOCATIONS,
|
|
3207
|
+
hostings: HOSTINGS,
|
|
3208
|
+
credentialTypes: CREDENTIAL_TYPES,
|
|
3209
|
+
planFormat: {
|
|
3210
|
+
alias: "lower-case-alias (required)",
|
|
3211
|
+
name: "Display name of the CRM",
|
|
3212
|
+
owner: "{ name, email } — the first admin; the CLI acts as this user",
|
|
3213
|
+
data: '{ backend: "journal", location: { type, settings, credential }, root?, encryption? } | { backend: "git-json", git: {…}, credential } | { backend: "firebase", firebase: {…}, credential }',
|
|
3214
|
+
hosting: "{ type, settings, credential?, folder? } | false",
|
|
3215
|
+
demo: "true seeds ~30 companies, 80 people, 40 deals and their activities",
|
|
3216
|
+
currency: "ISO code, default EUR / USD from settings",
|
|
3217
|
+
locale: "BCP-47, e.g. en-US / fr-FR"
|
|
3218
|
+
},
|
|
3219
|
+
planExample: PLAN_EXAMPLE,
|
|
3220
|
+
planNotes: [
|
|
3221
|
+
"Secrets never go in the plan: store them first with `agentcrm credentials set <type>:<name>` (the user types them, or --from-env) and reference the account id.",
|
|
3222
|
+
"Journal data is encrypted by default: a passphrase is generated at install and stored as passphrase:<alias> on this computer — the user must save it in a password manager.",
|
|
3223
|
+
"hosting.credential may be omitted for git hostings on the same provider as the data credential.",
|
|
3224
|
+
"hosting: false skips uploading the admin (run `agentcrm serve` to use it locally).",
|
|
3225
|
+
"Run `agentcrm install --plan plan.json --dry-run` first: it checks every access without writing."
|
|
3226
|
+
]
|
|
3227
|
+
};
|
|
3228
|
+
printResult(
|
|
3229
|
+
io,
|
|
3230
|
+
data,
|
|
3231
|
+
() => [
|
|
3232
|
+
"1. Data backend:",
|
|
3233
|
+
...DATA_BACKENDS.map((d) => ` ${d.id.padEnd(10)} ${d.summary}${d.cli ? "" : " (browser only)"}`),
|
|
3234
|
+
"",
|
|
3235
|
+
"2. Data location (journal):",
|
|
3236
|
+
...LOCATIONS.map((l) => ` ${l.id.padEnd(13)} ${l.summary}`),
|
|
3237
|
+
"",
|
|
3238
|
+
"3. Admin hosting:",
|
|
3239
|
+
...HOSTINGS.map((h) => ` ${h.id.padEnd(17)} ${h.summary}${h.cli ? "" : " (wizard only)"}`),
|
|
3240
|
+
"",
|
|
3241
|
+
"Plan example (agentcrm install --plan plan.json):",
|
|
3242
|
+
JSON.stringify(PLAN_EXAMPLE, null, 2)
|
|
3243
|
+
].join("\n")
|
|
3244
|
+
);
|
|
3245
|
+
}
|
|
3246
|
+
};
|
|
3247
|
+
const objectsCommand = {
|
|
3248
|
+
name: "objects",
|
|
3249
|
+
usage: "objects <list|get|create|update|delete> …",
|
|
3250
|
+
summary: "The data model: list objects, describe one (fields, types, select options, relations, pipelines), create custom objects, rename / recolor, delete a custom object (admin).",
|
|
3251
|
+
subcommands: [
|
|
3252
|
+
{ name: "list", usage: "objects list", summary: "Objects with record counts." },
|
|
3253
|
+
{ name: "get", usage: "objects get <id>", summary: "Fields (ids, types, options, relations) and pipelines of one object — read it before writing records." },
|
|
3254
|
+
{ name: "create", usage: "objects create --singular <label> --plural <label> [--id <slug>] [--icon <name>] [--color <c>] [--data <json>]", summary: "Create a custom object (with a name field; add fields with `fields add`).", writes: true },
|
|
3255
|
+
{ name: "update", usage: "objects update <id> [--singular …] [--plural …] [--icon …] [--color …] [--label-field <fieldId>]", summary: "Rename, change icon / color / label field.", writes: true },
|
|
3256
|
+
{ name: "delete", usage: "objects delete <id> [--yes]", summary: "Delete a custom object, ALL its records, its views and relation fields pointing at it. Plan unless --yes.", writes: true, destructive: true }
|
|
3257
|
+
],
|
|
3258
|
+
options: [
|
|
3259
|
+
SITE_OPTION,
|
|
3260
|
+
AS_OPTION,
|
|
3261
|
+
YES_OPTION,
|
|
3262
|
+
{ name: "singular", type: "string", description: "Singular label." },
|
|
3263
|
+
{ name: "plural", type: "string", description: "Plural label." },
|
|
3264
|
+
{ name: "id", type: "string", description: "create: object id (slug; default from the plural label)." },
|
|
3265
|
+
{ name: "icon", type: "string", description: "Icon name (lucide)." },
|
|
3266
|
+
{ name: "color", type: "string", description: "Color name." },
|
|
3267
|
+
{ name: "label-field", type: "string", description: "update: field used as the record label." },
|
|
3268
|
+
{ name: "data", type: "string", description: "create: full ObjectInput JSON (with fields) or @file." }
|
|
3269
|
+
],
|
|
3270
|
+
async run({ io, options, positionals }) {
|
|
3271
|
+
const [action = "list", id] = positionals;
|
|
3272
|
+
switch (action) {
|
|
3273
|
+
case "list":
|
|
3274
|
+
return withSession(io, options, {}, async ({ ctx, store }) => {
|
|
3275
|
+
const { listObjects } = await import("./chunks/schema-DlHt2PxI.mjs");
|
|
3276
|
+
const { recordsOf } = await import("./chunks/store-5gqRSKXg.mjs");
|
|
3277
|
+
return listObjects(ctx).map((o) => ({ id: o.id, labelSingular: o.labelSingular, labelPlural: o.labelPlural, standard: o.standard, labelField: o.labelField, fields: o.fields.length, records: recordsOf(store, o.id).length }));
|
|
3278
|
+
}, (rows) => table(rows, ["id", "labelPlural", "fields", "records", "standard"]));
|
|
3279
|
+
case "get": {
|
|
3280
|
+
const objectId = positional(positionals, 1, "object id", "objects get <id>");
|
|
3281
|
+
return withSession(io, options, {}, async ({ ctx }) => (await import("./chunks/schema-DlHt2PxI.mjs")).describeSchema(ctx, objectId));
|
|
3282
|
+
}
|
|
3283
|
+
case "create": {
|
|
3284
|
+
const data = jsonObjectOpt(options, "data") ?? {};
|
|
3285
|
+
const input = {
|
|
3286
|
+
...data,
|
|
3287
|
+
...strOpt(options, "id") ? { id: strOpt(options, "id") } : {},
|
|
3288
|
+
labelSingular: strOpt(options, "singular") ?? data.labelSingular,
|
|
3289
|
+
labelPlural: strOpt(options, "plural") ?? data.labelPlural,
|
|
3290
|
+
...strOpt(options, "icon") ? { icon: strOpt(options, "icon") } : {},
|
|
3291
|
+
...strOpt(options, "color") ? { color: strOpt(options, "color") } : {}
|
|
3292
|
+
};
|
|
3293
|
+
if (!input.labelSingular || !input.labelPlural) usage("objects create needs --singular and --plural (or --data).");
|
|
3294
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => (await import("./chunks/schema-DlHt2PxI.mjs")).createObject(ctx, input));
|
|
3295
|
+
}
|
|
3296
|
+
case "update": {
|
|
3297
|
+
const objectId = positional(positionals, 1, "object id", "objects update <id> …");
|
|
3298
|
+
const patch = {
|
|
3299
|
+
...strOpt(options, "singular") ? { labelSingular: strOpt(options, "singular") } : {},
|
|
3300
|
+
...strOpt(options, "plural") ? { labelPlural: strOpt(options, "plural") } : {},
|
|
3301
|
+
...strOpt(options, "icon") ? { icon: strOpt(options, "icon") } : {},
|
|
3302
|
+
...strOpt(options, "color") ? { color: strOpt(options, "color") } : {},
|
|
3303
|
+
...strOpt(options, "label-field") ? { labelField: strOpt(options, "label-field") } : {}
|
|
3304
|
+
};
|
|
3305
|
+
if (!Object.keys(patch).length) usage("objects update needs --singular, --plural, --icon, --color or --label-field.");
|
|
3306
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => (await import("./chunks/schema-DlHt2PxI.mjs")).updateObject(ctx, objectId, patch));
|
|
3307
|
+
}
|
|
3308
|
+
case "delete": {
|
|
3309
|
+
if (!id) usage("objects delete needs an object id.");
|
|
3310
|
+
return withSession(io, options, { lock: options.yes === true }, async ({ ctx, store }) => {
|
|
3311
|
+
const { getObject, deleteObject } = await import("./chunks/schema-DlHt2PxI.mjs");
|
|
3312
|
+
const { recordsOf } = await import("./chunks/store-5gqRSKXg.mjs");
|
|
3313
|
+
const o = getObject(ctx, id);
|
|
3314
|
+
const plan = { object: o.id, label: o.labelPlural, standard: o.standard, recordsDeleted: recordsOf(store, o.id, { includeDeleted: true }).length };
|
|
3315
|
+
if (options.yes !== true) return { needsConfirmation: true, plan };
|
|
3316
|
+
return deleteObject(ctx, id);
|
|
3317
|
+
});
|
|
3318
|
+
}
|
|
3319
|
+
default:
|
|
3320
|
+
usage(`Unknown objects action "${action}".`, "Use list, get, create, update or delete.");
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
};
|
|
3324
|
+
const fieldsCommand = {
|
|
3325
|
+
name: "fields",
|
|
3326
|
+
usage: "fields <add|update|delete> --object <id> …",
|
|
3327
|
+
summary: "Custom fields: add (any type, select options, relations), update (label, options, required, type change with data migration), delete (values cleared). Admin only.",
|
|
3328
|
+
subcommands: [
|
|
3329
|
+
{ name: "add", usage: `fields add --object <id> --label <label> --type <${FIELD_TYPES.join("|")}> [--options a,b,c] [--relation-object <id> --cardinality one|many] [--required] [--unique] [--description …] [--id <fieldId>]`, summary: "Add a field.", writes: true },
|
|
3330
|
+
{ name: "update", usage: "fields update <fieldId> --object <id> [--label …] [--options a,b] [--type <t>] [--required|--optional] [--data <json>]", summary: "Change a field (type changes migrate existing values).", writes: true },
|
|
3331
|
+
{ name: "delete", usage: "fields delete <fieldId> --object <id> [--yes]", summary: "Delete a custom field and clear its values. Plan unless --yes.", writes: true, destructive: true }
|
|
3332
|
+
],
|
|
3333
|
+
options: [
|
|
3334
|
+
SITE_OPTION,
|
|
3335
|
+
AS_OPTION,
|
|
3336
|
+
YES_OPTION,
|
|
3337
|
+
{ name: "object", type: "string", description: "Object id." },
|
|
3338
|
+
{ name: "label", type: "string", description: "Field label." },
|
|
3339
|
+
{ name: "type", type: "string", description: `Field type: ${FIELD_TYPES.join(", ")}.` },
|
|
3340
|
+
{ name: "id", type: "string", description: "add: field id (default: camelCase of the label)." },
|
|
3341
|
+
{ name: "options", type: "string", description: "select / multiSelect option labels, comma-separated." },
|
|
3342
|
+
{ name: "relation-object", type: "string", description: "relation: target object id." },
|
|
3343
|
+
{ name: "cardinality", type: "string", description: "relation: one (default) or many." },
|
|
3344
|
+
{ name: "required", type: "boolean", description: "Value required." },
|
|
3345
|
+
{ name: "optional", type: "boolean", description: "update: no longer required." },
|
|
3346
|
+
{ name: "unique", type: "boolean", description: "Values must be unique." },
|
|
3347
|
+
{ name: "description", type: "string", description: "Help text." },
|
|
3348
|
+
{ name: "data", type: "string", description: "update: raw FieldPatch JSON." }
|
|
3349
|
+
],
|
|
3350
|
+
async run({ io, options, positionals }) {
|
|
3351
|
+
const [action, fieldId] = positionals;
|
|
3352
|
+
const objectId = requireStr(options, "object", "Objects: `agentcrm objects list`.");
|
|
3353
|
+
switch (action) {
|
|
3354
|
+
case "add": {
|
|
3355
|
+
const type2 = requireStr(options, "type");
|
|
3356
|
+
if (!FIELD_TYPES.includes(type2)) usage(`Unknown field type "${type2}".`, `Types: ${FIELD_TYPES.join(", ")}.`);
|
|
3357
|
+
const rel = strOpt(options, "relation-object");
|
|
3358
|
+
if (type2 === "relation" && !rel) usage("relation fields need --relation-object <id>.");
|
|
3359
|
+
const input = {
|
|
3360
|
+
label: requireStr(options, "label"),
|
|
3361
|
+
type: type2,
|
|
3362
|
+
...strOpt(options, "id") ? { id: strOpt(options, "id") } : {},
|
|
3363
|
+
...listOpt(options, "options") ? { options: listOpt(options, "options") } : {},
|
|
3364
|
+
...rel ? { relation: { object: rel, cardinality: strOpt(options, "cardinality") === "many" ? "many" : "one" } } : {},
|
|
3365
|
+
...boolOpt(options, "required") ? { required: true } : {},
|
|
3366
|
+
...boolOpt(options, "unique") ? { unique: true } : {},
|
|
3367
|
+
...strOpt(options, "description") ? { description: strOpt(options, "description") } : {}
|
|
3368
|
+
};
|
|
3369
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => (await import("./chunks/schema-DlHt2PxI.mjs")).createField(ctx, objectId, input));
|
|
3370
|
+
}
|
|
3371
|
+
case "update": {
|
|
3372
|
+
if (!fieldId) usage("fields update needs a field id.");
|
|
3373
|
+
const type2 = strOpt(options, "type");
|
|
3374
|
+
if (type2 && !FIELD_TYPES.includes(type2)) usage(`Unknown field type "${type2}".`);
|
|
3375
|
+
const patch = {
|
|
3376
|
+
...jsonObjectOpt(options, "data") ?? {},
|
|
3377
|
+
...strOpt(options, "label") ? { label: strOpt(options, "label") } : {},
|
|
3378
|
+
...listOpt(options, "options") ? { options: listOpt(options, "options") } : {},
|
|
3379
|
+
...type2 ? { type: type2 } : {},
|
|
3380
|
+
...boolOpt(options, "required") ? { required: true } : {},
|
|
3381
|
+
...boolOpt(options, "optional") ? { required: false } : {},
|
|
3382
|
+
...boolOpt(options, "unique") ? { unique: true } : {},
|
|
3383
|
+
...strOpt(options, "description") !== void 0 ? { description: strOpt(options, "description") } : {}
|
|
3384
|
+
};
|
|
3385
|
+
if (!Object.keys(patch).length) usage("fields update needs at least one change.");
|
|
3386
|
+
return withSession(io, options, { lock: true }, async ({ ctx }) => (await import("./chunks/schema-DlHt2PxI.mjs")).updateField(ctx, objectId, fieldId, patch));
|
|
3387
|
+
}
|
|
3388
|
+
case "delete": {
|
|
3389
|
+
if (!fieldId) usage("fields delete needs a field id.");
|
|
3390
|
+
return withSession(io, options, { lock: options.yes === true }, async ({ ctx, store }) => {
|
|
3391
|
+
const { getObject, deleteField } = await import("./chunks/schema-DlHt2PxI.mjs");
|
|
3392
|
+
const { recordsOf } = await import("./chunks/store-5gqRSKXg.mjs");
|
|
3393
|
+
const field = getObject(ctx, objectId).fields.find((f) => f.id === fieldId);
|
|
3394
|
+
if (!field) usage(`No field "${fieldId}" on ${objectId}.`, `\`agentcrm objects get ${objectId}\` lists them.`);
|
|
3395
|
+
const plan = { object: objectId, field: field.id, label: field.label, standard: !!field.standard, valuesCleared: recordsOf(store, objectId).filter((r) => r.fields[fieldId] !== void 0).length };
|
|
3396
|
+
if (options.yes !== true) return { needsConfirmation: true, plan };
|
|
3397
|
+
return deleteField(ctx, objectId, fieldId);
|
|
3398
|
+
});
|
|
3399
|
+
}
|
|
3400
|
+
default:
|
|
3401
|
+
usage(`Unknown fields action "${action ?? ""}".`, "Use add, update or delete.");
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
};
|
|
3405
|
+
const MIME = {
|
|
3406
|
+
".html": "text/html; charset=utf-8",
|
|
3407
|
+
".js": "text/javascript; charset=utf-8",
|
|
3408
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
3409
|
+
".css": "text/css; charset=utf-8",
|
|
3410
|
+
".json": "application/json; charset=utf-8",
|
|
3411
|
+
".svg": "image/svg+xml",
|
|
3412
|
+
".png": "image/png",
|
|
3413
|
+
".jpg": "image/jpeg",
|
|
3414
|
+
".webp": "image/webp",
|
|
3415
|
+
".ico": "image/x-icon",
|
|
3416
|
+
".woff": "font/woff",
|
|
3417
|
+
".woff2": "font/woff2",
|
|
3418
|
+
".txt": "text/plain; charset=utf-8",
|
|
3419
|
+
".wasm": "application/wasm"
|
|
3420
|
+
};
|
|
3421
|
+
const NO_CACHE = /* @__PURE__ */ new Set(["/index.html", "/config.js", "/version.json", "/admin-files.json"]);
|
|
3422
|
+
function resolveRequestPath(root, url) {
|
|
3423
|
+
let pathname;
|
|
3424
|
+
try {
|
|
3425
|
+
pathname = decodeURIComponent(new URL(url, "http://localhost").pathname);
|
|
3426
|
+
} catch {
|
|
3427
|
+
return null;
|
|
3428
|
+
}
|
|
3429
|
+
if (pathname.endsWith("/")) pathname += "index.html";
|
|
3430
|
+
const file2 = normalize(join(root, pathname));
|
|
3431
|
+
if (file2 !== root && !file2.startsWith(root.endsWith(sep) ? root : root + sep)) return null;
|
|
3432
|
+
return file2;
|
|
3433
|
+
}
|
|
3434
|
+
function startStaticServer(root, host, port) {
|
|
3435
|
+
const server = createServer((req, res) => {
|
|
3436
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
3437
|
+
res.writeHead(405).end();
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
const file2 = resolveRequestPath(root, req.url ?? "/");
|
|
3441
|
+
if (!file2 || !existsSync(file2) || !statSync(file2).isFile()) {
|
|
3442
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
const rel = "/" + file2.slice(root.length).split(sep).filter(Boolean).join("/");
|
|
3446
|
+
res.writeHead(200, {
|
|
3447
|
+
"content-type": MIME[extname(file2).toLowerCase()] ?? "application/octet-stream",
|
|
3448
|
+
"cache-control": NO_CACHE.has(rel) ? "no-store" : "no-cache",
|
|
3449
|
+
"x-content-type-options": "nosniff"
|
|
3450
|
+
});
|
|
3451
|
+
if (req.method === "HEAD") res.end();
|
|
3452
|
+
else createReadStream(file2).pipe(res);
|
|
3453
|
+
});
|
|
3454
|
+
return new Promise((resolve2, reject) => {
|
|
3455
|
+
server.once("error", reject);
|
|
3456
|
+
server.listen(port, host, () => {
|
|
3457
|
+
server.off("error", reject);
|
|
3458
|
+
resolve2(server);
|
|
3459
|
+
});
|
|
3460
|
+
});
|
|
3461
|
+
}
|
|
3462
|
+
function openBrowser(url) {
|
|
3463
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
3464
|
+
try {
|
|
3465
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
|
|
3466
|
+
} catch {
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
const serveCommand = {
|
|
3470
|
+
name: "serve",
|
|
3471
|
+
usage: "serve [--port <n>] [--dir <folder>] [--no-open]",
|
|
3472
|
+
summary: "Serve the admin on this computer (127.0.0.1) and open the setup wizard in the browser. Runs until stopped (Ctrl+C).",
|
|
3473
|
+
options: [
|
|
3474
|
+
{ name: "port", type: "string", description: "First port to try (the next free one is used).", default: "4173" },
|
|
3475
|
+
{ name: "dir", type: "string", description: "Serve this folder instead of the bundled admin (e.g. a local-folder hosting)." },
|
|
3476
|
+
{ name: "no-open", type: "boolean", description: "Don't open a browser." }
|
|
3477
|
+
],
|
|
3478
|
+
async run({ io, options }) {
|
|
3479
|
+
const root = strOpt(options, "dir") ?? join(distRoot(), "admin");
|
|
3480
|
+
if (!existsSync(join(root, "index.html"))) throw new CliError("admin-missing", `No index.html in ${root}.`, "Use the npm package, or run `npm run build` in a clone.");
|
|
3481
|
+
const first = Number(strOpt(options, "port") ?? 4173);
|
|
3482
|
+
if (!Number.isInteger(first) || first < 1 || first > 65535) usage(`Invalid port: ${String(options.port)}`);
|
|
3483
|
+
let server = null;
|
|
3484
|
+
let port = first;
|
|
3485
|
+
for (; port < first + 20 && port <= 65535; port++) {
|
|
3486
|
+
try {
|
|
3487
|
+
server = await startStaticServer(root, "127.0.0.1", port);
|
|
3488
|
+
break;
|
|
3489
|
+
} catch (e) {
|
|
3490
|
+
if (e.code !== "EADDRINUSE") throw e;
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
if (!server) throw new CliError("port-busy", `Ports ${first}-${port - 1} are all in use.`, "Pass --port <n>.");
|
|
3494
|
+
const url = `http://localhost:${port}/`;
|
|
3495
|
+
printResult(io, { url, root }, () => `AgentCRM admin running at ${url}
|
|
3496
|
+
Press Ctrl+C to stop.`);
|
|
3497
|
+
if (!boolOpt(options, "no-open")) openBrowser(url);
|
|
3498
|
+
await new Promise((resolve2) => {
|
|
3499
|
+
const stop = () => server.close(() => resolve2());
|
|
3500
|
+
process.once("SIGINT", stop);
|
|
3501
|
+
process.once("SIGTERM", stop);
|
|
3502
|
+
});
|
|
3503
|
+
}
|
|
3504
|
+
};
|
|
3505
|
+
function missingCredentials(site) {
|
|
3506
|
+
const out = [];
|
|
3507
|
+
const c = site.credentials;
|
|
3508
|
+
const set = `\`agentcrm site credentials ${site.alias} --<role>-credential <account>\``;
|
|
3509
|
+
const rt = site.runtime;
|
|
3510
|
+
if (rt.backend === "journal") {
|
|
3511
|
+
if (rt.journal.connectorId !== "local-folder" && !c.data) out.push(`No credential can open the data location: add one with ${set} (role data).`);
|
|
3512
|
+
if (rt.journal.connectorId === "local-folder" && !site.dataPath) out.push("The data folder of this computer is unknown: re-register with --data-path <folder>.");
|
|
3513
|
+
if (rt.journal.encrypted && !c.passphrase) out.push(`The dataset is encrypted and no passphrase credential is recorded: store it (\`agentcrm credentials set passphrase:${site.alias}\`) then ${set} (role passphrase).`);
|
|
3514
|
+
}
|
|
3515
|
+
if ((rt.backend === "git-json" || rt.backend === "firebase") && !c.data) out.push(`No ${rt.backend === "firebase" ? "Firebase login" : "git token"}: add it with ${set} (role data).`);
|
|
3516
|
+
if (site.hosting && site.hosting.connectorId !== "local-folder" && !c.hosting) out.push(`No hosting credential: deploy-admin and forms publish will fail until one is added with ${set} (role hosting).`);
|
|
3517
|
+
return out;
|
|
3518
|
+
}
|
|
3519
|
+
function checkAccount(v) {
|
|
3520
|
+
parseAccount(v);
|
|
3521
|
+
if (!getCredential(v)) throw new CliError("credential-missing", `${v} is not stored on this computer.`, `Store it first: \`agentcrm credentials set ${v}\` (in the user's own terminal).`, 2);
|
|
3522
|
+
return v;
|
|
3523
|
+
}
|
|
3524
|
+
const siteCommand = {
|
|
3525
|
+
name: "site",
|
|
3526
|
+
usage: "site <list|show|add|use|rename|forget|credentials> [args] [--admin-url <url>] [--data-credential <acct>] [--hosting-credential <acct>] [--passphrase-credential <acct>] [--user <email>]",
|
|
3527
|
+
summary: "The registry of CRMs this computer drives (~/.agentcrm/sites.json, no secrets): list, show, add an existing CRM, choose the default, rename, forget (local only — nothing is deleted online), attach credentials.",
|
|
3528
|
+
subcommands: [
|
|
3529
|
+
{ name: "list", usage: "site list", summary: "Registered CRMs (* = default)." },
|
|
3530
|
+
{ name: "show", usage: "site show [<alias>]", summary: "One site's record (backend, location, hosting, credential accounts, CLI user)." },
|
|
3531
|
+
{ name: "add", usage: "site add <alias> (--admin-url <url> | --runtime <json|@file>) [--data-credential …] [--hosting-credential …] [--passphrase-credential …] [--data-path <folder>] [--user <email>]", summary: "Register a CRM installed elsewhere: reads its config.js, opens the data to prove the credentials, reads its hosting.", writes: true },
|
|
3532
|
+
{ name: "use", usage: "site use <alias>", summary: "Default site for commands without --site.", writes: true },
|
|
3533
|
+
{ name: "rename", usage: "site rename <from> <to>", summary: "Rename an alias.", writes: true },
|
|
3534
|
+
{ name: "forget", usage: "site forget <alias>", summary: "Remove from this computer's registry (nothing is deleted online).", writes: true },
|
|
3535
|
+
{ name: "credentials", usage: "site credentials <alias> [--data-credential <acct>] [--hosting-credential <acct>] [--passphrase-credential <acct>] [--user <email>]", summary: 'Attach / change the accounts a site uses ("" removes one).', writes: true }
|
|
3536
|
+
],
|
|
3537
|
+
options: [
|
|
3538
|
+
{ name: "site", type: "string", description: "Site alias (show / credentials)." },
|
|
3539
|
+
{ name: "admin-url", type: "string", description: "add: URL of the hosted admin, e.g. https://crm.acme.com/admin/." },
|
|
3540
|
+
{ name: "runtime", type: "string", description: "add: the runtime config (config.js object) as JSON or @file, when the admin isn't hosted." },
|
|
3541
|
+
{ name: "data-credential", type: "string", description: "Account of the data location / git token / Firebase login." },
|
|
3542
|
+
{ name: "hosting-credential", type: "string", description: "Account used to write to the admin hosting." },
|
|
3543
|
+
{ name: "passphrase-credential", type: "string", description: "Passphrase account of an encrypted dataset." },
|
|
3544
|
+
{ name: "data-path", type: "string", description: "add: folder of this computer holding a local-folder dataset." },
|
|
3545
|
+
{ name: "user", type: "string", description: "Email of the CRM user the CLI acts as (default: the store identity / first admin)." }
|
|
3546
|
+
],
|
|
3547
|
+
async run({ io, options, positionals }) {
|
|
3548
|
+
const [action = "list", a, b] = positionals;
|
|
3549
|
+
switch (action) {
|
|
3550
|
+
case "list": {
|
|
3551
|
+
const { default: def, sites } = listSites();
|
|
3552
|
+
const rows = sites.map((s) => ({
|
|
3553
|
+
alias: s.alias,
|
|
3554
|
+
name: s.name ?? s.alias,
|
|
3555
|
+
default: s.alias === def,
|
|
3556
|
+
backend: s.runtime.backend,
|
|
3557
|
+
location: s.runtime.backend === "journal" ? s.runtime.journal.connectorId : null,
|
|
3558
|
+
hosting: s.hosting?.type ?? null,
|
|
3559
|
+
adminUrl: s.adminUrl,
|
|
3560
|
+
adminVersion: s.adminVersion ?? null
|
|
3561
|
+
}));
|
|
3562
|
+
printResult(
|
|
3563
|
+
io,
|
|
3564
|
+
{ default: def, sites: rows },
|
|
3565
|
+
() => rows.length ? rows.map((r) => `${r.default ? "*" : " "} ${r.alias.padEnd(20)} ${r.backend}${r.location ? `/${r.location}` : ""} → ${r.hosting ?? "not hosted"} ${r.adminUrl ?? ""}`).join("\n") : "No CRM registered. Install one with `agentcrm install --plan …` or register one with `agentcrm site add`."
|
|
3566
|
+
);
|
|
3567
|
+
return;
|
|
3568
|
+
}
|
|
3569
|
+
case "show": {
|
|
3570
|
+
const site = resolveSite(a ?? strOpt(options, "site"));
|
|
3571
|
+
printResult(io, { ...site, warnings: missingCredentials(site) }, () => JSON.stringify(site, null, 2));
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
case "use": {
|
|
3575
|
+
if (!a) usage("site use needs an alias.");
|
|
3576
|
+
setDefaultSite(a);
|
|
3577
|
+
printResult(io, { default: a }, () => `Default site: ${a}`);
|
|
3578
|
+
return;
|
|
3579
|
+
}
|
|
3580
|
+
case "rename": {
|
|
3581
|
+
if (!a || !b) usage("site rename needs <from> <to>.");
|
|
3582
|
+
renameSite(a, b);
|
|
3583
|
+
printResult(io, { from: a, to: b }, () => `Renamed ${a} → ${b}`);
|
|
3584
|
+
return;
|
|
3585
|
+
}
|
|
3586
|
+
case "forget": {
|
|
3587
|
+
if (!a) usage("site forget needs an alias.");
|
|
3588
|
+
const removed = removeSite(a);
|
|
3589
|
+
printResult(io, { alias: a, removed }, () => removed ? `Forgot ${a} (nothing was deleted online).` : `${a} was not registered.`);
|
|
3590
|
+
return;
|
|
3591
|
+
}
|
|
3592
|
+
case "credentials": {
|
|
3593
|
+
const site = resolveSite(a ?? strOpt(options, "site"));
|
|
3594
|
+
const roles = { data: "data-credential", hosting: "hosting-credential", passphrase: "passphrase-credential" };
|
|
3595
|
+
const next = { ...site.credentials };
|
|
3596
|
+
let changed = false;
|
|
3597
|
+
for (const [role, flag] of Object.entries(roles)) {
|
|
3598
|
+
const v = options[flag];
|
|
3599
|
+
if (typeof v !== "string") continue;
|
|
3600
|
+
if (v === "") delete next[role];
|
|
3601
|
+
else next[role] = checkAccount(v);
|
|
3602
|
+
changed = true;
|
|
3603
|
+
}
|
|
3604
|
+
const user = strOpt(options, "user");
|
|
3605
|
+
if (user !== void 0) changed = true;
|
|
3606
|
+
if (!changed) usage('site credentials needs --data-credential, --hosting-credential, --passphrase-credential and/or --user ("" removes one).');
|
|
3607
|
+
const updated = { ...site, credentials: next, ...user !== void 0 ? { user: user || void 0 } : {} };
|
|
3608
|
+
putSite(updated);
|
|
3609
|
+
const warnings = missingCredentials(updated);
|
|
3610
|
+
printResult(io, { alias: site.alias, credentials: next, user: updated.user ?? null, warnings }, () => [`${site.alias}: ${JSON.stringify(next)}`, ...warnings.map((w) => ` warning: ${w}`)].join("\n"));
|
|
3611
|
+
return;
|
|
3612
|
+
}
|
|
3613
|
+
case "add": {
|
|
3614
|
+
if (!a) usage("site add needs an alias.");
|
|
3615
|
+
validateAlias(a);
|
|
3616
|
+
if (getSite(a)) throw new CliError("site-exists", `A site named "${a}" already exists.`, "Pick another alias or `site forget` it first.");
|
|
3617
|
+
const adminUrlRaw = strOpt(options, "admin-url");
|
|
3618
|
+
let runtimeRaw = jsonObjectOpt(options, "runtime");
|
|
3619
|
+
let adminUrl = null;
|
|
3620
|
+
if (adminUrlRaw) {
|
|
3621
|
+
adminUrl = adminUrlRaw.replace(/(index\.html)?$/, "").replace(/\/?$/, "/");
|
|
3622
|
+
const res = await fetchText(`${adminUrl}config.js`);
|
|
3623
|
+
if (!res.ok) throw new CliError("admin-unreachable", `Could not read ${adminUrl}config.js (${res.error ?? `HTTP ${res.status}`}).`, "Check the admin URL (it ends with the admin folder, e.g. …/admin/).");
|
|
3624
|
+
try {
|
|
3625
|
+
runtimeRaw = parseConfigJs(res.text);
|
|
3626
|
+
} catch (err) {
|
|
3627
|
+
throw new CliError("config-invalid", `${adminUrl}config.js: ${err.message}.`);
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
if (!runtimeRaw) usage("site add needs --admin-url <url> or --runtime <json|@file>.");
|
|
3631
|
+
installBrowserEnv();
|
|
3632
|
+
const { parseRuntimeConfig } = await import("./chunks/runtimeConfig-CCOFg1RM.mjs");
|
|
3633
|
+
const runtime = parseRuntimeConfig(runtimeRaw);
|
|
3634
|
+
if (!runtime) throw new CliError("config-invalid", "The runtime config is not a valid AgentCRM config.", "It is the object config.js assigns to window.__AGENTCRM_CONFIG__.");
|
|
3635
|
+
const dataPath = strOpt(options, "data-path");
|
|
3636
|
+
if (dataPath && !isAbsolute(dataPath)) usage("--data-path must be an absolute folder.");
|
|
3637
|
+
const credentials$1 = {};
|
|
3638
|
+
for (const [role, flag] of [["data", "data-credential"], ["hosting", "hosting-credential"], ["passphrase", "passphrase-credential"]]) {
|
|
3639
|
+
const v = strOpt(options, flag);
|
|
3640
|
+
if (v) credentials$1[role] = checkAccount(v);
|
|
3641
|
+
}
|
|
3642
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3643
|
+
const site = {
|
|
3644
|
+
alias: a,
|
|
3645
|
+
name: runtime.name,
|
|
3646
|
+
runtime,
|
|
3647
|
+
...dataPath ? { dataPath: resolve(dataPath) } : {},
|
|
3648
|
+
hosting: null,
|
|
3649
|
+
adminUrl,
|
|
3650
|
+
credentials: credentials$1,
|
|
3651
|
+
...strOpt(options, "user") ? { user: strOpt(options, "user") } : {},
|
|
3652
|
+
installedAt: now,
|
|
3653
|
+
updatedAt: now
|
|
3654
|
+
};
|
|
3655
|
+
const warnings = [];
|
|
3656
|
+
const { openStore: openStore2 } = await Promise.resolve().then(() => session);
|
|
3657
|
+
const { dataTargetOf: dataTargetOf2 } = await Promise.resolve().then(() => session);
|
|
3658
|
+
try {
|
|
3659
|
+
const passphrase = credentials$1.passphrase ? (await Promise.resolve().then(() => credentials)).requireCredential(credentials$1.passphrase).values.passphrase : null;
|
|
3660
|
+
const secrets = credentials$1.data && runtime.backend !== "journal" ? (await Promise.resolve().then(() => credentials)).requireCredential(credentials$1.data).values : void 0;
|
|
3661
|
+
const store = await openStore2(runtime, { dataTarget: runtime.backend === "journal" ? dataTargetOf2(site) : void 0, secrets, passphrase });
|
|
3662
|
+
try {
|
|
3663
|
+
await store.ready();
|
|
3664
|
+
const hosting = store.get("config", "hosting");
|
|
3665
|
+
if (hosting?.connectorId) {
|
|
3666
|
+
site.hosting = { type: hosting.type ?? hosting.connectorId, connectorId: hosting.connectorId, settings: hosting.settings ?? {}, folder: hosting.folder ?? "admin" };
|
|
3667
|
+
}
|
|
3668
|
+
} finally {
|
|
3669
|
+
store.close?.();
|
|
3670
|
+
}
|
|
3671
|
+
} catch (err) {
|
|
3672
|
+
const e = err;
|
|
3673
|
+
if (e.name === "PassphraseError") throw err;
|
|
3674
|
+
warnings.push(`Could not open the data yet: ${e.message}`);
|
|
3675
|
+
}
|
|
3676
|
+
putSite(site);
|
|
3677
|
+
warnings.push(...missingCredentials(site));
|
|
3678
|
+
printResult(io, { ...site, warnings }, () => [`Registered ${a}: ${runtime.backend} → ${site.hosting?.type ?? "hosting unknown"}`, ...warnings.map((w) => ` warning: ${w}`)].join("\n"));
|
|
3679
|
+
return;
|
|
3680
|
+
}
|
|
3681
|
+
default:
|
|
3682
|
+
usage(`Unknown action "${action}".`, "Use list, show, add, use, rename, forget or credentials.");
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
};
|
|
3686
|
+
const toolsCommand = {
|
|
3687
|
+
name: "tools",
|
|
3688
|
+
usage: "tools <list|call> [<name>] [--input <json|@file>]",
|
|
3689
|
+
summary: "The AI tool catalog (src/ai/tools.ts + the tools of the site's enabled extension modules) — exactly what the MCP server and the in-admin assistant expose: list the tools with their JSON schemas, or call one with a JSON input.",
|
|
3690
|
+
subcommands: [
|
|
3691
|
+
{ name: "list", usage: "tools list [--site <alias>]", summary: "Tools with description, input schema, whether they write and their source (builtin / extension)." },
|
|
3692
|
+
{ name: "call", usage: "tools call <name> --input <json|@file>", summary: "Run one tool as the site's CLI user.", writes: true }
|
|
3693
|
+
],
|
|
3694
|
+
options: [SITE_OPTION, AS_OPTION, { name: "input", type: "string", description: "call: the tool input as a JSON object or @file." }],
|
|
3695
|
+
async run({ io, options, positionals }) {
|
|
3696
|
+
const [action = "list"] = positionals;
|
|
3697
|
+
if (action === "list") {
|
|
3698
|
+
const { listAiTools, AI_TOOLS } = await import("./chunks/tools-Bew8lxKx.mjs");
|
|
3699
|
+
const builtin = new Set(AI_TOOLS.map((t) => t.name));
|
|
3700
|
+
const shape = () => listAiTools().map((t) => ({ name: t.name, description: t.description, mutating: t.mutating, source: builtin.has(t.name) ? "builtin" : "extension", inputSchema: t.inputSchema }));
|
|
3701
|
+
const human = (r) => table(r.tools, ["name", "mutating", "source", "description"]);
|
|
3702
|
+
let site = strOpt(options, "site");
|
|
3703
|
+
if (!site) {
|
|
3704
|
+
try {
|
|
3705
|
+
site = resolveSite().alias;
|
|
3706
|
+
} catch {
|
|
3707
|
+
site = void 0;
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
if (!site) {
|
|
3711
|
+
printResult(io, { tools: shape(), site: null }, () => human({ tools: shape() }));
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
3714
|
+
return withSession(io, { ...options, site }, {}, async (s) => ({ tools: shape(), site: s.site.alias }), human);
|
|
3715
|
+
}
|
|
3716
|
+
if (action === "call") {
|
|
3717
|
+
const name2 = positional(positionals, 1, "tool name", "tools call <name> --input <json>");
|
|
3718
|
+
const { findTool, callTool, listAiTools } = await import("./chunks/tools-Bew8lxKx.mjs");
|
|
3719
|
+
const input = jsonObjectOpt(options, "input") ?? {};
|
|
3720
|
+
const builtin = findTool(name2);
|
|
3721
|
+
return withSession(io, options, { lock: builtin ? builtin.mutating : true }, async ({ ctx }) => {
|
|
3722
|
+
if (!findTool(name2)) usage(`Unknown tool "${name2}".`, `\`agentcrm tools list\` lists them (${listAiTools().length} available).`);
|
|
3723
|
+
return { tool: name2, result: await callTool(ctx, name2, input) };
|
|
3724
|
+
});
|
|
3725
|
+
}
|
|
3726
|
+
usage(`Unknown tools action "${action}".`, "Use list or call.");
|
|
3727
|
+
}
|
|
3728
|
+
};
|
|
3729
|
+
const mcpCommand = {
|
|
3730
|
+
name: "mcp",
|
|
3731
|
+
usage: "mcp [--site <alias>] [--read-only] [--as <user>]",
|
|
3732
|
+
summary: "Run the MCP server on stdio (JSON-RPC 2.0, protocol 2025-06-18 / 2024-11-05): tools = the AI catalog incl. the site's extension tools (read-only mode hides the ones that write; --safe skips extensions), resources = schema, pipelines, modules, appearance, prompts = pipeline review, follow-up email, lead qualification. For Claude Desktop / Claude Code / Cursor.",
|
|
3733
|
+
options: [SITE_OPTION, AS_OPTION, { name: "read-only", type: "boolean", description: "Expose only the tools that don't write." }],
|
|
3734
|
+
async run({ options }) {
|
|
3735
|
+
const readOnly = boolOpt(options, "read-only");
|
|
3736
|
+
const session2 = await openSession(strOpt(options, "site"), { as: strOpt(options, "as"), safe: options.safe === true });
|
|
3737
|
+
const log = (msg) => process.stderr.write(`${msg}
|
|
3738
|
+
`);
|
|
3739
|
+
for (const w of session2.warnings) log(`[agentcrm mcp] note: ${w}`);
|
|
3740
|
+
const { listAiTools } = await import("./chunks/tools-Bew8lxKx.mjs");
|
|
3741
|
+
const { createMcpServer, serveStdio } = await import("./chunks/server-ISwlDRCN.mjs");
|
|
3742
|
+
let lastRefresh = Date.now();
|
|
3743
|
+
const server = createMcpServer({
|
|
3744
|
+
readOnly,
|
|
3745
|
+
// Built-ins + the tools of the site's loaded extensions, live.
|
|
3746
|
+
tools: () => listAiTools(),
|
|
3747
|
+
log,
|
|
3748
|
+
backend: {
|
|
3749
|
+
serverName: "agentcrm",
|
|
3750
|
+
version: readVersionInfo().version,
|
|
3751
|
+
siteLabel: `site ${session2.site.alias}${session2.user ? `, acting as ${session2.user.email} (${session2.user.role})` : ""}`,
|
|
3752
|
+
async context() {
|
|
3753
|
+
if (Date.now() - lastRefresh > 5e3) {
|
|
3754
|
+
lastRefresh = Date.now();
|
|
3755
|
+
await session2.store.refresh().catch((err) => log(`[agentcrm mcp] refresh failed: ${err.message}`));
|
|
3756
|
+
const report = await session2.extensions.sync();
|
|
3757
|
+
for (const f of report?.failed ?? []) log(`[agentcrm mcp] extension ${f.id} failed to load: ${f.error}`);
|
|
3758
|
+
}
|
|
3759
|
+
return session2.ctx;
|
|
3760
|
+
},
|
|
3761
|
+
async withWrite(fn) {
|
|
3762
|
+
const unlock = lockSite(session2.site.alias);
|
|
3763
|
+
try {
|
|
3764
|
+
return await fn();
|
|
3765
|
+
} finally {
|
|
3766
|
+
unlock();
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
}
|
|
3770
|
+
});
|
|
3771
|
+
log(`[agentcrm mcp] serving ${session2.site.alias}${readOnly ? " (read-only)" : ""} on stdio — ${server.toolNames().length} tools`);
|
|
3772
|
+
try {
|
|
3773
|
+
await serveStdio(server, process.stdin, (line) => process.stdout.write(line + "\n"), log);
|
|
3774
|
+
} finally {
|
|
3775
|
+
await session2.close();
|
|
3776
|
+
}
|
|
3777
|
+
return 0;
|
|
3778
|
+
}
|
|
3779
|
+
};
|
|
3780
|
+
const GLOBAL_OPTIONS = [
|
|
3781
|
+
{ name: "json", type: "boolean", description: "Print one JSON object on stdout: { ok, data } or { ok: false, error: { code, message, hint } }." },
|
|
3782
|
+
{ name: "help", type: "boolean", description: "Show the command's usage and options instead of running it." },
|
|
3783
|
+
{ name: "safe", type: "boolean", description: "Safe mode: don't load the site's extensions (modules / themes) in this run (also AGENTCRM_SAFE=1)." }
|
|
3784
|
+
];
|
|
3785
|
+
const helpCommand = {
|
|
3786
|
+
name: "help",
|
|
3787
|
+
usage: "help [<command>]",
|
|
3788
|
+
summary: "List commands with their subcommands and options (--json gives a machine-readable list).",
|
|
3789
|
+
options: [],
|
|
3790
|
+
async run({ io, positionals }) {
|
|
3791
|
+
const wanted = positionals[0];
|
|
3792
|
+
const list = wanted ? COMMANDS.filter((c) => c.name === wanted) : COMMANDS;
|
|
3793
|
+
if (wanted && !list.length) {
|
|
3794
|
+
const { CliError: CliError2 } = await Promise.resolve().then(() => output);
|
|
3795
|
+
throw new CliError2("unknown-command", `Unknown command "${wanted}".`, `Available: ${COMMANDS.map((c) => c.name).join(", ")}.`, 2);
|
|
3796
|
+
}
|
|
3797
|
+
const { version: version2 } = readVersionInfo();
|
|
3798
|
+
const data = {
|
|
3799
|
+
name: "agentcrm",
|
|
3800
|
+
version: version2,
|
|
3801
|
+
globalOptions: GLOBAL_OPTIONS,
|
|
3802
|
+
commands: list.map(({ name: name2, summary, usage: usage2, options, subcommands }) => ({ name: name2, summary, usage: usage2, options, ...subcommands ? { subcommands } : {} }))
|
|
3803
|
+
};
|
|
3804
|
+
printResult(
|
|
3805
|
+
io,
|
|
3806
|
+
data,
|
|
3807
|
+
() => [
|
|
3808
|
+
`agentcrm ${version2} — the static CRM your AI agent can run.`,
|
|
3809
|
+
"",
|
|
3810
|
+
"Usage: agentcrm <command> [options] [--json]",
|
|
3811
|
+
"",
|
|
3812
|
+
...list.flatMap((c) => [
|
|
3813
|
+
` ${c.usage}`,
|
|
3814
|
+
` ${c.summary}`,
|
|
3815
|
+
...wanted ? (c.subcommands ?? []).map((s) => ` · ${s.usage}
|
|
3816
|
+
${s.summary}`) : [],
|
|
3817
|
+
...wanted ? c.options.map((o) => ` --${o.name}${o.type === "string" ? " <value>" : ""} ${o.description}`) : []
|
|
3818
|
+
]),
|
|
3819
|
+
"",
|
|
3820
|
+
"Global options:",
|
|
3821
|
+
...GLOBAL_OPTIONS.map((o) => ` --${o.name} ${o.description}`),
|
|
3822
|
+
...wanted ? [] : ["", "`agentcrm help <command>` shows a command's actions and options."]
|
|
3823
|
+
].join("\n")
|
|
3824
|
+
);
|
|
3825
|
+
}
|
|
3826
|
+
};
|
|
3827
|
+
const COMMANDS = [
|
|
3828
|
+
helpCommand,
|
|
3829
|
+
versionCommand,
|
|
3830
|
+
statusCommand,
|
|
3831
|
+
optionsCommand,
|
|
3832
|
+
credentialsCommand,
|
|
3833
|
+
installCommand,
|
|
3834
|
+
siteCommand,
|
|
3835
|
+
doctorCommand,
|
|
3836
|
+
serveCommand,
|
|
3837
|
+
deployAdminCommand,
|
|
3838
|
+
recordsCommand,
|
|
3839
|
+
objectsCommand,
|
|
3840
|
+
fieldsCommand,
|
|
3841
|
+
activitiesCommand,
|
|
3842
|
+
notesCommand,
|
|
3843
|
+
tasksCommand,
|
|
3844
|
+
filesCommand,
|
|
3845
|
+
pipelinesCommand,
|
|
3846
|
+
viewsCommand,
|
|
3847
|
+
usersCommand,
|
|
3848
|
+
settingsCommand,
|
|
3849
|
+
importCommand,
|
|
3850
|
+
exportCommand,
|
|
3851
|
+
reportCommand,
|
|
3852
|
+
automationsCommand,
|
|
3853
|
+
formsCommand,
|
|
3854
|
+
compactCommand,
|
|
3855
|
+
statsCommand,
|
|
3856
|
+
modulesCommand,
|
|
3857
|
+
appearanceCommand,
|
|
3858
|
+
extCommand,
|
|
3859
|
+
toolsCommand,
|
|
3860
|
+
mcpCommand
|
|
3861
|
+
];
|
|
3862
|
+
function findCommand(name2) {
|
|
3863
|
+
return COMMANDS.find((c) => c.name === name2);
|
|
3864
|
+
}
|
|
3865
|
+
async function main(argv, io) {
|
|
3866
|
+
const json = argv.includes("--json");
|
|
3867
|
+
const out = createIo(json);
|
|
3868
|
+
try {
|
|
3869
|
+
const [name2 = "help", ...rest] = argv.filter((a) => a !== "--json");
|
|
3870
|
+
if (name2 === "--help" || name2 === "-h") return await run("help", rest, out) ?? 0;
|
|
3871
|
+
if (name2 === "--version" || name2 === "-v") return await run("version", rest, out) ?? 0;
|
|
3872
|
+
return await run(name2, rest, out) ?? 0;
|
|
3873
|
+
} catch (error) {
|
|
3874
|
+
return printError(out, error);
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
async function run(name2, args, io) {
|
|
3878
|
+
const command = findCommand(name2);
|
|
3879
|
+
if (!command) {
|
|
3880
|
+
throw new CliError("unknown-command", `Unknown command "${name2}".`, `Available: ${COMMANDS.map((c) => c.name).join(", ")}. Run \`agentcrm help\`.`, 2);
|
|
3881
|
+
}
|
|
3882
|
+
let parsed;
|
|
3883
|
+
try {
|
|
3884
|
+
parsed = parseArgs({
|
|
3885
|
+
args,
|
|
3886
|
+
allowPositionals: true,
|
|
3887
|
+
strict: true,
|
|
3888
|
+
options: Object.fromEntries([...command.options, ...GLOBAL_OPTIONS].map((o) => [o.name, { type: o.type, ...o.multiple ? { multiple: true } : {} }]))
|
|
3889
|
+
});
|
|
3890
|
+
} catch (e) {
|
|
3891
|
+
throw new CliError("usage", e.message, `Usage: agentcrm ${command.usage}`, 2);
|
|
3892
|
+
}
|
|
3893
|
+
if (parsed.values.help && command.name !== "help") return run("help", [command.name], io);
|
|
3894
|
+
return command.run({
|
|
3895
|
+
io,
|
|
3896
|
+
options: parsed.values,
|
|
3897
|
+
positionals: parsed.positionals
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
3900
|
+
installBrowserEnv();
|
|
3901
|
+
main(process.argv.slice(2)).then(
|
|
3902
|
+
(code) => process.exit(code),
|
|
3903
|
+
(err) => {
|
|
3904
|
+
console.error(err);
|
|
3905
|
+
process.exit(1);
|
|
3906
|
+
}
|
|
3907
|
+
);
|
|
3908
|
+
export {
|
|
3909
|
+
ACTIVITY_TYPES as A,
|
|
3910
|
+
CliError as C,
|
|
3911
|
+
FILTER_OPS as F,
|
|
3912
|
+
HOSTINGS as H,
|
|
3913
|
+
LOCATIONS as L,
|
|
3914
|
+
PACKAGE_NAME as P,
|
|
3915
|
+
USER_ROLES as U,
|
|
3916
|
+
parseAccount as a,
|
|
3917
|
+
requireCredential as b,
|
|
3918
|
+
findLocation as c,
|
|
3919
|
+
findHosting as d,
|
|
3920
|
+
FIELD_TYPES as e,
|
|
3921
|
+
findBackend as f,
|
|
3922
|
+
getCredential as g,
|
|
3923
|
+
openStore as h,
|
|
3924
|
+
distRoot as i,
|
|
3925
|
+
nodeImportBundle as n,
|
|
3926
|
+
ownPackageRoot as o,
|
|
3927
|
+
packageRoot as p,
|
|
3928
|
+
readVersionInfo as r,
|
|
3929
|
+
setCredential as s,
|
|
3930
|
+
toCliError as t,
|
|
3931
|
+
validateAlias as v
|
|
3932
|
+
};
|