@enerlence/suntropy-cli 0.11.9 → 0.13.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.
@@ -69,7 +69,8 @@ var SERVICE_PATHS = {
69
69
  templates: "/templates",
70
70
  profiles: "/profiles",
71
71
  periods: "/periods",
72
- notifications: "/notifications"
72
+ notifications: "/notifications",
73
+ satvolt: "/satvolt"
73
74
  };
74
75
  var LOCAL_PORTS = {
75
76
  security: 8080,
@@ -77,7 +78,8 @@ var LOCAL_PORTS = {
77
78
  templates: 8090,
78
79
  profiles: 8085,
79
80
  periods: 8084,
80
- notifications: 8093
81
+ notifications: 8093,
82
+ satvolt: 8099
81
83
  };
82
84
  function getServiceUrl(baseServer, service) {
83
85
  if (baseServer.includes("localhost") || baseServer.match(/:\d+$/)) {
@@ -266,6 +268,7 @@ function outputError(err) {
266
268
  const apiErr = err;
267
269
  const out = { error: true, message: apiErr.message || "Unknown error" };
268
270
  if (apiErr.status) out.status = apiErr.status;
271
+ if (apiErr.code) out.code = apiErr.code;
269
272
  if (apiErr.details) out.details = apiErr.details;
270
273
  process.stderr.write(JSON.stringify(out) + "\n");
271
274
  } else {
@@ -1240,8 +1243,8 @@ function getGlobalOpts3(cmd) {
1240
1243
  function parseData(data) {
1241
1244
  if (!data) return void 0;
1242
1245
  if (data === "-") {
1243
- const { readFileSync: readFileSync6 } = __require("fs");
1244
- const input = readFileSync6(0, "utf-8");
1246
+ const { readFileSync: readFileSync7 } = __require("fs");
1247
+ const input = readFileSync7(0, "utf-8");
1245
1248
  return JSON.parse(input);
1246
1249
  }
1247
1250
  return JSON.parse(data);
@@ -1390,8 +1393,8 @@ function getGlobalOpts4(cmd) {
1390
1393
  }
1391
1394
  function parseData2(data) {
1392
1395
  if (data === "-") {
1393
- const { readFileSync: readFileSync6 } = __require("fs");
1394
- return JSON.parse(readFileSync6(0, "utf-8"));
1396
+ const { readFileSync: readFileSync7 } = __require("fs");
1397
+ return JSON.parse(readFileSync7(0, "utf-8"));
1395
1398
  }
1396
1399
  return JSON.parse(data);
1397
1400
  }
@@ -1869,7 +1872,7 @@ function registerInventoryCommands(program2) {
1869
1872
  import { Option } from "commander";
1870
1873
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync4, existsSync as existsSync2 } from "fs";
1871
1874
 
1872
- // node_modules/uuid/dist/esm-node/rng.js
1875
+ // ../suntropy-cli/node_modules/uuid/dist/esm-node/rng.js
1873
1876
  import crypto from "crypto";
1874
1877
  var rnds8Pool = new Uint8Array(256);
1875
1878
  var poolPtr = rnds8Pool.length;
@@ -1881,16 +1884,16 @@ function rng() {
1881
1884
  return rnds8Pool.slice(poolPtr, poolPtr += 16);
1882
1885
  }
1883
1886
 
1884
- // node_modules/uuid/dist/esm-node/regex.js
1887
+ // ../suntropy-cli/node_modules/uuid/dist/esm-node/regex.js
1885
1888
  var regex_default = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;
1886
1889
 
1887
- // node_modules/uuid/dist/esm-node/validate.js
1890
+ // ../suntropy-cli/node_modules/uuid/dist/esm-node/validate.js
1888
1891
  function validate(uuid) {
1889
1892
  return typeof uuid === "string" && regex_default.test(uuid);
1890
1893
  }
1891
1894
  var validate_default = validate;
1892
1895
 
1893
- // node_modules/uuid/dist/esm-node/stringify.js
1896
+ // ../suntropy-cli/node_modules/uuid/dist/esm-node/stringify.js
1894
1897
  var byteToHex = [];
1895
1898
  for (let i = 0; i < 256; ++i) {
1896
1899
  byteToHex.push((i + 256).toString(16).substr(1));
@@ -1904,7 +1907,7 @@ function stringify(arr, offset = 0) {
1904
1907
  }
1905
1908
  var stringify_default = stringify;
1906
1909
 
1907
- // node_modules/uuid/dist/esm-node/v4.js
1910
+ // ../suntropy-cli/node_modules/uuid/dist/esm-node/v4.js
1908
1911
  function v4(options, buf, offset) {
1909
1912
  options = options || {};
1910
1913
  const rnds = options.random || (options.rng || rng)();
@@ -3792,13 +3795,13 @@ function getGlobalOpts9(cmd) {
3792
3795
  return root.opts();
3793
3796
  }
3794
3797
  function readStdin() {
3795
- return new Promise((resolve, reject) => {
3798
+ return new Promise((resolve2, reject) => {
3796
3799
  let data = "";
3797
3800
  process.stdin.setEncoding("utf-8");
3798
3801
  process.stdin.on("data", (chunk) => {
3799
3802
  data += chunk;
3800
3803
  });
3801
- process.stdin.on("end", () => resolve(data));
3804
+ process.stdin.on("end", () => resolve2(data));
3802
3805
  process.stdin.on("error", reject);
3803
3806
  });
3804
3807
  }
@@ -4091,13 +4094,13 @@ function getGlobalOpts11(cmd) {
4091
4094
  return root.opts();
4092
4095
  }
4093
4096
  function readStdin2() {
4094
- return new Promise((resolve, reject) => {
4097
+ return new Promise((resolve2, reject) => {
4095
4098
  let data = "";
4096
4099
  process.stdin.setEncoding("utf-8");
4097
4100
  process.stdin.on("data", (chunk) => {
4098
4101
  data += chunk;
4099
4102
  });
4100
- process.stdin.on("end", () => resolve(data));
4103
+ process.stdin.on("end", () => resolve2(data));
4101
4104
  process.stdin.on("error", reject);
4102
4105
  });
4103
4106
  }
@@ -4263,13 +4266,13 @@ function getGlobalOpts12(cmd) {
4263
4266
  return root.opts();
4264
4267
  }
4265
4268
  function readStdin3() {
4266
- return new Promise((resolve, reject) => {
4269
+ return new Promise((resolve2, reject) => {
4267
4270
  let data = "";
4268
4271
  process.stdin.setEncoding("utf-8");
4269
4272
  process.stdin.on("data", (chunk) => {
4270
4273
  data += chunk;
4271
4274
  });
4272
- process.stdin.on("end", () => resolve(data));
4275
+ process.stdin.on("end", () => resolve2(data));
4273
4276
  process.stdin.on("error", reject);
4274
4277
  });
4275
4278
  }
@@ -4364,13 +4367,13 @@ function getGlobalOpts13(cmd) {
4364
4367
  return root.opts();
4365
4368
  }
4366
4369
  function readStdin4() {
4367
- return new Promise((resolve, reject) => {
4370
+ return new Promise((resolve2, reject) => {
4368
4371
  let data = "";
4369
4372
  process.stdin.setEncoding("utf-8");
4370
4373
  process.stdin.on("data", (chunk) => {
4371
4374
  data += chunk;
4372
4375
  });
4373
- process.stdin.on("end", () => resolve(data));
4376
+ process.stdin.on("end", () => resolve2(data));
4374
4377
  process.stdin.on("error", reject);
4375
4378
  });
4376
4379
  }
@@ -4607,6 +4610,1232 @@ Examples:
4607
4610
  });
4608
4611
  }
4609
4612
 
4613
+ // src/commands/satvolt/api.ts
4614
+ import axios2 from "axios";
4615
+ import { readFileSync as readFileSync6 } from "fs";
4616
+ function getGlobalOpts17(cmd) {
4617
+ let root = cmd;
4618
+ while (root.parent) root = root.parent;
4619
+ return root.opts();
4620
+ }
4621
+ var SATVOLT_DEV_BASE_URL = "https://api-dev.suntropy.domain.eu.axebow.cloud/satvolt";
4622
+ function resolveSatvoltBaseUrl(resolved) {
4623
+ const override = process.env.SUNTROPY_SATVOLT_URL?.trim();
4624
+ if (override) return override.replace(/\/+$/, "");
4625
+ if (/^https?:\/\/(localhost|127\.0\.0\.1)([:/]|$)/.test(resolved)) return resolved;
4626
+ return SATVOLT_DEV_BASE_URL;
4627
+ }
4628
+ function satvoltClient(global, timeout = 6e4) {
4629
+ const client = createServiceClient("satvolt", global);
4630
+ const base = resolveSatvoltBaseUrl(client.defaults.baseURL ?? "");
4631
+ if (global.verbose && base !== client.defaults.baseURL) {
4632
+ process.stderr.write(`\u2192 satvolt pinned to ${base} (preliminary testing phase)
4633
+ `);
4634
+ }
4635
+ client.defaults.baseURL = `${base}/api/v1`;
4636
+ client.defaults.timeout = timeout;
4637
+ return client;
4638
+ }
4639
+ async function call(client, method, path, options = {}) {
4640
+ const res = await client.request({
4641
+ method,
4642
+ url: path,
4643
+ params: dropEmpty(options.params),
4644
+ data: options.data
4645
+ });
4646
+ return res.data?.data;
4647
+ }
4648
+ function dropEmpty(params) {
4649
+ if (!params) return void 0;
4650
+ return Object.fromEntries(
4651
+ Object.entries(params).filter(([, v]) => v !== void 0 && v !== null && v !== "")
4652
+ );
4653
+ }
4654
+ function satvoltError(err) {
4655
+ if (axios2.isAxiosError(err)) {
4656
+ let body = err.response?.data;
4657
+ if (body instanceof ArrayBuffer || Buffer.isBuffer(body)) {
4658
+ try {
4659
+ body = JSON.parse(Buffer.from(body).toString("utf-8"));
4660
+ } catch {
4661
+ body = void 0;
4662
+ }
4663
+ }
4664
+ const envelope = body;
4665
+ if (envelope?.error) {
4666
+ return {
4667
+ error: true,
4668
+ status: err.response?.status ?? 0,
4669
+ code: envelope.error.code,
4670
+ message: envelope.error.message || envelope.error.code || "Request failed",
4671
+ details: envelope.error.details
4672
+ };
4673
+ }
4674
+ return {
4675
+ error: true,
4676
+ status: err.response?.status ?? 0,
4677
+ message: err.response?.status === 404 && !envelope ? "Endpoint not found: is the Satvolt public API deployed on this server?" : err.response?.statusText || err.message,
4678
+ details: body
4679
+ };
4680
+ }
4681
+ return { error: true, status: 0, message: err instanceof Error ? err.message : String(err) };
4682
+ }
4683
+ function readJsonArg(value, label) {
4684
+ let raw;
4685
+ if (value === "-") {
4686
+ raw = readFileSync6(0, "utf-8");
4687
+ } else if (value.startsWith("@")) {
4688
+ raw = readFileSync6(value.slice(1), "utf-8");
4689
+ } else {
4690
+ raw = value;
4691
+ }
4692
+ try {
4693
+ return JSON.parse(raw);
4694
+ } catch (err) {
4695
+ throw new Error(`${label} is not valid JSON: ${err.message}`);
4696
+ }
4697
+ }
4698
+ function parseId(value, label = "id") {
4699
+ const n = Number(value);
4700
+ if (!Number.isInteger(n) || n <= 0) {
4701
+ throw new Error(`${label} must be a positive integer, got "${value}"`);
4702
+ }
4703
+ return n;
4704
+ }
4705
+ function parseIntOption(value, label) {
4706
+ if (value === void 0) return void 0;
4707
+ const n = Number(value);
4708
+ if (!Number.isInteger(n) || n < 0) throw new Error(`${label} must be a non-negative integer`);
4709
+ return n;
4710
+ }
4711
+ function parseLatLng(value, label) {
4712
+ const parts = value.split(",").map((p) => Number(p.trim()));
4713
+ if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n))) {
4714
+ throw new Error(`${label} must be "lat,lng", got "${value}"`);
4715
+ }
4716
+ return [parts[0], parts[1]];
4717
+ }
4718
+
4719
+ // src/commands/satvolt/campaigns.ts
4720
+ import chalk2 from "chalk";
4721
+ var TERMINAL_CAMPAIGN_STATES = /* @__PURE__ */ new Set(["completed", "failed", "canceled"]);
4722
+ var CAMPAIGN_LIST_FIELDS = "idCampaign,name,state,source,totalLeads,maxLeads,leadsCompletionPercentage,creationTimestamp";
4723
+ function buildArea(opts) {
4724
+ const given = ["circle", "polygon", "bounds"].filter((k) => opts[k] !== void 0);
4725
+ if (given.length !== 1) {
4726
+ throw new Error("Pass exactly one area: --circle <lat,lng> --radius <m> | --polygon <json|@file> | --bounds <nwLat,nwLng,seLat,seLng>");
4727
+ }
4728
+ if (opts.circle !== void 0) {
4729
+ if (opts.radius === void 0) throw new Error("--circle requires --radius <meters>");
4730
+ return {
4731
+ type: "circle",
4732
+ center: parseLatLng(opts.circle, "--circle"),
4733
+ radiusMeters: Number(opts.radius)
4734
+ };
4735
+ }
4736
+ if (opts.bounds !== void 0) {
4737
+ const n = opts.bounds.split(",").map((p) => Number(p.trim()));
4738
+ if (n.length !== 4 || n.some((x) => !Number.isFinite(x))) {
4739
+ throw new Error('--bounds must be "nwLat,nwLng,seLat,seLng"');
4740
+ }
4741
+ return { type: "bounds", northWest: [n[0], n[1]], southEast: [n[2], n[3]] };
4742
+ }
4743
+ const parsed = readJsonArg(opts.polygon, "--polygon");
4744
+ const geometry = parsed?.type === "Feature" ? parsed.geometry : parsed;
4745
+ if (geometry?.type === "Polygon") {
4746
+ const ring = geometry.coordinates?.[0];
4747
+ if (!Array.isArray(ring)) throw new Error("GeoJSON Polygon has no outer ring");
4748
+ return { type: "polygon", coordinates: ring.map(([lng, lat]) => [lat, lng]) };
4749
+ }
4750
+ if (!Array.isArray(parsed)) {
4751
+ throw new Error("--polygon must be an array of [lat,lng] points or a GeoJSON Polygon");
4752
+ }
4753
+ return { type: "polygon", coordinates: parsed };
4754
+ }
4755
+ function registerSatvoltCampaignCommands(satvolt) {
4756
+ const campaigns = satvolt.command("campaigns").description("Satvolt campaigns: list, create from an area, start, pause, cancel, reset, resume, usage, logs and funnel.");
4757
+ campaigns.command("list").description(
4758
+ 'List campaigns, newest first.\nExamples:\n suntropy satvolt campaigns list --state completed,inProgress\n suntropy satvolt campaigns list --search "Alcobendas" --format human'
4759
+ ).option("--limit <n>", "Max results (max 200)", "50").option("--offset <n>", "Skip results", "0").option("--search <text>", "Match name, region or input address").option("--state <states>", "Comma-separated campaign states (see: satvolt catalog states)").option("--source <source>", "maps | excel | campaign").action(async (opts) => {
4760
+ const global = getGlobalOpts17(campaigns);
4761
+ try {
4762
+ const client = satvoltClient(global);
4763
+ const page = await call(client, "get", "/campaigns", {
4764
+ params: { limit: opts.limit, offset: opts.offset, search: opts.search, state: opts.state, source: opts.source }
4765
+ });
4766
+ const outOpts = { ...global, fields: global.fields ?? (global.format !== "json" ? CAMPAIGN_LIST_FIELDS : void 0) };
4767
+ if (outOpts.format === "csv") output(page.items, outOpts);
4768
+ else outputPaginated(page.items, page.total, page.limit, page.offset, outOpts);
4769
+ } catch (err) {
4770
+ outputError(satvoltError(err));
4771
+ }
4772
+ });
4773
+ campaigns.command("get <campaignId>").description("Campaign detail: area, lead counts by state and pipeline configuration.").action(async (campaignId) => {
4774
+ const global = getGlobalOpts17(campaigns);
4775
+ try {
4776
+ const data = await call(satvoltClient(global), "get", `/campaigns/${parseId(campaignId, "campaignId")}`);
4777
+ output(data, global);
4778
+ } catch (err) {
4779
+ outputError(satvoltError(err));
4780
+ }
4781
+ });
4782
+ campaigns.command("create").summary("Create a Maps campaign over an area, optionally from a template or campaign.").description(
4783
+ `Create a Maps campaign over an area. It stays queued unless --start is passed
4784
+ (same as the web app). Steps are the LEAD steps only: SECTORIZE, FIND_LEADS
4785
+ and COMPLETE are added by the backend, and missing dependencies are added too.
4786
+
4787
+ Area (exactly one):
4788
+ --circle <lat,lng> --radius <meters> stored as a circle (100 m - 50 km)
4789
+ --bounds <nwLat,nwLng,seLat,seLng> rectangle
4790
+ --polygon <json|@file|-> [[lat,lng],...] or GeoJSON; searched as its
4791
+ bounding rectangle (a warning is returned)
4792
+
4793
+ Examples:
4794
+ suntropy satvolt campaigns create --name "Pol\xEDgono Cobo Calleja" \\
4795
+ --circle 40.2597,-3.7545 --radius 1500 --max-leads 300 \\
4796
+ --business-groups businesses --steps @steps.json
4797
+ suntropy satvolt campaigns create --name "Test" --bounds 40.45,-3.70,40.44,-3.68 \\
4798
+ --steps '[{"action":"FIND_ROOFTOP"},{"action":"SOLAR_ANALYSIS"}]' --start
4799
+ suntropy satvolt campaigns create --data @campaign.json (full request body)
4800
+
4801
+ Base configuration (optional, one of them): steps, business groups, description,
4802
+ search query and lead limit come from it; any flag you pass wins.
4803
+ --template <id|name> a campaign template (see: satvolt templates list)
4804
+ --from-campaign <id> copy the pipeline of another Maps campaign
4805
+ suntropy satvolt campaigns create --name "Sonda Hu\xE9var" --template "Greenvolt industria" \\
4806
+ --circle 37.3509,-6.2757 --radius 5000 --max-leads 50`
4807
+ ).option("--name <name>", "Campaign name").option("--template <idOrName>", "Base the campaign on a campaign template").option("--from-campaign <id>", "Base the campaign on the configuration of another Maps campaign").option("--description <text>", "Natural language description of the configuration").option("--circle <lat,lng>", "Circle center").option("--radius <meters>", "Circle radius in meters").option("--bounds <nwLat,nwLng,seLat,seLng>", "Rectangle corners").option("--polygon <json>", "Polygon points, GeoJSON, @file or - for stdin").option("--search-query <text>", "Use Places text search with this query instead of nearby search").option("--max-leads <n>", "Stop discovering leads after this many").option("--business-groups <ids>", "Comma-separated group ids (default: businesses). See: satvolt catalog business-groups").option("--steps <json>", "LEAD steps as JSON array, @file or -. See: satvolt catalog actions").option("--address <text>", "Reference address shown in the web app").option("--region <text>", "Region shown in the web app").option("--start", "Start the pipeline right after creating it (spends credits)").option("--data <json>", "Full request body (JSON, @file or -); flags override its fields").action(async (opts) => {
4808
+ const global = getGlobalOpts17(campaigns);
4809
+ try {
4810
+ const body = opts.data ? readJsonArg(opts.data, "--data") : {};
4811
+ if (opts.name) body.name = opts.name;
4812
+ if (opts.circle !== void 0 || opts.polygon !== void 0 || opts.bounds !== void 0) {
4813
+ body.area = buildArea(opts);
4814
+ }
4815
+ if (!body.name) throw new Error("--name is required");
4816
+ if (!body.area) throw new Error("An area is required: --circle/--radius, --bounds or --polygon");
4817
+ if (opts.template && opts.fromCampaign) throw new Error("Use either --template or --from-campaign, not both");
4818
+ if (opts.template) body.templateId = opts.template;
4819
+ if (opts.fromCampaign) body.fromCampaignId = parseId(opts.fromCampaign, "--from-campaign");
4820
+ if (opts.description !== void 0) body.description = opts.description;
4821
+ if (opts.searchQuery) body.searchQuery = opts.searchQuery;
4822
+ if (opts.maxLeads !== void 0) body.maxLeads = parseIntOption(opts.maxLeads, "--max-leads");
4823
+ if (opts.businessGroups) body.businessGroups = opts.businessGroups.split(",").map((s) => s.trim()).filter(Boolean);
4824
+ if (opts.steps) body.steps = readJsonArg(opts.steps, "--steps");
4825
+ if (opts.address) body.inputAddress = opts.address;
4826
+ if (opts.region) body.region = opts.region;
4827
+ if (opts.start) body.start = true;
4828
+ const data = await call(satvoltClient(global), "post", "/campaigns", { data: body });
4829
+ output(data, global);
4830
+ } catch (err) {
4831
+ outputError(satvoltError(err));
4832
+ }
4833
+ });
4834
+ campaigns.command("estimate").summary("Count the businesses a campaign would find over an area, without creating it.").description(
4835
+ 'Preview of FIND_LEADS: how many businesses Google Maps returns inside the area with\nthe given business groups (nearby search) or text query, plus a sample to inspect.\nNothing is created and no credits are spent. Use it to iterate the filters before\n`campaigns create`: check `places.atLeast`, `byType` and the `sample` names, then\ntighten or widen --business-groups until the sample looks like the target.\n\nThe count is a minimum: the preview has a request budget and is cached (7 days) per\narea, groups and query; `places.exhaustive:false` means dense zones were left out.\nA text query matches place NAMES, not categories ("manufactura" only finds businesses\ncalled "Manufacturas \u2026"): for a category, use business groups without --search-query.\n\nArea (exactly one): --circle <lat,lng> --radius <m> | --bounds <nwLat,nwLng,seLat,seLng>\n| --polygon <json|@file|->. Base (optional): --template or --from-campaign give the\ngroups and query when not passed explicitly.\n\nExamples:\n suntropy satvolt campaigns estimate --circle 43.3934,-3.8445 --radius 1500 \\\n --business-groups businesses --format human\n suntropy satvolt campaigns estimate --circle 43.3934,-3.8445 --radius 1500 \\\n --business-groups industrial_logistics,automotive --sample 50\n suntropy satvolt campaigns estimate --bounds 40.45,-3.70,40.44,-3.68 --template "Industria"'
4836
+ ).option("--circle <lat,lng>", "Circle center").option("--radius <meters>", "Circle radius in meters").option("--bounds <nwLat,nwLng,seLat,seLng>", "Rectangle corners").option("--polygon <json>", "Polygon points, GeoJSON, @file or - for stdin").option("--template <idOrName>", "Take business groups and query from a campaign template").option("--from-campaign <id>", "Take business groups and query from another Maps campaign").option("--business-groups <ids>", "Comma-separated group ids or raw Google Places types. See: satvolt catalog business-groups").option("--search-query <text>", "Places text search with this query instead of nearby search (matches names, not categories)").option("--sample <n>", "Sample places to return (1-100, default 20)").option("--offset <n>", "Skip this many sample places (to page through them)").action(async (opts) => {
4837
+ const global = getGlobalOpts17(campaigns);
4838
+ try {
4839
+ const body = { area: buildArea(opts) };
4840
+ if (opts.template && opts.fromCampaign) throw new Error("Use either --template or --from-campaign, not both");
4841
+ if (opts.template) body.templateId = opts.template;
4842
+ if (opts.fromCampaign) body.fromCampaignId = parseId(opts.fromCampaign, "--from-campaign");
4843
+ if (opts.businessGroups) body.businessGroups = opts.businessGroups.split(",").map((s) => s.trim()).filter(Boolean);
4844
+ if (opts.searchQuery) body.searchQuery = opts.searchQuery;
4845
+ if (opts.sample !== void 0) body.sample = parseIntOption(opts.sample, "--sample");
4846
+ if (opts.offset !== void 0) body.offset = parseIntOption(opts.offset, "--offset");
4847
+ const data = await call(satvoltClient(global, 12e4), "post", "/campaigns/estimate", { data: body });
4848
+ output(data, global);
4849
+ } catch (err) {
4850
+ outputError(satvoltError(err));
4851
+ }
4852
+ });
4853
+ campaigns.command("delete <campaignId>").summary("Delete a campaign and everything it generated (irreversible, --yes).").description(
4854
+ "Delete a campaign with its sectors, leads, step executions, pipeline configuration\nand queued jobs. Irreversible. Export tables of the campaign stop working.\nExample:\n suntropy satvolt campaigns delete 63 --yes"
4855
+ ).option("--yes", "Confirm the deletion (required)").action(async (campaignId, opts) => {
4856
+ const global = getGlobalOpts17(campaigns);
4857
+ try {
4858
+ if (!opts.yes) throw new Error("Deleting a campaign is irreversible. Re-run with --yes to confirm.");
4859
+ const data = await call(satvoltClient(global), "delete", `/campaigns/${parseId(campaignId, "campaignId")}`);
4860
+ output(data, global);
4861
+ } catch (err) {
4862
+ outputError(satvoltError(err));
4863
+ }
4864
+ });
4865
+ campaigns.command("start <campaignId>").description("Start the pipeline of a queued campaign (spends credits).").action(async (campaignId) => {
4866
+ const global = getGlobalOpts17(campaigns);
4867
+ try {
4868
+ const data = await call(satvoltClient(global), "post", `/campaigns/${parseId(campaignId, "campaignId")}/start`);
4869
+ output(data, global);
4870
+ } catch (err) {
4871
+ outputError(satvoltError(err));
4872
+ }
4873
+ });
4874
+ campaigns.command("pause <campaignId>").summary("Pause a running campaign: nothing else runs or is charged until unpause.").description(
4875
+ "Pause a running campaign. Queued work is withdrawn and steps already in flight\nfinish without queuing their successors, so no more credits are spent. Results\nof async steps whose webhook arrives meanwhile are kept. Only a running\ncampaign (inProgress, sectorized, leadsFound, analyzed) can be paused;\notherwise 409 INVALID_CAMPAIGN_STATE.\nExample:\n suntropy satvolt campaigns pause 72"
4876
+ ).action(async (campaignId) => {
4877
+ const global = getGlobalOpts17(campaigns);
4878
+ try {
4879
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/pause`);
4880
+ output(data, global);
4881
+ } catch (err) {
4882
+ outputError(satvoltError(err));
4883
+ }
4884
+ });
4885
+ campaigns.command("unpause <campaignId>").summary("Resume a paused campaign where it stopped (no step is run twice).").description(
4886
+ "Resume a paused campaign where it stopped: every lead that is not finished gets\nits next pending step queued, and steps already executed are neither re-run\nnor charged again. Only a paused campaign can be unpaused. This is not\n`resume`, which appends a NEW step to a finished campaign.\nExample:\n suntropy satvolt campaigns unpause 72"
4887
+ ).action(async (campaignId) => {
4888
+ const global = getGlobalOpts17(campaigns);
4889
+ try {
4890
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/unpause`);
4891
+ output(data, global);
4892
+ } catch (err) {
4893
+ outputError(satvoltError(err));
4894
+ }
4895
+ });
4896
+ campaigns.command("cancel <campaignId>").summary("Cancel a running or paused campaign for good, keeping its leads and data (--yes).").description(
4897
+ "Cancel a running, paused or queued campaign. Irreversible: it cannot be unpaused\nor started again (reset relaunches it from scratch, deleting the leads). Leads\nand the data already enriched are kept, can be exported and are still charged.\nExample:\n suntropy satvolt campaigns cancel 72 --yes"
4898
+ ).option("--yes", "Confirm the cancellation (required)").action(async (campaignId, opts) => {
4899
+ const global = getGlobalOpts17(campaigns);
4900
+ try {
4901
+ if (!opts.yes) throw new Error("Cancelling a campaign is irreversible. Re-run with --yes to confirm.");
4902
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/cancel`);
4903
+ output(data, global);
4904
+ } catch (err) {
4905
+ outputError(satvoltError(err));
4906
+ }
4907
+ });
4908
+ campaigns.command("reset <campaignId>").summary("Relaunch a campaign from scratch: deletes its leads and results (--yes).").description(
4909
+ "Relaunch a campaign from scratch: deletes its sectors, leads and processing data\nand returns it to queued, keeping the configuration. Excel/campaign-sourced\ncampaigns keep their imported leads. Irreversible.\nExample:\n suntropy satvolt campaigns reset 59 --yes --start"
4910
+ ).option("--start", "Start the pipeline again after resetting").option("--yes", "Confirm the reset (required)").action(async (campaignId, opts) => {
4911
+ const global = getGlobalOpts17(campaigns);
4912
+ try {
4913
+ if (!opts.yes) throw new Error("Reset deletes the campaign leads and results. Re-run with --yes to confirm.");
4914
+ const data = await call(satvoltClient(global), "post", `/campaigns/${parseId(campaignId, "campaignId")}/reset`, {
4915
+ params: { start: opts.start ? "true" : void 0 }
4916
+ });
4917
+ output(data, global);
4918
+ } catch (err) {
4919
+ outputError(satvoltError(err));
4920
+ }
4921
+ });
4922
+ campaigns.command("extend <campaignId>").summary("Get more leads from a finished Maps campaign without relaunching it.").description(
4923
+ "Get more leads from a finished Maps campaign without relaunching it: raises (or\nremoves) its lead limit and searches again only in the sectors whose search was\ncut short. Existing leads are kept and only the new ones go through the pipeline\n(spends credits). `campaigns get` shows sectorSearch: incomplete + unknown > 0\nmeans more leads can still be found; 0 means the area is exhausted.\nCost: new leads x credits per lead (`campaigns usage` \u2192 avgCreditsPerLead). With\n--max-leads the new leads are at most the difference; with --no-limit estimate them\nfrom the leads found per sector so far.\nExamples:\n suntropy satvolt campaigns extend 62 --max-leads 500\n suntropy satvolt campaigns extend 62 --no-limit"
4924
+ ).option("--max-leads <n>", "New lead limit (must be greater than the current number of leads)").option("--no-limit", "Remove the lead limit and search every pending sector fully").action(async (campaignId, opts) => {
4925
+ const global = getGlobalOpts17(campaigns);
4926
+ try {
4927
+ const removeLimit = opts.limit === false;
4928
+ if (removeLimit === (opts.maxLeads !== void 0)) {
4929
+ throw new Error("Pass exactly one of --max-leads <n> or --no-limit.");
4930
+ }
4931
+ const maxLeads = removeLimit ? null : parseIntOption(opts.maxLeads, "--max-leads");
4932
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/extend`, {
4933
+ data: { maxLeads }
4934
+ });
4935
+ output(data, global);
4936
+ } catch (err) {
4937
+ outputError(satvoltError(err));
4938
+ }
4939
+ });
4940
+ campaigns.command("resume <campaignId>").summary("Append a new step at the end and run it over the existing leads.").description(
4941
+ 'Resume a finished campaign from a NEW step: appends it at the end of the pipeline\n(before COMPLETE) and runs it over the existing leads that reached the previous\nstep. Missing dependencies are added before it. If the step cannot run, the\nconfiguration change is rolled back. --config is only the config object of the step\n(see configSchema in `satvolt catalog actions`), not { action, config }.\nCost: leads that reached the last step (`campaigns funnel`) x creditCost of the action.\nExamples:\n suntropy satvolt campaigns resume 59 --action ESTIMATE_CONSUMPTION --config \'{"tariffTemplate":"3.0TD"}\'\n suntropy satvolt campaigns resume 59 --action AI_AGENT \\\n --config \'{"customName":"Web corporativa","agentId":"<id from catalog ai-agents>","outputKey":"web"}\''
4942
+ ).requiredOption("--action <ACTION>", "Action to add (see: satvolt catalog actions)").option("--config <json>", "Step config as JSON, @file or -").action(async (campaignId, opts) => {
4943
+ const global = getGlobalOpts17(campaigns);
4944
+ try {
4945
+ const step = { action: String(opts.action).toUpperCase() };
4946
+ if (opts.config) step.config = readJsonArg(opts.config, "--config");
4947
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/resume`, {
4948
+ data: { step }
4949
+ });
4950
+ output(data, global);
4951
+ } catch (err) {
4952
+ outputError(satvoltError(err));
4953
+ }
4954
+ });
4955
+ campaigns.command("usage <campaignId>").description(
4956
+ "Credits consumed by the campaign, as shown in the Usage tab: total, per lead\naverage and per step. --by-lead adds the per-lead breakdown."
4957
+ ).option("--by-lead", "Include the per-lead breakdown").action(async (campaignId, opts) => {
4958
+ const global = getGlobalOpts17(campaigns);
4959
+ try {
4960
+ const data = await call(satvoltClient(global, 12e4), "get", `/campaigns/${parseId(campaignId, "campaignId")}/usage`, {
4961
+ params: { include: opts.byLead ? "leads" : void 0 }
4962
+ });
4963
+ if (global.format === "human" || global.format === "csv") {
4964
+ if (global.format === "human") {
4965
+ process.stderr.write(
4966
+ chalk2.bold(`Total ${data.totalCredits} credits \xB7 ${data.totalLeads} leads \xB7 ${data.avgCreditsPerLead} per lead
4967
+
4968
+ `)
4969
+ );
4970
+ }
4971
+ output(data.byStep, global);
4972
+ return;
4973
+ }
4974
+ output(data, global);
4975
+ } catch (err) {
4976
+ outputError(satvoltError(err));
4977
+ }
4978
+ });
4979
+ campaigns.command("funnel <campaignId>").summary("Leads per pipeline step, by execution or by success criteria").description(
4980
+ "Funnel of the campaign: for each LEAD step, how many leads reached it and how many\nsucceeded, failed, were skipped, are processing (async) or still pending, plus\nthe lead counts by state.\n\n--mode success shows how many leads the step actually brought data for, which is\nnot the same thing: an agent can finish without error answering that it found\nnothing. It uses the `successIf` paths configured on each step, evaluated against\nthe lead's current data \u2014 change the criteria and the numbers change, with no\nre-run. Steps without criteria fall back to their successful executions.\nExamples:\n suntropy satvolt campaigns funnel 62\n suntropy satvolt campaigns funnel 62 --mode success"
4981
+ ).option("--mode <mode>", "execution (default) | success", "execution").action(async (campaignId, opts) => {
4982
+ const global = getGlobalOpts17(campaigns);
4983
+ try {
4984
+ const mode = String(opts.mode || "execution").toLowerCase();
4985
+ if (mode !== "execution" && mode !== "success") {
4986
+ outputError({ code: "INVALID_MODE", message: "--mode must be execution or success" });
4987
+ return;
4988
+ }
4989
+ const data = await call(satvoltClient(global, 12e4), "get", `/campaigns/${parseId(campaignId, "campaignId")}/funnel`);
4990
+ if (global.format === "human" || global.format === "csv") {
4991
+ output(
4992
+ (data.steps ?? []).map(
4993
+ (s) => mode === "success" ? {
4994
+ step: s.name,
4995
+ action: s.action,
4996
+ reached: s.reached,
4997
+ withData: s.criteria ? s.criteria.met : s.success,
4998
+ pctOfTotal: s.criteria ? s.criteria.metPct : s.reachedPct,
4999
+ missingData: s.criteria ? s.criteria.unmet : "",
5000
+ retries: s.criteria?.maxRetries || "",
5001
+ successIf: s.criteria ? s.criteria.paths.join(", ") : "(no criteria)"
5002
+ } : {
5003
+ step: s.name,
5004
+ action: s.action,
5005
+ reached: s.reached,
5006
+ pctOfTotal: s.reachedPct,
5007
+ success: s.success,
5008
+ failure: s.failure,
5009
+ skipped: s.skipped,
5010
+ processing: s.processing,
5011
+ pending: s.pending
5012
+ }
5013
+ ),
5014
+ global
5015
+ );
5016
+ return;
5017
+ }
5018
+ output(data, global);
5019
+ } catch (err) {
5020
+ outputError(satvoltError(err));
5021
+ }
5022
+ });
5023
+ campaigns.command("logs <campaignId>").description(
5024
+ "Campaign processing logs (kept 30 days, last 5000 entries).\n--follow keeps polling and stops by itself when the campaign reaches a final state.\nExamples:\n suntropy satvolt campaigns logs 59 --level error\n suntropy satvolt campaigns logs 59 --follow --format human"
5025
+ ).option("--limit <n>", "Max entries (max 5000)", "500").option("--since <ts>", "Only entries after this epoch-ms timestamp (use lastTs from a previous call)").option("--level <level>", "debug | log | warn | error").option("--follow", "Poll for new entries until the campaign finishes").option("--interval <seconds>", "Polling interval with --follow", "3").action(async (campaignId, opts) => {
5026
+ const global = getGlobalOpts17(campaigns);
5027
+ try {
5028
+ const id = parseId(campaignId, "campaignId");
5029
+ const client = satvoltClient(global);
5030
+ const fetchLogs = (sinceTs) => call(client, "get", `/campaigns/${id}/logs`, {
5031
+ params: { limit: opts.limit, sinceTs, level: opts.level }
5032
+ });
5033
+ const printEntries = (entries) => {
5034
+ for (const e of entries) {
5035
+ if (global.format === "human") {
5036
+ const color = e.level === "error" ? chalk2.red : e.level === "warn" ? chalk2.yellow : chalk2.dim;
5037
+ process.stdout.write(`${chalk2.dim(new Date(e.ts).toISOString())} ${color(e.level.padEnd(5))} ${e.source}: ${e.message}
5038
+ `);
5039
+ } else {
5040
+ process.stdout.write(JSON.stringify(e) + "\n");
5041
+ }
5042
+ }
5043
+ };
5044
+ const first = await fetchLogs(opts.since);
5045
+ if (!opts.follow) {
5046
+ if (global.format === "human") printEntries(first.entries);
5047
+ else output(first, global);
5048
+ return;
5049
+ }
5050
+ printEntries(first.entries);
5051
+ let lastTs = first.lastTs;
5052
+ const intervalMs = Math.max(1, Number(opts.interval) || 3) * 1e3;
5053
+ for (; ; ) {
5054
+ const campaign = await call(client, "get", `/campaigns/${id}`);
5055
+ await new Promise((r) => setTimeout(r, intervalMs));
5056
+ const next = await fetchLogs(lastTs ?? void 0);
5057
+ printEntries(next.entries);
5058
+ lastTs = next.lastTs ?? lastTs;
5059
+ if (TERMINAL_CAMPAIGN_STATES.has(campaign.state)) {
5060
+ process.stderr.write(`Campaign ${id} is ${campaign.state}; stopped following.
5061
+ `);
5062
+ return;
5063
+ }
5064
+ }
5065
+ } catch (err) {
5066
+ outputError(satvoltError(err));
5067
+ }
5068
+ });
5069
+ }
5070
+
5071
+ // src/commands/satvolt/pipeline.ts
5072
+ var STEP_LIST_FIELDS = "index,uid,action,name,disable,creditCost,executedLeads,runnable,reason";
5073
+ async function patchSteps(cmd, campaignId, steps) {
5074
+ const global = getGlobalOpts17(cmd);
5075
+ const data = await call(satvoltClient(global), "patch", `/campaigns/${parseId(campaignId, "campaignId")}/configuration`, {
5076
+ data: { steps }
5077
+ });
5078
+ output(data, global);
5079
+ }
5080
+ function registerSatvoltPipelineCommands(satvolt) {
5081
+ const config = satvolt.command("config").summary("Pipeline configuration of a campaign as JSON (get, update, patch).").description(
5082
+ "Pipeline configuration of a campaign as JSON.\nWorkflow: config get > edit the JSON > config update (full) or config patch (partial)."
5083
+ );
5084
+ config.command("get <campaignId>").description(
5085
+ "Get the configuration: source, businessGroups, description and steps\n(uid, action, target, disable, structural, config).\nExample:\n suntropy satvolt config get 59 --save pipeline.json"
5086
+ ).action(async (campaignId) => {
5087
+ const global = getGlobalOpts17(config);
5088
+ try {
5089
+ const data = await call(satvoltClient(global), "get", `/campaigns/${parseId(campaignId, "campaignId")}/configuration`);
5090
+ output(data, global);
5091
+ } catch (err) {
5092
+ outputError(satvoltError(err));
5093
+ }
5094
+ });
5095
+ config.command("update <campaignId>").description(
5096
+ "Replace the pipeline (PUT). The body is { steps, businessGroups?, description? } where\nsteps is the FULL list of editable steps in order. Structural steps (SECTORIZE,\nFIND_LEADS, IMPORT_LEADS, COMPLETE) are ignored in the input and kept by the backend,\nso the output of `config get` can be sent back as is. Steps without uid get one;\nconfig is validated against each action schema (satvolt catalog actions).\nExample:\n suntropy satvolt config update 59 --data @pipeline.json"
5097
+ ).requiredOption("--data <json>", "Configuration JSON, @file or -").action(async (campaignId, opts) => {
5098
+ const global = getGlobalOpts17(config);
5099
+ try {
5100
+ const body = readJsonArg(opts.data, "--data");
5101
+ const data = await call(satvoltClient(global), "put", `/campaigns/${parseId(campaignId, "campaignId")}/configuration`, {
5102
+ data: body
5103
+ });
5104
+ output(data, global);
5105
+ } catch (err) {
5106
+ outputError(satvoltError(err));
5107
+ }
5108
+ });
5109
+ config.command("patch <campaignId>").description(
5110
+ `Partial change (PATCH). Body: { steps?, businessGroups?, description? }.
5111
+ Each item in steps:
5112
+ { "uid": "<existing>", "config": {...} } merge config (JSON Merge Patch: null resets a key to its default)
5113
+ { "uid": "<existing>", "replaceConfig": true, "config": {...} } replace config
5114
+ { "uid": "<existing>", "disable": true }
5115
+ { "uid": "<existing>", "after": "<uid>" } move (also "before")
5116
+ { "uid": "<existing>", "remove": true }
5117
+ { "action": "QUALIFY", "config": {...} } add (before COMPLETE, or before/after a uid)
5118
+ Example:
5119
+ suntropy satvolt config patch 59 --data '{"steps":[{"uid":"d3f2849c52281f36","config":{"enableWebSearch":true}}]}'`
5120
+ ).requiredOption("--data <json>", "Patch JSON, @file or -").action(async (campaignId, opts) => {
5121
+ const global = getGlobalOpts17(config);
5122
+ try {
5123
+ const body = readJsonArg(opts.data, "--data");
5124
+ const data = await call(satvoltClient(global), "patch", `/campaigns/${parseId(campaignId, "campaignId")}/configuration`, {
5125
+ data: body
5126
+ });
5127
+ output(data, global);
5128
+ } catch (err) {
5129
+ outputError(satvoltError(err));
5130
+ }
5131
+ });
5132
+ const steps = satvolt.command("steps").description("Steps of a campaign pipeline: list with run status, add, set, remove and run.");
5133
+ steps.command("list <campaignId>").description(
5134
+ "List the pipeline steps in order with catalog data (name, credits, async) and run\nstatus: executedLeads, and runnable/reason for steps no lead has executed yet."
5135
+ ).action(async (campaignId) => {
5136
+ const global = getGlobalOpts17(steps);
5137
+ try {
5138
+ const data = await call(satvoltClient(global), "get", `/campaigns/${parseId(campaignId, "campaignId")}/steps`);
5139
+ if (global.format === "json" && !global.fields) {
5140
+ output(data, global);
5141
+ } else {
5142
+ output(data.steps, { ...global, fields: global.fields ?? STEP_LIST_FIELDS });
5143
+ }
5144
+ } catch (err) {
5145
+ outputError(satvoltError(err));
5146
+ }
5147
+ });
5148
+ steps.command("add <campaignId>").description(
5149
+ `Add a step. Goes before COMPLETE unless --before/--after is given. Config defaults
5150
+ from the action schema are filled in and missing dependencies are added.
5151
+ Example:
5152
+ suntropy satvolt steps add 59 --action QUALIFY --config '{"qualificationDefinition":"..."}'`
5153
+ ).requiredOption("--action <ACTION>", "Action (see: satvolt catalog actions)").option("--config <json>", "Step config as JSON, @file or -").option("--uid <uid>", "Explicit uid for the new step").option("--before <uid>", "Insert before this step").option("--after <uid>", "Insert after this step").option("--disabled", "Add it disabled").action(async (campaignId, opts) => {
5154
+ try {
5155
+ const step = { action: String(opts.action).toUpperCase() };
5156
+ if (opts.config) step.config = readJsonArg(opts.config, "--config");
5157
+ if (opts.uid) step.uid = opts.uid;
5158
+ if (opts.before) step.before = opts.before;
5159
+ if (opts.after) step.after = opts.after;
5160
+ if (opts.disabled) step.disable = true;
5161
+ await patchSteps(steps, campaignId, [step]);
5162
+ } catch (err) {
5163
+ outputError(satvoltError(err));
5164
+ }
5165
+ });
5166
+ steps.command("set <campaignId> <stepUid>").description(
5167
+ `Change one step: merge --config into its config (null resets a key to its default), or replace it
5168
+ with --replace-config; enable/disable; move with --before/--after.
5169
+
5170
+ Every step also takes two common config keys, whatever its action:
5171
+ skipIfEmpty path that, when empty, skips the step for that lead
5172
+ successIf paths the step must fill for its result to count as useful, relative
5173
+ to what the step writes (an agent: relative to its response), or
5174
+ absolute with fullData./lead. Feeds \`campaigns funnel --mode success\`
5175
+ maxRetries 0-5. Repeats the step while successIf is not met. Credits are charged
5176
+ once per step, not per attempt; if the last attempt still has no data
5177
+ the lead carries on to the next step.
5178
+ Examples:
5179
+ suntropy satvolt steps set 59 95161b8b080180e4 --config '{"outputKey":"company"}'
5180
+ suntropy satvolt steps set 62 7f3edaf055d60627 --config '{"successIf":["response.linkedinUrl"],"maxRetries":2}'`
5181
+ ).option("--config <json>", "Config JSON, @file or -").option("--replace-config", "Replace the whole config instead of merging").option("--enable", "Enable the step").option("--disable", "Disable the step").option("--before <uid>", "Move before this step").option("--after <uid>", "Move after this step").action(async (campaignId, stepUid, opts) => {
5182
+ try {
5183
+ if (opts.enable && opts.disable) throw new Error("Use either --enable or --disable");
5184
+ const step = { uid: stepUid };
5185
+ if (opts.config) step.config = readJsonArg(opts.config, "--config");
5186
+ if (opts.replaceConfig) step.replaceConfig = true;
5187
+ if (opts.enable) step.disable = false;
5188
+ if (opts.disable) step.disable = true;
5189
+ if (opts.before) step.before = opts.before;
5190
+ if (opts.after) step.after = opts.after;
5191
+ if (Object.keys(step).length === 1) throw new Error("Nothing to change: pass --config, --enable/--disable or --before/--after");
5192
+ await patchSteps(steps, campaignId, [step]);
5193
+ } catch (err) {
5194
+ outputError(satvoltError(err));
5195
+ }
5196
+ });
5197
+ steps.command("remove <campaignId> <stepUid>").description("Remove a step from the pipeline (results already stored on leads are kept).").action(async (campaignId, stepUid) => {
5198
+ try {
5199
+ await patchSteps(steps, campaignId, [{ uid: stepUid, remove: true }]);
5200
+ } catch (err) {
5201
+ outputError(satvoltError(err));
5202
+ }
5203
+ });
5204
+ steps.command("run <campaignId> <stepUid>").description(
5205
+ "Run a step that no lead has executed yet over the existing leads, then continue the\npipeline to the end. Only the first never-executed step with nothing executed\nafter it is runnable (see runnable/reason in `steps list`). Spends credits."
5206
+ ).action(async (campaignId, stepUid) => {
5207
+ const global = getGlobalOpts17(steps);
5208
+ try {
5209
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/steps/${encodeURIComponent(stepUid)}/run`);
5210
+ output(data, global);
5211
+ } catch (err) {
5212
+ outputError(satvoltError(err));
5213
+ }
5214
+ });
5215
+ }
5216
+
5217
+ // src/commands/satvolt/fields.ts
5218
+ var pct = (v) => v === null || v === void 0 ? "-" : `${Math.round(v * 100)}%`;
5219
+ function flatten(catalog, onlyStep) {
5220
+ const rows = [];
5221
+ const push = (group, f, source = f.source) => rows.push({ group, path: f.path, label: f.label, type: f.type, source, coverage: pct(f.coverage), example: f.example ?? null });
5222
+ if (!onlyStep) {
5223
+ for (const f of catalog.lead) push("Lead", f, "lead");
5224
+ for (const f of catalog.synthetic) push("Lead", f, "synthetic");
5225
+ }
5226
+ for (const step of catalog.steps) {
5227
+ const group = `${step.name} [${step.action}]`;
5228
+ for (const f of step.fields) push(group, f);
5229
+ for (const d of step.dynamic) {
5230
+ rows.push({
5231
+ group,
5232
+ path: `${d.path}.*`,
5233
+ label: "-",
5234
+ type: "-",
5235
+ source: "dynamic",
5236
+ coverage: "-",
5237
+ example: d.inferredFrom ? `${d.description} Fields above inferred from campaign ${d.inferredFrom.campaignId}.` : d.description
5238
+ });
5239
+ }
5240
+ }
5241
+ if (!onlyStep) for (const f of catalog.other) push("Other (no current step)", f);
5242
+ return rows;
5243
+ }
5244
+ function registerFieldsCommand(parent, note) {
5245
+ parent.command("fields <campaignId>").summary("Fields for export table columns, grouped by pipeline step.").description(
5246
+ "Data paths for export table columns, grouped by pipeline step. Each step lists the\nfields it declares (available before the campaign has data) plus the ones seen in a\nsample of leads, with column type, coverage and an example. AI agent responses depend\non the agent: until this campaign has answers they are inferred from another campaign\nof yours with the same agent (source: otherCampaign).\nsource: catalog (declared by the step) | observed (seen in leads) | otherCampaign |\n dynamic (shape depends on the step config) | lead | synthetic\n--search matches path and label (labels are in English: consumption, email\u2026).\n" + (note ? `${note}
5247
+ ` : "") + "Examples:\n suntropy satvolt export-tables fields 59 --format human\n suntropy satvolt export-tables fields 59 --step QUALIFY --format human\n suntropy satvolt export-tables fields 59 --search cnae --format human"
5248
+ ).option("--sample <n>", "Leads to sample (max 100)", "25").option("--step <step>", "Only this step: uid, ACTION (if unique), step name or fullData key").option("--search <text>", "Only paths or labels containing this text").action(async (campaignId, opts) => {
5249
+ const global = getGlobalOpts17(parent);
5250
+ try {
5251
+ const catalog = await call(
5252
+ satvoltClient(global, 12e4),
5253
+ "get",
5254
+ `/campaigns/${parseId(campaignId, "campaignId")}/fields`,
5255
+ { params: { sample: opts.sample, step: opts.step } }
5256
+ );
5257
+ const search = typeof opts.search === "string" ? opts.search.toLowerCase() : void 0;
5258
+ if (global.format === "json") {
5259
+ if (search) {
5260
+ const match = (f) => f.path.toLowerCase().includes(search) || f.label?.toLowerCase().includes(search);
5261
+ catalog.lead = catalog.lead.filter(match);
5262
+ catalog.synthetic = catalog.synthetic.filter(match);
5263
+ catalog.other = catalog.other.filter(match);
5264
+ catalog.steps = catalog.steps.map((s) => ({ ...s, fields: s.fields.filter(match) })).filter((s) => s.fields.length > 0);
5265
+ }
5266
+ output(catalog, global);
5267
+ return;
5268
+ }
5269
+ let rows = flatten(catalog, Boolean(opts.step));
5270
+ if (search) {
5271
+ rows = rows.filter(
5272
+ (r) => String(r.path).toLowerCase().includes(search) || String(r.label).toLowerCase().includes(search)
5273
+ );
5274
+ }
5275
+ output(rows, global);
5276
+ } catch (err) {
5277
+ outputError(satvoltError(err));
5278
+ }
5279
+ });
5280
+ }
5281
+
5282
+ // src/commands/satvolt/leads.ts
5283
+ var LEAD_LIST_FIELDS = "idLead,commercialName,state,stateError,lastAction,address,phone,url";
5284
+ function registerSatvoltLeadCommands(satvolt) {
5285
+ const leads = satvolt.command("leads").description("Leads of a campaign: paginated list with state, filters by step, detail and available data fields.");
5286
+ leads.command("list <campaignId>").description(
5287
+ 'List leads with their state (plus stateError, last action and QUALIFY verdicts).\n\nFilters:\n --name <text> commercial name contains (like the web table)\n --search <text> name, address, phone, place id or reference\n --state <a,b> lead states (see: satvolt catalog states)\n --step <step> pipeline step: uid, ACTION (if it appears once), step name\n ("Buscador de CIF") or fullData key (cif)\n --step-status <s> reached (default) | success | failure | skipped | processing | pending\n\nExamples:\n suntropy satvolt leads list 59 --name "logistica" --limit 25\n suntropy satvolt leads list 59 --step QUALIFY --step-status failure\n suntropy satvolt leads list 59 --step 95161b8b080180e4 --step-status pending --format csv'
5288
+ ).option("--limit <n>", "Page size (max 200)", "25").option("--offset <n>", "Skip results", "0").option("--page <n>", "Page number (1-based); overrides --offset").option("--name <text>", "Commercial name contains").option("--search <text>", "Broad search").option("--state <states>", "Comma-separated lead states").option("--step <step>", "Filter by pipeline step: uid, ACTION (if unique), step name or fullData key").option("--step-status <status>", "Status in that step").option("--with-steps", "Include the status of every LEAD step on each lead").action(async (campaignId, opts) => {
5289
+ const global = getGlobalOpts17(leads);
5290
+ try {
5291
+ const limit = Number(opts.limit);
5292
+ const offset = opts.page ? (Math.max(1, Number(opts.page)) - 1) * limit : Number(opts.offset);
5293
+ const page = await call(satvoltClient(global), "get", `/campaigns/${parseId(campaignId, "campaignId")}/leads`, {
5294
+ params: {
5295
+ limit,
5296
+ offset,
5297
+ name: opts.name,
5298
+ search: opts.search,
5299
+ state: opts.state,
5300
+ step: opts.step,
5301
+ stepStatus: opts.stepStatus,
5302
+ include: opts.withSteps ? "steps" : void 0
5303
+ }
5304
+ });
5305
+ const outOpts = { ...global, fields: global.fields ?? (global.format !== "json" ? LEAD_LIST_FIELDS : void 0) };
5306
+ if (outOpts.format === "csv") output(page.items, outOpts);
5307
+ else outputPaginated(page.items, page.total, page.limit, page.offset, outOpts);
5308
+ } catch (err) {
5309
+ outputError(satvoltError(err));
5310
+ }
5311
+ });
5312
+ leads.command("get <campaignId> <leadId>").description(
5313
+ "Lead detail: columns, status of each pipeline step, state history and the list of\nfullData keys. --full-data adds the enriched data (all of it, or only some keys).\nExamples:\n suntropy satvolt leads get 59 1216\n suntropy satvolt leads get 59 1216 --full-data consumptionEstimate,solarPanelAnalysis"
5314
+ ).option("--full-data [keys]", "Include fullData (optionally comma-separated top-level keys)").action(async (campaignId, leadId, opts) => {
5315
+ const global = getGlobalOpts17(leads);
5316
+ try {
5317
+ const fullData = opts.fullData === true ? "true" : opts.fullData;
5318
+ const data = await call(
5319
+ satvoltClient(global),
5320
+ "get",
5321
+ `/campaigns/${parseId(campaignId, "campaignId")}/leads/${parseId(leadId, "leadId")}`,
5322
+ { params: { fullData } }
5323
+ );
5324
+ output(data, global);
5325
+ } catch (err) {
5326
+ outputError(satvoltError(err));
5327
+ }
5328
+ });
5329
+ leads.command("full-data <campaignId> <leadId>").summary("Only the enriched data of a lead (all, some keys or one path).").description(
5330
+ "Print only the enriched data (fullData) of a lead, optionally a subset of keys or a\nsingle nested path.\nExamples:\n suntropy satvolt leads full-data 62 2078\n suntropy satvolt leads full-data 62 2078 --keys consumptionEstimate,cif\n suntropy satvolt leads full-data 62 2078 --path cif.response.extras.cnae"
5331
+ ).option("--keys <keys>", "Comma-separated top-level keys").option("--path <dotted.path>", "Return the value at this path inside fullData").action(async (campaignId, leadId, opts) => {
5332
+ const global = getGlobalOpts17(leads);
5333
+ try {
5334
+ const topKey = opts.path ? String(opts.path).split(".")[0] : void 0;
5335
+ const data = await call(
5336
+ satvoltClient(global),
5337
+ "get",
5338
+ `/campaigns/${parseId(campaignId, "campaignId")}/leads/${parseId(leadId, "leadId")}`,
5339
+ { params: { fullData: topKey ?? opts.keys ?? "true" } }
5340
+ );
5341
+ let value = data.fullData ?? {};
5342
+ if (opts.path) {
5343
+ for (const part of String(opts.path).split(".")) {
5344
+ value = value && typeof value === "object" ? value[part] : void 0;
5345
+ }
5346
+ if (value === void 0) throw new Error(`Path "${opts.path}" not found in the fullData of lead ${leadId}`);
5347
+ }
5348
+ output(value, global);
5349
+ } catch (err) {
5350
+ outputError(satvoltError(err));
5351
+ }
5352
+ });
5353
+ leads.command("run-step <campaignId> <leadId> <step>").summary("Run one pipeline step on a single lead (only that step, or --continue).").description(
5354
+ "Run one pipeline step on a single lead, through the same queue as the pipeline.\nStep: uid, action if it appears once, step name or fullData key (e.g. cif).\nSpends the credits of that step (a failed or skipped run is free).\n default only that step: later steps are not queued and a completed or\n unqualified lead keeps its state (except when re-running QUALIFY)\n --continue continue the pipeline from that step (later steps run again)\n --force skip the check that the lead completed the step dependencies\nExamples:\n suntropy satvolt leads run-step 62 2034 ESTIMATE_CONSUMPTION\n suntropy satvolt leads run-step 62 2081 f8771dae3f963702 --continue"
5355
+ ).option("--continue", "Continue the pipeline after the step").option("--force", "Run even if the lead has not completed the step dependencies").action(async (campaignId, leadId, step, opts) => {
5356
+ const global = getGlobalOpts17(leads);
5357
+ try {
5358
+ const data = await call(
5359
+ satvoltClient(global),
5360
+ "post",
5361
+ `/campaigns/${parseId(campaignId, "campaignId")}/leads/${parseId(leadId, "leadId")}/steps/${encodeURIComponent(step)}/run`,
5362
+ { data: { mode: opts.continue ? "continue" : "only", force: opts.force === true } }
5363
+ );
5364
+ output(data, global);
5365
+ } catch (err) {
5366
+ outputError(satvoltError(err));
5367
+ }
5368
+ });
5369
+ registerFieldsCommand(leads, "Same as `satvolt export-tables fields`.");
5370
+ }
5371
+
5372
+ // src/commands/satvolt/export-tables.ts
5373
+ import { writeFileSync as writeFileSync5 } from "fs";
5374
+ import { resolve } from "path";
5375
+ function parseColumns(value) {
5376
+ const trimmed = value.trim();
5377
+ if (trimmed.startsWith("[") || trimmed.startsWith("@") || trimmed === "-") {
5378
+ const parsed = readJsonArg(value, "--columns");
5379
+ if (!Array.isArray(parsed)) throw new Error("--columns JSON must be an array");
5380
+ return parsed;
5381
+ }
5382
+ return trimmed.split(";").map((part) => {
5383
+ const m = /^(.+?)=([^:]+)(?::(string|number|boolean|date|url))?$/.exec(part.trim());
5384
+ if (!m) throw new Error(`Invalid column "${part}". Use "Label=path[:type]" separated by ";" or a JSON array`);
5385
+ return { label: m[1].trim(), path: m[2].trim(), ...m[3] ? { type: m[3] } : {} };
5386
+ });
5387
+ }
5388
+ function registerSatvoltExportTableCommands(satvolt) {
5389
+ const tables = satvolt.command("export-tables").summary("Export tables: column sets over lead data, paged reads and XLSX/CSV downloads.").description(
5390
+ "Export tables of a campaign: named column sets over lead data, readable page by page\nand downloadable as XLSX or CSV. Column paths: lead.<column>, fullData.<path> and\nsynthetic.googleMapsUrl. `export-tables fields <campaignId>` lists them by pipeline step."
5391
+ );
5392
+ registerFieldsCommand(tables, "Same as `satvolt leads fields`.");
5393
+ tables.command("list <campaignId>").description("List the export tables of a campaign.").action(async (campaignId) => {
5394
+ const global = getGlobalOpts17(tables);
5395
+ try {
5396
+ const data = await call(satvoltClient(global), "get", `/campaigns/${parseId(campaignId, "campaignId")}/export-tables`);
5397
+ output(global.format === "json" ? data : data.map((t) => ({ ...t, columns: t.columns.length })), global);
5398
+ } catch (err) {
5399
+ outputError(satvoltError(err));
5400
+ }
5401
+ });
5402
+ tables.command("get <tableId>").description("Get an export table with its columns.").action(async (tableId) => {
5403
+ const global = getGlobalOpts17(tables);
5404
+ try {
5405
+ output(await call(satvoltClient(global), "get", `/export-tables/${tableId}`), global);
5406
+ } catch (err) {
5407
+ outputError(satvoltError(err));
5408
+ }
5409
+ });
5410
+ tables.command("create <campaignId>").summary("Create an export table with its columns.").description(
5411
+ 'Create an export table. Column ids are generated when omitted. Without a type, the\ncolumn takes the one declared by the step that writes the path, or string.\nFind paths with: suntropy satvolt export-tables fields <campaignId>\nExamples:\n suntropy satvolt export-tables create 59 --name "CRM" \\\n --columns "Empresa=lead.commercialName;Web=lead.url;Consumo kWh=fullData.consumptionEstimate.annualKwh;Maps=synthetic.googleMapsUrl"\nfullData paths depend on the steps of each campaign (an AI agent with outputKey "cif"\nwrites fullData.cif.response.<field>): take them from `export-tables fields`.\nSynthetic paths: synthetic.googleMapsUrl (Google Maps link built from the coordinates).\nThe response is the table: { id, campaignId, name, description, columns, warnings }.\n suntropy satvolt export-tables create 59 --data @table.json ({ name, description?, columns })'
5412
+ ).option("--name <name>", "Table name").option("--description <text>", "Description").option("--columns <spec>", '"Label=path[:type];..." or JSON array, @file or -').option("--data <json>", "Full body as JSON, @file or -; flags override its fields").action(async (campaignId, opts) => {
5413
+ const global = getGlobalOpts17(tables);
5414
+ try {
5415
+ const body = opts.data ? readJsonArg(opts.data, "--data") : {};
5416
+ if (opts.name) body.name = opts.name;
5417
+ if (opts.description) body.description = opts.description;
5418
+ if (opts.columns) body.columns = parseColumns(opts.columns);
5419
+ if (!body.name) throw new Error("--name is required");
5420
+ if (!Array.isArray(body.columns) || body.columns.length === 0) throw new Error("--columns is required");
5421
+ const data = await call(satvoltClient(global), "post", `/campaigns/${parseId(campaignId, "campaignId")}/export-tables`, { data: body });
5422
+ output(data, global);
5423
+ } catch (err) {
5424
+ outputError(satvoltError(err));
5425
+ }
5426
+ });
5427
+ tables.command("update <tableId>").description(
5428
+ "Replace a table (PUT): name and the full columns list are required. Keep column ids\nfrom `export-tables get` to preserve them.\nExample:\n suntropy satvolt export-tables get 6650... --save table.json # edit, then:\n suntropy satvolt export-tables update 6650... --data @table.json"
5429
+ ).requiredOption("--data <json>", "{ name, description?, columns } as JSON, @file or -").action(async (tableId, opts) => {
5430
+ const global = getGlobalOpts17(tables);
5431
+ try {
5432
+ const body = readJsonArg(opts.data, "--data");
5433
+ output(await call(satvoltClient(global), "put", `/export-tables/${tableId}`, { data: body }), global);
5434
+ } catch (err) {
5435
+ outputError(satvoltError(err));
5436
+ }
5437
+ });
5438
+ tables.command("patch <tableId>").summary("Change the name, description or the whole column list.").description(
5439
+ 'Change the name, the description or the whole column list. To add, edit, move or\nremove single columns use `export-tables columns`.\nExample:\n suntropy satvolt export-tables patch 6650... --name "CRM v2"'
5440
+ ).option("--name <name>", "New name").option("--description <text>", "New description").option("--columns <spec>", 'Replace all columns: "Label=path[:type];..." or JSON').action(async (tableId, opts) => {
5441
+ const global = getGlobalOpts17(tables);
5442
+ try {
5443
+ const body = {};
5444
+ if (opts.name) body.name = opts.name;
5445
+ if (opts.description !== void 0) body.description = opts.description;
5446
+ if (opts.columns) body.columns = parseColumns(opts.columns);
5447
+ if (Object.keys(body).length === 0) throw new Error("Nothing to change");
5448
+ output(await call(satvoltClient(global), "patch", `/export-tables/${tableId}`, { data: body }), global);
5449
+ } catch (err) {
5450
+ outputError(satvoltError(err));
5451
+ }
5452
+ });
5453
+ registerColumnCommands(tables);
5454
+ tables.command("delete <tableId>").description("Delete an export table.").action(async (tableId) => {
5455
+ const global = getGlobalOpts17(tables);
5456
+ try {
5457
+ output(await call(satvoltClient(global), "delete", `/export-tables/${tableId}`), global);
5458
+ } catch (err) {
5459
+ outputError(satvoltError(err));
5460
+ }
5461
+ });
5462
+ tables.command("duplicate <tableId>").description("Copy a table (its columns) into another campaign.").requiredOption("--campaign <campaignId>", "Target campaign").option("--name <name>", "Name of the copy (default: same name)").action(async (tableId, opts) => {
5463
+ const global = getGlobalOpts17(tables);
5464
+ try {
5465
+ const data = await call(satvoltClient(global), "post", `/export-tables/${tableId}/duplicate`, {
5466
+ data: { targetCampaignId: parseId(opts.campaign, "--campaign"), name: opts.name }
5467
+ });
5468
+ output(data, global);
5469
+ } catch (err) {
5470
+ outputError(satvoltError(err));
5471
+ }
5472
+ });
5473
+ tables.command("data <tableId>").description(
5474
+ "Read table rows page by page, one object per lead keyed by column label.\nExample:\n suntropy satvolt export-tables data 6650... --limit 100 --format csv"
5475
+ ).option("--limit <n>", "Page size (max 500)", "50").option("--offset <n>", "Skip rows", "0").option("--search <text>", "Name, address or phone contains").action(async (tableId, opts) => {
5476
+ const global = getGlobalOpts17(tables);
5477
+ try {
5478
+ const page = await call(satvoltClient(global), "get", `/export-tables/${tableId}/data`, {
5479
+ params: { limit: opts.limit, offset: opts.offset, search: opts.search }
5480
+ });
5481
+ const columns = page.columns;
5482
+ const rows = page.items.map((r) => {
5483
+ const row = { "Lead ID": r.idLead };
5484
+ for (const c of columns) row[c.label] = r.values[c.id] ?? null;
5485
+ return row;
5486
+ });
5487
+ if (global.format === "csv") output(rows, global);
5488
+ else outputPaginated(rows, page.total, page.limit, page.offset, global);
5489
+ } catch (err) {
5490
+ outputError(satvoltError(err));
5491
+ }
5492
+ });
5493
+ tables.command("export <tableId>").description(
5494
+ "Download every row of the table (up to 100k) as XLSX or CSV (UTF-8 with BOM).\nExample:\n suntropy satvolt export-tables export 6650... --file-format csv --out leads.csv"
5495
+ ).option("--file-format <format>", "xlsx | csv", "xlsx").option("--out <path>", "Output file (default: the name suggested by the server, in the current directory)").action(async (tableId, opts) => {
5496
+ const global = getGlobalOpts17(tables);
5497
+ try {
5498
+ const format = String(opts.fileFormat).toLowerCase();
5499
+ if (format !== "xlsx" && format !== "csv") throw new Error("--file-format must be xlsx or csv");
5500
+ const client = satvoltClient(global, 3e5);
5501
+ const res = await client.get(`/export-tables/${tableId}/export`, {
5502
+ params: { format },
5503
+ responseType: "arraybuffer"
5504
+ });
5505
+ const disposition = String(res.headers["content-disposition"] ?? "");
5506
+ const suggested = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `export-${tableId}.${format}`;
5507
+ const path = resolve(opts.out ?? suggested);
5508
+ const buffer = Buffer.from(res.data);
5509
+ writeFileSync5(path, buffer);
5510
+ output(
5511
+ {
5512
+ saved: path,
5513
+ format,
5514
+ bytes: buffer.length,
5515
+ rows: res.headers["x-row-count"] !== void 0 ? Number(res.headers["x-row-count"]) : null
5516
+ },
5517
+ global
5518
+ );
5519
+ } catch (err) {
5520
+ outputError(satvoltError(err));
5521
+ }
5522
+ });
5523
+ }
5524
+ var normalizeLabel = (s) => s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toLowerCase();
5525
+ function resolveColumn(columns, ref) {
5526
+ const byId = columns.find((c) => c.id === ref);
5527
+ if (byId) return byId;
5528
+ const byLabel = columns.filter((c) => normalizeLabel(c.label) === normalizeLabel(ref));
5529
+ if (byLabel.length === 1) return byLabel[0];
5530
+ if (byLabel.length > 1) {
5531
+ throw new Error(`Label "${ref}" matches several columns; use one of the ids: ${byLabel.map((c) => c.id).join(", ")}`);
5532
+ }
5533
+ throw new Error(
5534
+ `No column "${ref}". Columns: ${columns.map((c) => `${c.id} (${c.label})`).join(", ") || "(none)"}`
5535
+ );
5536
+ }
5537
+ function targetPosition(columns, opts, moving) {
5538
+ const given = [opts.position, opts.before, opts.after].filter((v) => v !== void 0).length;
5539
+ if (given > 1) throw new Error("Use only one of --position, --before or --after");
5540
+ if (opts.position !== void 0) return parseIntOption(opts.position, "--position");
5541
+ const anchorRef = opts.before ?? opts.after;
5542
+ if (anchorRef === void 0) return void 0;
5543
+ const others = columns.filter((c) => c.id !== moving?.id);
5544
+ const anchor = resolveColumn(others, anchorRef);
5545
+ const index = others.findIndex((c) => c.id === anchor.id);
5546
+ return opts.before !== void 0 ? index : index + 1;
5547
+ }
5548
+ function registerColumnCommands(tables) {
5549
+ const columns = tables.command("columns").summary("Add, edit, move, reorder or remove single columns of an export table.").description(
5550
+ "Edit one column at a time without resending the whole list. Columns are referenced\nby id or by label (case and accents ignored; if two share a label, use the id).\nPositions are 0-based (0 = first column). Each change returns { table, column, warnings }.\nFind paths with: suntropy satvolt export-tables fields <campaignId>"
5551
+ );
5552
+ const list = async (client, tableId) => call(client, "get", `/export-tables/${tableId}/columns`);
5553
+ columns.command("list <tableId>").description("List the columns of a table in order, with their position.").action(async (tableId) => {
5554
+ const global = getGlobalOpts17(tables);
5555
+ try {
5556
+ const cols = await list(satvoltClient(global), tableId);
5557
+ output(cols.map((c, position) => ({ position, ...c })), global);
5558
+ } catch (err) {
5559
+ outputError(satvoltError(err));
5560
+ }
5561
+ });
5562
+ columns.command("add <tableId>").summary("Add one or more columns, optionally at a position.").description(
5563
+ 'Add a column (at the end unless --position, --before or --after). Without --type the\nserver uses the type the pipeline step declares for the path, or string. --columns adds\nseveral at once.\nExamples:\n suntropy satvolt export-tables columns add 6650... --label "Consumo kWh" --path fullData.consumptionEstimate.annualKwh\n suntropy satvolt export-tables columns add 6650... --label CIF --path fullData.cif.response.cif --after Empresa\n suntropy satvolt export-tables columns add 6650... --columns "Web=lead.url:url;Tel\xE9fono=lead.phone"'
5564
+ ).option("--label <label>", "Column header").option("--path <path>", "lead.<column>, fullData.<path> or synthetic.googleMapsUrl").option("--type <type>", "string | number | boolean | date | url").option("--id <id>", "Column id (letters, digits, _ or -); generated when omitted").option("--columns <spec>", 'Several columns: "Label=path[:type];..." or JSON array').option("--position <n>", "0-based position").option("--before <column>", "Insert before this column (id or label)").option("--after <column>", "Insert after this column (id or label)").action(async (tableId, opts) => {
5565
+ const global = getGlobalOpts17(tables);
5566
+ try {
5567
+ const client = satvoltClient(global);
5568
+ const specs = opts.columns ? parseColumns(opts.columns) : [{ label: opts.label, path: opts.path, ...opts.type ? { type: opts.type } : {}, ...opts.id ? { id: opts.id } : {} }];
5569
+ if (opts.columns && (opts.label || opts.path || opts.type || opts.id)) {
5570
+ throw new Error("--columns cannot be combined with --label/--path/--type/--id");
5571
+ }
5572
+ if (specs.some((c) => !c.label || !c.path)) throw new Error("--label and --path are required (or --columns)");
5573
+ let position = targetPosition(await list(client, tableId), opts);
5574
+ let result;
5575
+ const added = [];
5576
+ const warnings = [];
5577
+ for (const spec of specs) {
5578
+ result = await call(client, "post", `/export-tables/${tableId}/columns`, {
5579
+ data: { ...spec, ...position !== void 0 ? { position } : {} }
5580
+ });
5581
+ added.push(result.column);
5582
+ warnings.push(...result.warnings ?? []);
5583
+ if (position !== void 0) position++;
5584
+ }
5585
+ output(specs.length === 1 ? result : { table: result.table, columns: added, warnings }, global);
5586
+ } catch (err) {
5587
+ outputError(satvoltError(err));
5588
+ }
5589
+ });
5590
+ columns.command("set <tableId> <column>").summary("Change the label, path, type or position of one column.").description(
5591
+ 'Change the label, path, type and/or position of one column (id or label). What is not\ngiven stays the same; changing the path keeps the type unless --type is given.\nExamples:\n suntropy satvolt export-tables columns set 6650... "Consumo kWh" --label "Consumo anual (kWh)"\n suntropy satvolt export-tables columns set 6650... c_1a2b3c4d --path fullData.cif.response.revenue.value --type number'
5592
+ ).option("--label <label>", "New header").option("--path <path>", "New data path").option("--type <type>", "string | number | boolean | date | url").option("--position <n>", "Move to this 0-based position").option("--before <column>", "Move before this column").option("--after <column>", "Move after this column").action(async (tableId, columnRef, opts) => {
5593
+ const global = getGlobalOpts17(tables);
5594
+ try {
5595
+ const client = satvoltClient(global);
5596
+ const cols = await list(client, tableId);
5597
+ const column = resolveColumn(cols, columnRef);
5598
+ const position = targetPosition(cols, opts, column);
5599
+ const body = {};
5600
+ if (opts.label !== void 0) body.label = opts.label;
5601
+ if (opts.path !== void 0) body.path = opts.path;
5602
+ if (opts.type !== void 0) body.type = opts.type;
5603
+ if (position !== void 0) body.position = position;
5604
+ if (Object.keys(body).length === 0) throw new Error("Nothing to change: give --label, --path, --type or a position");
5605
+ output(await call(client, "patch", `/export-tables/${tableId}/columns/${encodeURIComponent(column.id)}`, { data: body }), global);
5606
+ } catch (err) {
5607
+ outputError(satvoltError(err));
5608
+ }
5609
+ });
5610
+ columns.command("move <tableId> <column>").summary("Move one column to a position, or before/after another.").description(
5611
+ "Move one column to a 0-based position, or before/after another column.\nExamples:\n suntropy satvolt export-tables columns move 6650... Maps --position 0\n suntropy satvolt export-tables columns move 6650... CIF --after Empresa"
5612
+ ).option("--position <n>", "0-based position").option("--before <column>", "Move before this column").option("--after <column>", "Move after this column").action(async (tableId, columnRef, opts) => {
5613
+ const global = getGlobalOpts17(tables);
5614
+ try {
5615
+ const client = satvoltClient(global);
5616
+ const cols = await list(client, tableId);
5617
+ const column = resolveColumn(cols, columnRef);
5618
+ const position = targetPosition(cols, opts, column);
5619
+ if (position === void 0) throw new Error("Give --position, --before or --after");
5620
+ output(
5621
+ await call(client, "patch", `/export-tables/${tableId}/columns/${encodeURIComponent(column.id)}`, { data: { position } }),
5622
+ global
5623
+ );
5624
+ } catch (err) {
5625
+ outputError(satvoltError(err));
5626
+ }
5627
+ });
5628
+ columns.command("reorder <tableId> <columns...>").description(
5629
+ "Set the order of all columns at once (ids or labels, every column exactly once).\nExample:\n suntropy satvolt export-tables columns reorder 6650... Empresa CIF Tel\xE9fono Maps"
5630
+ ).action(async (tableId, refs) => {
5631
+ const global = getGlobalOpts17(tables);
5632
+ try {
5633
+ const client = satvoltClient(global);
5634
+ const cols = await list(client, tableId);
5635
+ const columnIds = refs.map((ref) => resolveColumn(cols, ref).id);
5636
+ output(await call(client, "put", `/export-tables/${tableId}/columns/order`, { data: { columnIds } }), global);
5637
+ } catch (err) {
5638
+ outputError(satvoltError(err));
5639
+ }
5640
+ });
5641
+ columns.command("remove <tableId> <columns...>").summary("Remove one or more columns (ids or labels).").description(
5642
+ "Remove one or more columns (ids or labels).\nExample:\n suntropy satvolt export-tables columns remove 6650... Maps c_1a2b3c4d"
5643
+ ).action(async (tableId, refs) => {
5644
+ const global = getGlobalOpts17(tables);
5645
+ try {
5646
+ const client = satvoltClient(global);
5647
+ const cols = await list(client, tableId);
5648
+ const targets = [...new Map(refs.map((ref) => resolveColumn(cols, ref)).map((c) => [c.id, c])).values()];
5649
+ let result;
5650
+ for (const column of targets) {
5651
+ result = await call(client, "delete", `/export-tables/${tableId}/columns/${encodeURIComponent(column.id)}`);
5652
+ }
5653
+ output({ table: result.table, removed: targets }, global);
5654
+ } catch (err) {
5655
+ outputError(satvoltError(err));
5656
+ }
5657
+ });
5658
+ }
5659
+
5660
+ // src/commands/satvolt/templates.ts
5661
+ var TEMPLATE_LIST_FIELDS = "id,name,description,steps,businessGroups,searchQuery,maxLeads,sourceCampaignId";
5662
+ var splitList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
5663
+ var templatePath = (idOrName) => `/campaign-templates/${encodeURIComponent(idOrName)}`;
5664
+ function registerSatvoltTemplateCommands(satvolt) {
5665
+ const templates = satvolt.command("templates").summary("Campaign templates: reuse a pipeline for probes, final campaigns or new areas.").description(
5666
+ 'Campaign templates keep everything that defines a campaign except its name and area:\npipeline steps (with their uids), business groups, configuration description, search\nquery and lead limit. Reference them by id or by exact name.\nTypical flow:\n satvolt templates create --name "Greenvolt industria" --from-campaign 62\n satvolt campaigns create --name "Sonda Elche" --template "Greenvolt industria" \\\n --circle 38.29,-0.61 --radius 3000 --max-leads 50'
5667
+ );
5668
+ templates.command("list").description("List the campaign templates of your company.").option("--search <text>", "Filter by name").action(async (opts) => {
5669
+ const global = getGlobalOpts17(templates);
5670
+ try {
5671
+ const data = await call(satvoltClient(global), "get", "/campaign-templates", {
5672
+ params: { search: opts.search }
5673
+ });
5674
+ if (global.format === "human" && !global.fields) {
5675
+ output(
5676
+ data.map((t) => ({ ...t, steps: t.steps.map((s) => s.action).join(" \u2192 "), businessGroups: t.businessGroups.join(",") })),
5677
+ { ...global, fields: TEMPLATE_LIST_FIELDS }
5678
+ );
5679
+ return;
5680
+ }
5681
+ output(data, global);
5682
+ } catch (err) {
5683
+ outputError(satvoltError(err));
5684
+ }
5685
+ });
5686
+ templates.command("get <idOrName>").description("Template detail, with its full steps and config.").action(async (idOrName) => {
5687
+ const global = getGlobalOpts17(templates);
5688
+ try {
5689
+ output(await call(satvoltClient(global), "get", templatePath(idOrName)), global);
5690
+ } catch (err) {
5691
+ outputError(satvoltError(err));
5692
+ }
5693
+ });
5694
+ templates.command("create").summary("Create a template from a campaign or from JSON steps.").description(
5695
+ 'Create a template from a campaign (--from-campaign) or from JSON steps.\nSteps are LEAD steps, validated like `campaigns create` (defaults filled, missing\ndependencies added). Business groups default to "businesses".\nExamples:\n suntropy satvolt templates create --name "Greenvolt industria" --from-campaign 62\n suntropy satvolt templates create --name "Solo tejados" --steps \'[{"action":"FIND_ROOFTOP"}]\' --max-leads 100\n suntropy satvolt templates create --data @template.json'
5696
+ ).option("--name <name>", "Template name (unique in your company)").option("--description <text>", "What the template is for").option("--from-campaign <id>", "Copy the configuration of this Maps campaign").option("--steps <json>", "LEAD steps as JSON array, @file or -").option("--business-groups <ids>", "Comma-separated business group ids").option("--configuration-description <text>", "Natural language description copied to campaigns").option("--search-query <text>", "Places text search query").option("--max-leads <n>", "Default lead limit of campaigns created from it").option("--data <json>", "Full request body (JSON, @file or -); flags override its fields").action(async (opts) => {
5697
+ const global = getGlobalOpts17(templates);
5698
+ try {
5699
+ const body = opts.data ? readJsonArg(opts.data, "--data") : {};
5700
+ if (opts.name) body.name = opts.name;
5701
+ if (opts.description !== void 0) body.description = opts.description;
5702
+ if (opts.fromCampaign) body.fromCampaignId = parseId(opts.fromCampaign, "--from-campaign");
5703
+ if (opts.steps) body.steps = readJsonArg(opts.steps, "--steps");
5704
+ if (opts.businessGroups) body.businessGroups = splitList(opts.businessGroups);
5705
+ if (opts.configurationDescription !== void 0) body.configurationDescription = opts.configurationDescription;
5706
+ if (opts.searchQuery) body.searchQuery = opts.searchQuery;
5707
+ if (opts.maxLeads !== void 0) body.maxLeads = parseIntOption(opts.maxLeads, "--max-leads");
5708
+ if (!body.name) throw new Error("--name is required");
5709
+ if (body.fromCampaignId === void 0 && body.steps === void 0) {
5710
+ throw new Error("Pass --from-campaign <id> or --steps <json>");
5711
+ }
5712
+ output(await call(satvoltClient(global), "post", "/campaign-templates", { data: body }), global);
5713
+ } catch (err) {
5714
+ outputError(satvoltError(err));
5715
+ }
5716
+ });
5717
+ templates.command("update <idOrName>").summary("Replace a whole template (PUT).").description(
5718
+ "Replace a template (PUT). The body is the whole template: steps are required and\nomitted fields are cleared. Tip: `templates get <id> --save t.json`, edit, update."
5719
+ ).requiredOption("--data <json>", "Template body (JSON, @file or -)").action(async (idOrName, opts) => {
5720
+ const global = getGlobalOpts17(templates);
5721
+ try {
5722
+ output(
5723
+ await call(satvoltClient(global), "put", templatePath(idOrName), { data: readJsonArg(opts.data, "--data") }),
5724
+ global
5725
+ );
5726
+ } catch (err) {
5727
+ outputError(satvoltError(err));
5728
+ }
5729
+ });
5730
+ templates.command("patch <idOrName>").summary("Change some fields or steps of a template.").description(
5731
+ 'Change some fields of a template. --steps takes step patches by uid, like\n`config patch`: {uid, config} merges (null resets a key to its default),\n{uid, remove: true} deletes, {action, config} without uid adds a step.\nExamples:\n suntropy satvolt templates patch "Greenvolt industria" --max-leads 500\n suntropy satvolt templates patch "Greenvolt industria" --no-max-leads --business-groups businesses\n suntropy satvolt templates patch <id> --steps \'[{"uid":"8771dae3f9637027","config":{"tariffTemplate":"6.1TD"}}]\''
5732
+ ).option("--name <name>", "Rename the template").option("--description <text>", "New description").option("--steps <json>", "Step patches as JSON array, @file or -").option("--business-groups <ids>", "Comma-separated business group ids (empty string: no filter)").option("--configuration-description <text>", "Natural language description copied to campaigns").option("--search-query <text>", "Places text search query (empty string removes it)").option("--max-leads <n>", "Default lead limit").option("--no-max-leads", "Remove the default lead limit").option("--data <json>", "Patch body (JSON, @file or -); flags override its fields").action(async (idOrName, opts) => {
5733
+ const global = getGlobalOpts17(templates);
5734
+ try {
5735
+ const body = opts.data ? readJsonArg(opts.data, "--data") : {};
5736
+ if (opts.name) body.name = opts.name;
5737
+ if (opts.description !== void 0) body.description = opts.description;
5738
+ if (opts.steps) body.steps = readJsonArg(opts.steps, "--steps");
5739
+ if (opts.businessGroups !== void 0) body.businessGroups = splitList(opts.businessGroups);
5740
+ if (opts.configurationDescription !== void 0) body.configurationDescription = opts.configurationDescription;
5741
+ if (opts.searchQuery !== void 0) body.searchQuery = opts.searchQuery === "" ? null : opts.searchQuery;
5742
+ if (opts.maxLeads === false) body.maxLeads = null;
5743
+ else if (opts.maxLeads !== void 0 && opts.maxLeads !== true) body.maxLeads = parseIntOption(opts.maxLeads, "--max-leads");
5744
+ if (Object.keys(body).length === 0) throw new Error("Nothing to change: pass at least one option");
5745
+ output(await call(satvoltClient(global), "patch", templatePath(idOrName), { data: body }), global);
5746
+ } catch (err) {
5747
+ outputError(satvoltError(err));
5748
+ }
5749
+ });
5750
+ templates.command("delete <idOrName>").description("Delete a template. Campaigns created from it are not affected.").option("--yes", "Confirm the deletion (required)").action(async (idOrName, opts) => {
5751
+ const global = getGlobalOpts17(templates);
5752
+ try {
5753
+ if (!opts.yes) throw new Error("Re-run with --yes to delete the template.");
5754
+ output(await call(satvoltClient(global), "delete", templatePath(idOrName)), global);
5755
+ } catch (err) {
5756
+ outputError(satvoltError(err));
5757
+ }
5758
+ });
5759
+ }
5760
+
5761
+ // src/commands/satvolt/index.ts
5762
+ function registerSatvoltCommands(program2) {
5763
+ const satvolt = program2.command("satvolt").description(
5764
+ `Satvolt lead-generation campaigns (public API /satvolt/api/v1), with the same auth token.
5765
+ Preliminary testing phase: every call is pinned to the dev cluster (${SATVOLT_DEV_BASE_URL}),
5766
+ whatever the active profile says. Override with SUNTROPY_SATVOLT_URL, or use a localhost server.
5767
+ Typical flow:
5768
+ satvolt catalog actions actions and their config schema
5769
+ satvolt campaigns create --circle ... --steps @steps.json
5770
+ satvolt campaigns start <id> \xB7 satvolt campaigns logs <id> --follow
5771
+ satvolt campaigns funnel <id> \xB7 satvolt leads list <id> --step QUALIFY
5772
+ satvolt export-tables create <id> ... \xB7 satvolt export-tables export <tableId>
5773
+ satvolt campaigns resume <id> --action AI_AGENT --config @step.json
5774
+ satvolt templates create --name <n> --from-campaign <id> \xB7 campaigns create --template <n>
5775
+ satvolt campaigns extend <id> --max-leads N \xB7 leads run-step <id> <leadId> <step>
5776
+ satvolt campaigns pause <id> \xB7 unpause <id> \xB7 cancel <id> --yes
5777
+ satvolt usage [--month YYYY-MM] credits spent by the account this month`
5778
+ );
5779
+ registerSatvoltCampaignCommands(satvolt);
5780
+ registerSatvoltPipelineCommands(satvolt);
5781
+ registerSatvoltLeadCommands(satvolt);
5782
+ registerSatvoltExportTableCommands(satvolt);
5783
+ registerSatvoltTemplateCommands(satvolt);
5784
+ satvolt.command("usage").summary("Credits spent by the whole account in a calendar month, by campaign and by step.").description(
5785
+ "Credits spent by the whole account in a calendar month (UTC): total, previous\nmonth for comparison, and the breakdown by campaign and by step. It sums every\ncharge in the period, including the Google Maps search (FIND_LEADS), so it\ndoes not have to match `campaigns usage`, which reconstructs the cost of one\ncampaign and leaves the search out.\nExamples:\n suntropy satvolt usage\n suntropy satvolt usage --month 2026-08 --format human"
5786
+ ).option("--month <YYYY-MM>", "Month to report (default: current month)").action(async (opts) => {
5787
+ const global = getGlobalOpts17(satvolt);
5788
+ try {
5789
+ if (opts.month !== void 0 && !/^\d{4}-\d{2}$/.test(String(opts.month))) {
5790
+ throw new Error("--month must be YYYY-MM, e.g. 2026-08");
5791
+ }
5792
+ const data = await call(satvoltClient(global, 12e4), "get", "/usage", { params: { month: opts.month } });
5793
+ if (global.format === "human" || global.format === "csv") {
5794
+ if (global.format === "human") {
5795
+ process.stderr.write(
5796
+ `${data.month}: ${data.credits} credits \xB7 ${data.executions} executions \xB7 previous month (${data.previous.month}): ${data.previous.credits} credits
5797
+
5798
+ `
5799
+ );
5800
+ }
5801
+ output(data.byCampaign, global);
5802
+ return;
5803
+ }
5804
+ output(data, global);
5805
+ } catch (err) {
5806
+ outputError(satvoltError(err));
5807
+ }
5808
+ });
5809
+ const catalog = satvolt.command("catalog").description("Reference data for building campaigns and pipelines.");
5810
+ const catalogEntries = [
5811
+ ["actions", "/catalog/actions", "Pipeline actions: credits per lead (fixed per action; failed or skipped runs are free), dependencies, multiple, and the JSON Schema of their config (the input). The data each step writes: satvolt export-tables fields <campaignId>."],
5812
+ ["ai-agents", "/catalog/ai-agents", "AI agents allowed in AI_AGENT config.agentId: id, name and description. Every agent costs the AI_AGENT credits."],
5813
+ ["business-groups", "/catalog/business-groups", "Business category groups for --business-groups."],
5814
+ ["states", "/catalog/states", "Campaign and lead states."]
5815
+ ];
5816
+ for (const [name, path, description] of catalogEntries) {
5817
+ catalog.command(name).description(description).action(async () => {
5818
+ const global = getGlobalOpts17(catalog);
5819
+ try {
5820
+ output(await call(satvoltClient(global), "get", path), global);
5821
+ } catch (err) {
5822
+ outputError(satvoltError(err));
5823
+ }
5824
+ });
5825
+ }
5826
+ addSummaries(satvolt);
5827
+ }
5828
+ function addSummaries(cmd) {
5829
+ for (const sub of cmd.commands) {
5830
+ const description = sub.description();
5831
+ if (!sub.summary() && description.includes("\n")) {
5832
+ const first = description.split("\n")[0].trim();
5833
+ sub.summary(/[.:)]$/.test(first) ? first.replace(/:$/, ".") : `${first}\u2026`);
5834
+ }
5835
+ addSummaries(sub);
5836
+ }
5837
+ }
5838
+
4610
5839
  // src/access.ts
4611
5840
  var COMMAND_TIERS = ["read", "write", "delete"];
4612
5841
  var TIER_VALUE = { read: 0, write: 1, delete: 2 };
@@ -4642,7 +5871,7 @@ function setStoredCommandProfile(tier) {
4642
5871
  else cfg.commandProfile = tier;
4643
5872
  saveConfig(cfg);
4644
5873
  }
4645
- var DELETE_VERBS = /* @__PURE__ */ new Set(["delete", "delete-batch", "archive"]);
5874
+ var DELETE_VERBS = /* @__PURE__ */ new Set(["delete", "delete-batch", "archive", "reset"]);
4646
5875
  var WRITE_VERBS = /* @__PURE__ */ new Set([
4647
5876
  "create",
4648
5877
  "update",
@@ -4659,7 +5888,20 @@ var WRITE_VERBS = /* @__PURE__ */ new Set([
4659
5888
  "comment",
4660
5889
  "send",
4661
5890
  "calculate-results",
4662
- "optimize-peakpower"
5891
+ "optimize-peakpower",
5892
+ // satvolt: these launch pipeline work (spending credits) or change configuration.
5893
+ "patch",
5894
+ "start",
5895
+ "resume",
5896
+ "run",
5897
+ "run-step",
5898
+ "duplicate",
5899
+ "extend",
5900
+ "move",
5901
+ "reorder",
5902
+ "pause",
5903
+ "unpause",
5904
+ "cancel"
4663
5905
  ]);
4664
5906
  var PATH_OVERRIDES = {
4665
5907
  "studies calculate production": "write"
@@ -4706,7 +5948,7 @@ function keepCommand(cmd, segs, max) {
4706
5948
  }
4707
5949
 
4708
5950
  // src/commands/command-profile.ts
4709
- function getGlobalOpts17(cmd) {
5951
+ function getGlobalOpts18(cmd) {
4710
5952
  let root = cmd;
4711
5953
  while (root.parent) root = root.parent;
4712
5954
  return root.opts();
@@ -4714,7 +5956,7 @@ function getGlobalOpts17(cmd) {
4714
5956
  function registerCommandProfileCommand(program2) {
4715
5957
  program2.command("command-profile [tier]", { hidden: true }).description("Admin: get/set the command access profile (read | write | delete | reset)").action((tier) => {
4716
5958
  try {
4717
- const global = getGlobalOpts17(program2);
5959
+ const global = getGlobalOpts18(program2);
4718
5960
  if (!tier) {
4719
5961
  const resolved = getActiveCommandProfile();
4720
5962
  output(
@@ -4769,7 +6011,7 @@ function registerCommandProfileCommand(program2) {
4769
6011
  }
4770
6012
 
4771
6013
  // src/index.ts
4772
- var CLI_VERSION = true ? "0.11.9" : "0.0.0-dev";
6014
+ var CLI_VERSION = true ? "0.13.0" : "0.0.0-dev";
4773
6015
  function createProgram() {
4774
6016
  const program2 = new Command4();
4775
6017
  program2.name("suntropy").description("Agent-first CLI for Suntropy solar platform. Optimized for programmatic data manipulation and progressive exploration.").version(CLI_VERSION).option("--format <format>", "Output format: json (default), human, csv", "json").option("--fields <fields>", "Comma-separated fields to include in output").option("--server <url>", "Override API server URL").option("--token <jwt>", "Override authentication token").option("--profile <name>", "Use a specific config profile").option("--verbose", "Show HTTP request/response details on stderr").option("--quiet", "Suppress non-data output").option("--save <file>", "Save output to file (also writes to stdout)");
@@ -4785,6 +6027,7 @@ function createProgram() {
4785
6027
  registerTemplatesCommands(program2);
4786
6028
  registerGeocodeCommands(program2);
4787
6029
  registerNotificationsCommands(program2);
6030
+ registerSatvoltCommands(program2);
4788
6031
  registerCommandProfileCommand(program2);
4789
6032
  applyCommandProfile(program2);
4790
6033
  return program2;