@enerlence/suntropy-cli 0.11.8 → 0.12.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);
@@ -1324,7 +1327,11 @@ function createResourceCommands(cfg) {
1324
1327
  const client = createServiceClient(service, global);
1325
1328
  const body = parseData(opts.data);
1326
1329
  if (cfg.putBodyOnly) {
1327
- const res = await client.put(cfg.basePath, { ...body, [cfg.idField]: id });
1330
+ const numericId = Number(id);
1331
+ const res = await client.put(cfg.basePath, {
1332
+ ...body,
1333
+ [cfg.idField]: Number.isFinite(numericId) ? numericId : id
1334
+ });
1328
1335
  output(res.data, global);
1329
1336
  } else {
1330
1337
  const res = await client.put(`${cfg.basePath}/${id}`, body);
@@ -1386,8 +1393,8 @@ function getGlobalOpts4(cmd) {
1386
1393
  }
1387
1394
  function parseData2(data) {
1388
1395
  if (data === "-") {
1389
- const { readFileSync: readFileSync6 } = __require("fs");
1390
- return JSON.parse(readFileSync6(0, "utf-8"));
1396
+ const { readFileSync: readFileSync7 } = __require("fs");
1397
+ return JSON.parse(readFileSync7(0, "utf-8"));
1391
1398
  }
1392
1399
  return JSON.parse(data);
1393
1400
  }
@@ -2141,6 +2148,21 @@ function updateStudy(filePath, updater) {
2141
2148
  cascadeResets
2142
2149
  };
2143
2150
  }
2151
+ function distributePanels(surfaces, total) {
2152
+ if (!surfaces.length) return [];
2153
+ if (surfaces.length === 1) return [total];
2154
+ const current = surfaces.map((s) => Number(s.panelNumber) || 0);
2155
+ const currentTotal = current.reduce((a, b) => a + b, 0);
2156
+ const weights = currentTotal > 0 ? current.map((n) => n / currentTotal) : surfaces.map(() => 1 / surfaces.length);
2157
+ const counts = weights.map((w) => Math.floor(total * w));
2158
+ let remainder = total - counts.reduce((a, b) => a + b, 0);
2159
+ const order = counts.map((n, i) => ({ n, i })).sort((a, b) => b.n - a.n).map((x) => x.i);
2160
+ for (let k = 0; remainder > 0; k = (k + 1) % order.length) {
2161
+ counts[order[k]] += 1;
2162
+ remainder -= 1;
2163
+ }
2164
+ return counts;
2165
+ }
2144
2166
  function deepMerge(target, source) {
2145
2167
  for (const key of Object.keys(source)) {
2146
2168
  const sv = source[key];
@@ -2559,26 +2581,45 @@ Examples:
2559
2581
  }
2560
2582
  });
2561
2583
  set.command("panel").description(
2562
- 'Set solar panel for the study. Auto-sets peakPowerIntroductionMode to "solarPanel".\nFetches full panel data from inventory.\nExample: suntropy studies set panel --file study.json --panel-id 456 --panels-count 12'
2563
- ).option("--file <path>", "Study file path").requiredOption("--panel-id <n>", "Solar panel ID from inventory").option("--panels-count <n>", "Number of panels").action(async (opts) => {
2584
+ 'Set solar panel for the study. Auto-sets peakPowerIntroductionMode to "solarPanel".\nFetches full panel data from inventory.\n--panels-count is the TOTAL for the study: it is spread over the surfaces keeping\ntheir current proportion (use --surface-index to target a single surface).\nThe response lists the resulting panelNumber per surface.\nExample: suntropy studies set panel --file study.json --panel-id 456 --panels-count 12'
2585
+ ).option("--file <path>", "Study file path").requiredOption("--panel-id <n>", "Solar panel ID from inventory").option("--panels-count <n>", "TOTAL number of panels of the study, spread over its surfaces keeping their current proportion").option("--surface-index <n>", "Apply --panels-count to this surface only (0-based) instead of spreading it").action(async (opts) => {
2564
2586
  try {
2565
2587
  const global = getGlobalOpts7(studies);
2566
2588
  const solarClient = createServiceClient("solar", global);
2567
2589
  const panelRes = await solarClient.get(`/solar-panels/${opts.panelId}`);
2568
2590
  const panel = panelRes.data;
2591
+ let distribution = [];
2569
2592
  const result = updateStudy(resolveFile(opts), (study) => {
2570
2593
  study.solarPanel = panel;
2571
2594
  study.peakPowerIntroductionMode = "solarPanel";
2572
2595
  study.solarKit = void 0;
2573
2596
  if (opts.panelsCount) {
2574
2597
  const surfaces = study.surfaces;
2598
+ const total = parseInt(opts.panelsCount);
2575
2599
  if (surfaces?.length) {
2576
- surfaces[0].panelNumber = parseInt(opts.panelsCount);
2600
+ if (opts.surfaceIndex !== void 0) {
2601
+ const idx = parseInt(opts.surfaceIndex);
2602
+ if (!Number.isInteger(idx) || idx < 0 || idx >= surfaces.length) {
2603
+ throw new Error(
2604
+ `--surface-index ${opts.surfaceIndex} is out of range: the study has ${surfaces.length} surface(s)`
2605
+ );
2606
+ }
2607
+ surfaces[idx].panelNumber = total;
2608
+ } else {
2609
+ const counts = distributePanels(surfaces, total);
2610
+ surfaces.forEach((surface, i) => {
2611
+ surface.panelNumber = counts[i];
2612
+ });
2613
+ }
2614
+ distribution = surfaces.map((surface, i) => ({
2615
+ surfaceIndex: i,
2616
+ panelNumber: Number(surface.panelNumber) || 0
2617
+ }));
2577
2618
  }
2578
2619
  }
2579
2620
  return void 0;
2580
2621
  });
2581
- output(result, global);
2622
+ output(distribution.length ? { ...result, surfaces: distribution } : result, global);
2582
2623
  } catch (err) {
2583
2624
  outputError(handleApiError(err));
2584
2625
  }
@@ -2610,7 +2651,7 @@ Examples:
2610
2651
  }
2611
2652
  });
2612
2653
  set.command("inverter").description(
2613
- "Set inverter(s) for the study (when using solarPanel mode).\nExample: suntropy studies set inverter --file study.json --inverter-id 789"
2654
+ "Set inverter(s) for the study (when using solarPanel mode).\nREPLACES the whole list: pass every inverter of the installation, comma-separated,\nrepeating an id as many times as units there are. The response reports how many\nwere there before and how many are there now.\nExample: suntropy studies set inverter --file study.json --inverter-id 789,789"
2614
2655
  ).option("--file <path>", "Study file path").requiredOption("--inverter-id <ids>", "Inverter ID(s), comma-separated for multiple").action(async (opts) => {
2615
2656
  try {
2616
2657
  const global = getGlobalOpts7(studies);
@@ -2621,11 +2662,13 @@ Examples:
2621
2662
  const res = await solarClient.get(`/solar-inverter/${id}`);
2622
2663
  inverters.push(res.data);
2623
2664
  }
2665
+ let previousCount = 0;
2624
2666
  const result = updateStudy(resolveFile(opts), (study) => {
2667
+ previousCount = study.solarInverters?.length ?? 0;
2625
2668
  study.solarInverters = inverters;
2626
2669
  return void 0;
2627
2670
  });
2628
- output(result, global);
2671
+ output({ ...result, inverters: { before: previousCount, after: inverters.length } }, global);
2629
2672
  } catch (err) {
2630
2673
  outputError(handleApiError(err));
2631
2674
  }
@@ -3752,13 +3795,13 @@ function getGlobalOpts9(cmd) {
3752
3795
  return root.opts();
3753
3796
  }
3754
3797
  function readStdin() {
3755
- return new Promise((resolve, reject) => {
3798
+ return new Promise((resolve2, reject) => {
3756
3799
  let data = "";
3757
3800
  process.stdin.setEncoding("utf-8");
3758
3801
  process.stdin.on("data", (chunk) => {
3759
3802
  data += chunk;
3760
3803
  });
3761
- process.stdin.on("end", () => resolve(data));
3804
+ process.stdin.on("end", () => resolve2(data));
3762
3805
  process.stdin.on("error", reject);
3763
3806
  });
3764
3807
  }
@@ -4051,13 +4094,13 @@ function getGlobalOpts11(cmd) {
4051
4094
  return root.opts();
4052
4095
  }
4053
4096
  function readStdin2() {
4054
- return new Promise((resolve, reject) => {
4097
+ return new Promise((resolve2, reject) => {
4055
4098
  let data = "";
4056
4099
  process.stdin.setEncoding("utf-8");
4057
4100
  process.stdin.on("data", (chunk) => {
4058
4101
  data += chunk;
4059
4102
  });
4060
- process.stdin.on("end", () => resolve(data));
4103
+ process.stdin.on("end", () => resolve2(data));
4061
4104
  process.stdin.on("error", reject);
4062
4105
  });
4063
4106
  }
@@ -4223,13 +4266,13 @@ function getGlobalOpts12(cmd) {
4223
4266
  return root.opts();
4224
4267
  }
4225
4268
  function readStdin3() {
4226
- return new Promise((resolve, reject) => {
4269
+ return new Promise((resolve2, reject) => {
4227
4270
  let data = "";
4228
4271
  process.stdin.setEncoding("utf-8");
4229
4272
  process.stdin.on("data", (chunk) => {
4230
4273
  data += chunk;
4231
4274
  });
4232
- process.stdin.on("end", () => resolve(data));
4275
+ process.stdin.on("end", () => resolve2(data));
4233
4276
  process.stdin.on("error", reject);
4234
4277
  });
4235
4278
  }
@@ -4324,13 +4367,13 @@ function getGlobalOpts13(cmd) {
4324
4367
  return root.opts();
4325
4368
  }
4326
4369
  function readStdin4() {
4327
- return new Promise((resolve, reject) => {
4370
+ return new Promise((resolve2, reject) => {
4328
4371
  let data = "";
4329
4372
  process.stdin.setEncoding("utf-8");
4330
4373
  process.stdin.on("data", (chunk) => {
4331
4374
  data += chunk;
4332
4375
  });
4333
- process.stdin.on("end", () => resolve(data));
4376
+ process.stdin.on("end", () => resolve2(data));
4334
4377
  process.stdin.on("error", reject);
4335
4378
  });
4336
4379
  }
@@ -4567,6 +4610,1152 @@ Examples:
4567
4610
  });
4568
4611
  }
4569
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, 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("delete <campaignId>").summary("Delete a campaign and everything it generated (irreversible, --yes).").description(
4835
+ "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"
4836
+ ).option("--yes", "Confirm the deletion (required)").action(async (campaignId, opts) => {
4837
+ const global = getGlobalOpts17(campaigns);
4838
+ try {
4839
+ if (!opts.yes) throw new Error("Deleting a campaign is irreversible. Re-run with --yes to confirm.");
4840
+ const data = await call(satvoltClient(global), "delete", `/campaigns/${parseId(campaignId, "campaignId")}`);
4841
+ output(data, global);
4842
+ } catch (err) {
4843
+ outputError(satvoltError(err));
4844
+ }
4845
+ });
4846
+ campaigns.command("start <campaignId>").description("Start the pipeline of a queued campaign (spends credits).").action(async (campaignId) => {
4847
+ const global = getGlobalOpts17(campaigns);
4848
+ try {
4849
+ const data = await call(satvoltClient(global), "post", `/campaigns/${parseId(campaignId, "campaignId")}/start`);
4850
+ output(data, global);
4851
+ } catch (err) {
4852
+ outputError(satvoltError(err));
4853
+ }
4854
+ });
4855
+ campaigns.command("reset <campaignId>").summary("Relaunch a campaign from scratch: deletes its leads and results (--yes).").description(
4856
+ "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"
4857
+ ).option("--start", "Start the pipeline again after resetting").option("--yes", "Confirm the reset (required)").action(async (campaignId, opts) => {
4858
+ const global = getGlobalOpts17(campaigns);
4859
+ try {
4860
+ if (!opts.yes) throw new Error("Reset deletes the campaign leads and results. Re-run with --yes to confirm.");
4861
+ const data = await call(satvoltClient(global), "post", `/campaigns/${parseId(campaignId, "campaignId")}/reset`, {
4862
+ params: { start: opts.start ? "true" : void 0 }
4863
+ });
4864
+ output(data, global);
4865
+ } catch (err) {
4866
+ outputError(satvoltError(err));
4867
+ }
4868
+ });
4869
+ campaigns.command("extend <campaignId>").summary("Get more leads from a finished Maps campaign without relaunching it.").description(
4870
+ "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"
4871
+ ).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) => {
4872
+ const global = getGlobalOpts17(campaigns);
4873
+ try {
4874
+ const removeLimit = opts.limit === false;
4875
+ if (removeLimit === (opts.maxLeads !== void 0)) {
4876
+ throw new Error("Pass exactly one of --max-leads <n> or --no-limit.");
4877
+ }
4878
+ const maxLeads = removeLimit ? null : parseIntOption(opts.maxLeads, "--max-leads");
4879
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/extend`, {
4880
+ data: { maxLeads }
4881
+ });
4882
+ output(data, global);
4883
+ } catch (err) {
4884
+ outputError(satvoltError(err));
4885
+ }
4886
+ });
4887
+ campaigns.command("resume <campaignId>").summary("Append a new step at the end and run it over the existing leads.").description(
4888
+ '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"}\''
4889
+ ).requiredOption("--action <ACTION>", "Action to add (see: satvolt catalog actions)").option("--config <json>", "Step config as JSON, @file or -").action(async (campaignId, opts) => {
4890
+ const global = getGlobalOpts17(campaigns);
4891
+ try {
4892
+ const step = { action: String(opts.action).toUpperCase() };
4893
+ if (opts.config) step.config = readJsonArg(opts.config, "--config");
4894
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/resume`, {
4895
+ data: { step }
4896
+ });
4897
+ output(data, global);
4898
+ } catch (err) {
4899
+ outputError(satvoltError(err));
4900
+ }
4901
+ });
4902
+ campaigns.command("usage <campaignId>").description(
4903
+ "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."
4904
+ ).option("--by-lead", "Include the per-lead breakdown").action(async (campaignId, opts) => {
4905
+ const global = getGlobalOpts17(campaigns);
4906
+ try {
4907
+ const data = await call(satvoltClient(global, 12e4), "get", `/campaigns/${parseId(campaignId, "campaignId")}/usage`, {
4908
+ params: { include: opts.byLead ? "leads" : void 0 }
4909
+ });
4910
+ if (global.format === "human" || global.format === "csv") {
4911
+ if (global.format === "human") {
4912
+ process.stderr.write(
4913
+ chalk2.bold(`Total ${data.totalCredits} credits \xB7 ${data.totalLeads} leads \xB7 ${data.avgCreditsPerLead} per lead
4914
+
4915
+ `)
4916
+ );
4917
+ }
4918
+ output(data.byStep, global);
4919
+ return;
4920
+ }
4921
+ output(data, global);
4922
+ } catch (err) {
4923
+ outputError(satvoltError(err));
4924
+ }
4925
+ });
4926
+ campaigns.command("funnel <campaignId>").summary("Leads per pipeline step, by execution or by success criteria").description(
4927
+ "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"
4928
+ ).option("--mode <mode>", "execution (default) | success", "execution").action(async (campaignId, opts) => {
4929
+ const global = getGlobalOpts17(campaigns);
4930
+ try {
4931
+ const mode = String(opts.mode || "execution").toLowerCase();
4932
+ if (mode !== "execution" && mode !== "success") {
4933
+ outputError({ code: "INVALID_MODE", message: "--mode must be execution or success" });
4934
+ return;
4935
+ }
4936
+ const data = await call(satvoltClient(global, 12e4), "get", `/campaigns/${parseId(campaignId, "campaignId")}/funnel`);
4937
+ if (global.format === "human" || global.format === "csv") {
4938
+ output(
4939
+ (data.steps ?? []).map(
4940
+ (s) => mode === "success" ? {
4941
+ step: s.name,
4942
+ action: s.action,
4943
+ reached: s.reached,
4944
+ withData: s.criteria ? s.criteria.met : s.success,
4945
+ pctOfTotal: s.criteria ? s.criteria.metPct : s.reachedPct,
4946
+ missingData: s.criteria ? s.criteria.unmet : "",
4947
+ retries: s.criteria?.maxRetries || "",
4948
+ successIf: s.criteria ? s.criteria.paths.join(", ") : "(no criteria)"
4949
+ } : {
4950
+ step: s.name,
4951
+ action: s.action,
4952
+ reached: s.reached,
4953
+ pctOfTotal: s.reachedPct,
4954
+ success: s.success,
4955
+ failure: s.failure,
4956
+ skipped: s.skipped,
4957
+ processing: s.processing,
4958
+ pending: s.pending
4959
+ }
4960
+ ),
4961
+ global
4962
+ );
4963
+ return;
4964
+ }
4965
+ output(data, global);
4966
+ } catch (err) {
4967
+ outputError(satvoltError(err));
4968
+ }
4969
+ });
4970
+ campaigns.command("logs <campaignId>").description(
4971
+ "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"
4972
+ ).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) => {
4973
+ const global = getGlobalOpts17(campaigns);
4974
+ try {
4975
+ const id = parseId(campaignId, "campaignId");
4976
+ const client = satvoltClient(global);
4977
+ const fetchLogs = (sinceTs) => call(client, "get", `/campaigns/${id}/logs`, {
4978
+ params: { limit: opts.limit, sinceTs, level: opts.level }
4979
+ });
4980
+ const printEntries = (entries) => {
4981
+ for (const e of entries) {
4982
+ if (global.format === "human") {
4983
+ const color = e.level === "error" ? chalk2.red : e.level === "warn" ? chalk2.yellow : chalk2.dim;
4984
+ process.stdout.write(`${chalk2.dim(new Date(e.ts).toISOString())} ${color(e.level.padEnd(5))} ${e.source}: ${e.message}
4985
+ `);
4986
+ } else {
4987
+ process.stdout.write(JSON.stringify(e) + "\n");
4988
+ }
4989
+ }
4990
+ };
4991
+ const first = await fetchLogs(opts.since);
4992
+ if (!opts.follow) {
4993
+ if (global.format === "human") printEntries(first.entries);
4994
+ else output(first, global);
4995
+ return;
4996
+ }
4997
+ printEntries(first.entries);
4998
+ let lastTs = first.lastTs;
4999
+ const intervalMs = Math.max(1, Number(opts.interval) || 3) * 1e3;
5000
+ for (; ; ) {
5001
+ const campaign = await call(client, "get", `/campaigns/${id}`);
5002
+ await new Promise((r) => setTimeout(r, intervalMs));
5003
+ const next = await fetchLogs(lastTs ?? void 0);
5004
+ printEntries(next.entries);
5005
+ lastTs = next.lastTs ?? lastTs;
5006
+ if (TERMINAL_CAMPAIGN_STATES.has(campaign.state)) {
5007
+ process.stderr.write(`Campaign ${id} is ${campaign.state}; stopped following.
5008
+ `);
5009
+ return;
5010
+ }
5011
+ }
5012
+ } catch (err) {
5013
+ outputError(satvoltError(err));
5014
+ }
5015
+ });
5016
+ }
5017
+
5018
+ // src/commands/satvolt/pipeline.ts
5019
+ var STEP_LIST_FIELDS = "index,uid,action,name,disable,creditCost,executedLeads,runnable,reason";
5020
+ async function patchSteps(cmd, campaignId, steps) {
5021
+ const global = getGlobalOpts17(cmd);
5022
+ const data = await call(satvoltClient(global), "patch", `/campaigns/${parseId(campaignId, "campaignId")}/configuration`, {
5023
+ data: { steps }
5024
+ });
5025
+ output(data, global);
5026
+ }
5027
+ function registerSatvoltPipelineCommands(satvolt) {
5028
+ const config = satvolt.command("config").summary("Pipeline configuration of a campaign as JSON (get, update, patch).").description(
5029
+ "Pipeline configuration of a campaign as JSON.\nWorkflow: config get > edit the JSON > config update (full) or config patch (partial)."
5030
+ );
5031
+ config.command("get <campaignId>").description(
5032
+ "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"
5033
+ ).action(async (campaignId) => {
5034
+ const global = getGlobalOpts17(config);
5035
+ try {
5036
+ const data = await call(satvoltClient(global), "get", `/campaigns/${parseId(campaignId, "campaignId")}/configuration`);
5037
+ output(data, global);
5038
+ } catch (err) {
5039
+ outputError(satvoltError(err));
5040
+ }
5041
+ });
5042
+ config.command("update <campaignId>").description(
5043
+ "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"
5044
+ ).requiredOption("--data <json>", "Configuration JSON, @file or -").action(async (campaignId, opts) => {
5045
+ const global = getGlobalOpts17(config);
5046
+ try {
5047
+ const body = readJsonArg(opts.data, "--data");
5048
+ const data = await call(satvoltClient(global), "put", `/campaigns/${parseId(campaignId, "campaignId")}/configuration`, {
5049
+ data: body
5050
+ });
5051
+ output(data, global);
5052
+ } catch (err) {
5053
+ outputError(satvoltError(err));
5054
+ }
5055
+ });
5056
+ config.command("patch <campaignId>").description(
5057
+ `Partial change (PATCH). Body: { steps?, businessGroups?, description? }.
5058
+ Each item in steps:
5059
+ { "uid": "<existing>", "config": {...} } merge config (JSON Merge Patch: null resets a key to its default)
5060
+ { "uid": "<existing>", "replaceConfig": true, "config": {...} } replace config
5061
+ { "uid": "<existing>", "disable": true }
5062
+ { "uid": "<existing>", "after": "<uid>" } move (also "before")
5063
+ { "uid": "<existing>", "remove": true }
5064
+ { "action": "QUALIFY", "config": {...} } add (before COMPLETE, or before/after a uid)
5065
+ Example:
5066
+ suntropy satvolt config patch 59 --data '{"steps":[{"uid":"d3f2849c52281f36","config":{"enableWebSearch":true}}]}'`
5067
+ ).requiredOption("--data <json>", "Patch JSON, @file or -").action(async (campaignId, opts) => {
5068
+ const global = getGlobalOpts17(config);
5069
+ try {
5070
+ const body = readJsonArg(opts.data, "--data");
5071
+ const data = await call(satvoltClient(global), "patch", `/campaigns/${parseId(campaignId, "campaignId")}/configuration`, {
5072
+ data: body
5073
+ });
5074
+ output(data, global);
5075
+ } catch (err) {
5076
+ outputError(satvoltError(err));
5077
+ }
5078
+ });
5079
+ const steps = satvolt.command("steps").description("Steps of a campaign pipeline: list with run status, add, set, remove and run.");
5080
+ steps.command("list <campaignId>").description(
5081
+ "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."
5082
+ ).action(async (campaignId) => {
5083
+ const global = getGlobalOpts17(steps);
5084
+ try {
5085
+ const data = await call(satvoltClient(global), "get", `/campaigns/${parseId(campaignId, "campaignId")}/steps`);
5086
+ if (global.format === "json" && !global.fields) {
5087
+ output(data, global);
5088
+ } else {
5089
+ output(data.steps, { ...global, fields: global.fields ?? STEP_LIST_FIELDS });
5090
+ }
5091
+ } catch (err) {
5092
+ outputError(satvoltError(err));
5093
+ }
5094
+ });
5095
+ steps.command("add <campaignId>").description(
5096
+ `Add a step. Goes before COMPLETE unless --before/--after is given. Config defaults
5097
+ from the action schema are filled in and missing dependencies are added.
5098
+ Example:
5099
+ suntropy satvolt steps add 59 --action QUALIFY --config '{"qualificationDefinition":"..."}'`
5100
+ ).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) => {
5101
+ try {
5102
+ const step = { action: String(opts.action).toUpperCase() };
5103
+ if (opts.config) step.config = readJsonArg(opts.config, "--config");
5104
+ if (opts.uid) step.uid = opts.uid;
5105
+ if (opts.before) step.before = opts.before;
5106
+ if (opts.after) step.after = opts.after;
5107
+ if (opts.disabled) step.disable = true;
5108
+ await patchSteps(steps, campaignId, [step]);
5109
+ } catch (err) {
5110
+ outputError(satvoltError(err));
5111
+ }
5112
+ });
5113
+ steps.command("set <campaignId> <stepUid>").description(
5114
+ `Change one step: merge --config into its config (null resets a key to its default), or replace it
5115
+ with --replace-config; enable/disable; move with --before/--after.
5116
+
5117
+ Every step also takes two common config keys, whatever its action:
5118
+ skipIfEmpty path that, when empty, skips the step for that lead
5119
+ successIf paths the step must fill for its result to count as useful, relative
5120
+ to what the step writes (an agent: relative to its response), or
5121
+ absolute with fullData./lead. Feeds \`campaigns funnel --mode success\`
5122
+ maxRetries 0-5. Repeats the step while successIf is not met. Credits are charged
5123
+ once per step, not per attempt; if the last attempt still has no data
5124
+ the lead carries on to the next step.
5125
+ Examples:
5126
+ suntropy satvolt steps set 59 95161b8b080180e4 --config '{"outputKey":"company"}'
5127
+ suntropy satvolt steps set 62 7f3edaf055d60627 --config '{"successIf":["response.linkedinUrl"],"maxRetries":2}'`
5128
+ ).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) => {
5129
+ try {
5130
+ if (opts.enable && opts.disable) throw new Error("Use either --enable or --disable");
5131
+ const step = { uid: stepUid };
5132
+ if (opts.config) step.config = readJsonArg(opts.config, "--config");
5133
+ if (opts.replaceConfig) step.replaceConfig = true;
5134
+ if (opts.enable) step.disable = false;
5135
+ if (opts.disable) step.disable = true;
5136
+ if (opts.before) step.before = opts.before;
5137
+ if (opts.after) step.after = opts.after;
5138
+ if (Object.keys(step).length === 1) throw new Error("Nothing to change: pass --config, --enable/--disable or --before/--after");
5139
+ await patchSteps(steps, campaignId, [step]);
5140
+ } catch (err) {
5141
+ outputError(satvoltError(err));
5142
+ }
5143
+ });
5144
+ steps.command("remove <campaignId> <stepUid>").description("Remove a step from the pipeline (results already stored on leads are kept).").action(async (campaignId, stepUid) => {
5145
+ try {
5146
+ await patchSteps(steps, campaignId, [{ uid: stepUid, remove: true }]);
5147
+ } catch (err) {
5148
+ outputError(satvoltError(err));
5149
+ }
5150
+ });
5151
+ steps.command("run <campaignId> <stepUid>").description(
5152
+ "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."
5153
+ ).action(async (campaignId, stepUid) => {
5154
+ const global = getGlobalOpts17(steps);
5155
+ try {
5156
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/steps/${encodeURIComponent(stepUid)}/run`);
5157
+ output(data, global);
5158
+ } catch (err) {
5159
+ outputError(satvoltError(err));
5160
+ }
5161
+ });
5162
+ }
5163
+
5164
+ // src/commands/satvolt/fields.ts
5165
+ var pct = (v) => v === null || v === void 0 ? "-" : `${Math.round(v * 100)}%`;
5166
+ function flatten(catalog, onlyStep) {
5167
+ const rows = [];
5168
+ 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 });
5169
+ if (!onlyStep) {
5170
+ for (const f of catalog.lead) push("Lead", f, "lead");
5171
+ for (const f of catalog.synthetic) push("Lead", f, "synthetic");
5172
+ }
5173
+ for (const step of catalog.steps) {
5174
+ const group = `${step.name} [${step.action}]`;
5175
+ for (const f of step.fields) push(group, f);
5176
+ for (const d of step.dynamic) {
5177
+ rows.push({
5178
+ group,
5179
+ path: `${d.path}.*`,
5180
+ label: "-",
5181
+ type: "-",
5182
+ source: "dynamic",
5183
+ coverage: "-",
5184
+ example: d.inferredFrom ? `${d.description} Fields above inferred from campaign ${d.inferredFrom.campaignId}.` : d.description
5185
+ });
5186
+ }
5187
+ }
5188
+ if (!onlyStep) for (const f of catalog.other) push("Other (no current step)", f);
5189
+ return rows;
5190
+ }
5191
+ function registerFieldsCommand(parent, note) {
5192
+ parent.command("fields <campaignId>").summary("Fields for export table columns, grouped by pipeline step.").description(
5193
+ "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}
5194
+ ` : "") + "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"
5195
+ ).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) => {
5196
+ const global = getGlobalOpts17(parent);
5197
+ try {
5198
+ const catalog = await call(
5199
+ satvoltClient(global, 12e4),
5200
+ "get",
5201
+ `/campaigns/${parseId(campaignId, "campaignId")}/fields`,
5202
+ { params: { sample: opts.sample, step: opts.step } }
5203
+ );
5204
+ const search = typeof opts.search === "string" ? opts.search.toLowerCase() : void 0;
5205
+ if (global.format === "json") {
5206
+ if (search) {
5207
+ const match = (f) => f.path.toLowerCase().includes(search) || f.label?.toLowerCase().includes(search);
5208
+ catalog.lead = catalog.lead.filter(match);
5209
+ catalog.synthetic = catalog.synthetic.filter(match);
5210
+ catalog.other = catalog.other.filter(match);
5211
+ catalog.steps = catalog.steps.map((s) => ({ ...s, fields: s.fields.filter(match) })).filter((s) => s.fields.length > 0);
5212
+ }
5213
+ output(catalog, global);
5214
+ return;
5215
+ }
5216
+ let rows = flatten(catalog, Boolean(opts.step));
5217
+ if (search) {
5218
+ rows = rows.filter(
5219
+ (r) => String(r.path).toLowerCase().includes(search) || String(r.label).toLowerCase().includes(search)
5220
+ );
5221
+ }
5222
+ output(rows, global);
5223
+ } catch (err) {
5224
+ outputError(satvoltError(err));
5225
+ }
5226
+ });
5227
+ }
5228
+
5229
+ // src/commands/satvolt/leads.ts
5230
+ var LEAD_LIST_FIELDS = "idLead,commercialName,state,stateError,lastAction,address,phone,url";
5231
+ function registerSatvoltLeadCommands(satvolt) {
5232
+ const leads = satvolt.command("leads").description("Leads of a campaign: paginated list with state, filters by step, detail and available data fields.");
5233
+ leads.command("list <campaignId>").description(
5234
+ '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'
5235
+ ).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) => {
5236
+ const global = getGlobalOpts17(leads);
5237
+ try {
5238
+ const limit = Number(opts.limit);
5239
+ const offset = opts.page ? (Math.max(1, Number(opts.page)) - 1) * limit : Number(opts.offset);
5240
+ const page = await call(satvoltClient(global), "get", `/campaigns/${parseId(campaignId, "campaignId")}/leads`, {
5241
+ params: {
5242
+ limit,
5243
+ offset,
5244
+ name: opts.name,
5245
+ search: opts.search,
5246
+ state: opts.state,
5247
+ step: opts.step,
5248
+ stepStatus: opts.stepStatus,
5249
+ include: opts.withSteps ? "steps" : void 0
5250
+ }
5251
+ });
5252
+ const outOpts = { ...global, fields: global.fields ?? (global.format !== "json" ? LEAD_LIST_FIELDS : void 0) };
5253
+ if (outOpts.format === "csv") output(page.items, outOpts);
5254
+ else outputPaginated(page.items, page.total, page.limit, page.offset, outOpts);
5255
+ } catch (err) {
5256
+ outputError(satvoltError(err));
5257
+ }
5258
+ });
5259
+ leads.command("get <campaignId> <leadId>").description(
5260
+ "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"
5261
+ ).option("--full-data [keys]", "Include fullData (optionally comma-separated top-level keys)").action(async (campaignId, leadId, opts) => {
5262
+ const global = getGlobalOpts17(leads);
5263
+ try {
5264
+ const fullData = opts.fullData === true ? "true" : opts.fullData;
5265
+ const data = await call(
5266
+ satvoltClient(global),
5267
+ "get",
5268
+ `/campaigns/${parseId(campaignId, "campaignId")}/leads/${parseId(leadId, "leadId")}`,
5269
+ { params: { fullData } }
5270
+ );
5271
+ output(data, global);
5272
+ } catch (err) {
5273
+ outputError(satvoltError(err));
5274
+ }
5275
+ });
5276
+ leads.command("full-data <campaignId> <leadId>").summary("Only the enriched data of a lead (all, some keys or one path).").description(
5277
+ "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"
5278
+ ).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) => {
5279
+ const global = getGlobalOpts17(leads);
5280
+ try {
5281
+ const topKey = opts.path ? String(opts.path).split(".")[0] : void 0;
5282
+ const data = await call(
5283
+ satvoltClient(global),
5284
+ "get",
5285
+ `/campaigns/${parseId(campaignId, "campaignId")}/leads/${parseId(leadId, "leadId")}`,
5286
+ { params: { fullData: topKey ?? opts.keys ?? "true" } }
5287
+ );
5288
+ let value = data.fullData ?? {};
5289
+ if (opts.path) {
5290
+ for (const part of String(opts.path).split(".")) {
5291
+ value = value && typeof value === "object" ? value[part] : void 0;
5292
+ }
5293
+ if (value === void 0) throw new Error(`Path "${opts.path}" not found in the fullData of lead ${leadId}`);
5294
+ }
5295
+ output(value, global);
5296
+ } catch (err) {
5297
+ outputError(satvoltError(err));
5298
+ }
5299
+ });
5300
+ leads.command("run-step <campaignId> <leadId> <step>").summary("Run one pipeline step on a single lead (only that step, or --continue).").description(
5301
+ "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"
5302
+ ).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) => {
5303
+ const global = getGlobalOpts17(leads);
5304
+ try {
5305
+ const data = await call(
5306
+ satvoltClient(global),
5307
+ "post",
5308
+ `/campaigns/${parseId(campaignId, "campaignId")}/leads/${parseId(leadId, "leadId")}/steps/${encodeURIComponent(step)}/run`,
5309
+ { data: { mode: opts.continue ? "continue" : "only", force: opts.force === true } }
5310
+ );
5311
+ output(data, global);
5312
+ } catch (err) {
5313
+ outputError(satvoltError(err));
5314
+ }
5315
+ });
5316
+ registerFieldsCommand(leads, "Same as `satvolt export-tables fields`.");
5317
+ }
5318
+
5319
+ // src/commands/satvolt/export-tables.ts
5320
+ import { writeFileSync as writeFileSync5 } from "fs";
5321
+ import { resolve } from "path";
5322
+ function parseColumns(value) {
5323
+ const trimmed = value.trim();
5324
+ if (trimmed.startsWith("[") || trimmed.startsWith("@") || trimmed === "-") {
5325
+ const parsed = readJsonArg(value, "--columns");
5326
+ if (!Array.isArray(parsed)) throw new Error("--columns JSON must be an array");
5327
+ return parsed;
5328
+ }
5329
+ return trimmed.split(";").map((part) => {
5330
+ const m = /^(.+?)=([^:]+)(?::(string|number|boolean|date|url))?$/.exec(part.trim());
5331
+ if (!m) throw new Error(`Invalid column "${part}". Use "Label=path[:type]" separated by ";" or a JSON array`);
5332
+ return { label: m[1].trim(), path: m[2].trim(), ...m[3] ? { type: m[3] } : {} };
5333
+ });
5334
+ }
5335
+ function registerSatvoltExportTableCommands(satvolt) {
5336
+ const tables = satvolt.command("export-tables").summary("Export tables: column sets over lead data, paged reads and XLSX/CSV downloads.").description(
5337
+ "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."
5338
+ );
5339
+ registerFieldsCommand(tables, "Same as `satvolt leads fields`.");
5340
+ tables.command("list <campaignId>").description("List the export tables of a campaign.").action(async (campaignId) => {
5341
+ const global = getGlobalOpts17(tables);
5342
+ try {
5343
+ const data = await call(satvoltClient(global), "get", `/campaigns/${parseId(campaignId, "campaignId")}/export-tables`);
5344
+ output(global.format === "json" ? data : data.map((t) => ({ ...t, columns: t.columns.length })), global);
5345
+ } catch (err) {
5346
+ outputError(satvoltError(err));
5347
+ }
5348
+ });
5349
+ tables.command("get <tableId>").description("Get an export table with its columns.").action(async (tableId) => {
5350
+ const global = getGlobalOpts17(tables);
5351
+ try {
5352
+ output(await call(satvoltClient(global), "get", `/export-tables/${tableId}`), global);
5353
+ } catch (err) {
5354
+ outputError(satvoltError(err));
5355
+ }
5356
+ });
5357
+ tables.command("create <campaignId>").summary("Create an export table with its columns.").description(
5358
+ '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 })'
5359
+ ).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) => {
5360
+ const global = getGlobalOpts17(tables);
5361
+ try {
5362
+ const body = opts.data ? readJsonArg(opts.data, "--data") : {};
5363
+ if (opts.name) body.name = opts.name;
5364
+ if (opts.description) body.description = opts.description;
5365
+ if (opts.columns) body.columns = parseColumns(opts.columns);
5366
+ if (!body.name) throw new Error("--name is required");
5367
+ if (!Array.isArray(body.columns) || body.columns.length === 0) throw new Error("--columns is required");
5368
+ const data = await call(satvoltClient(global), "post", `/campaigns/${parseId(campaignId, "campaignId")}/export-tables`, { data: body });
5369
+ output(data, global);
5370
+ } catch (err) {
5371
+ outputError(satvoltError(err));
5372
+ }
5373
+ });
5374
+ tables.command("update <tableId>").description(
5375
+ "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"
5376
+ ).requiredOption("--data <json>", "{ name, description?, columns } as JSON, @file or -").action(async (tableId, opts) => {
5377
+ const global = getGlobalOpts17(tables);
5378
+ try {
5379
+ const body = readJsonArg(opts.data, "--data");
5380
+ output(await call(satvoltClient(global), "put", `/export-tables/${tableId}`, { data: body }), global);
5381
+ } catch (err) {
5382
+ outputError(satvoltError(err));
5383
+ }
5384
+ });
5385
+ tables.command("patch <tableId>").summary("Change the name, description or the whole column list.").description(
5386
+ '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"'
5387
+ ).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) => {
5388
+ const global = getGlobalOpts17(tables);
5389
+ try {
5390
+ const body = {};
5391
+ if (opts.name) body.name = opts.name;
5392
+ if (opts.description !== void 0) body.description = opts.description;
5393
+ if (opts.columns) body.columns = parseColumns(opts.columns);
5394
+ if (Object.keys(body).length === 0) throw new Error("Nothing to change");
5395
+ output(await call(satvoltClient(global), "patch", `/export-tables/${tableId}`, { data: body }), global);
5396
+ } catch (err) {
5397
+ outputError(satvoltError(err));
5398
+ }
5399
+ });
5400
+ registerColumnCommands(tables);
5401
+ tables.command("delete <tableId>").description("Delete an export table.").action(async (tableId) => {
5402
+ const global = getGlobalOpts17(tables);
5403
+ try {
5404
+ output(await call(satvoltClient(global), "delete", `/export-tables/${tableId}`), global);
5405
+ } catch (err) {
5406
+ outputError(satvoltError(err));
5407
+ }
5408
+ });
5409
+ 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) => {
5410
+ const global = getGlobalOpts17(tables);
5411
+ try {
5412
+ const data = await call(satvoltClient(global), "post", `/export-tables/${tableId}/duplicate`, {
5413
+ data: { targetCampaignId: parseId(opts.campaign, "--campaign"), name: opts.name }
5414
+ });
5415
+ output(data, global);
5416
+ } catch (err) {
5417
+ outputError(satvoltError(err));
5418
+ }
5419
+ });
5420
+ tables.command("data <tableId>").description(
5421
+ "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"
5422
+ ).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) => {
5423
+ const global = getGlobalOpts17(tables);
5424
+ try {
5425
+ const page = await call(satvoltClient(global), "get", `/export-tables/${tableId}/data`, {
5426
+ params: { limit: opts.limit, offset: opts.offset, search: opts.search }
5427
+ });
5428
+ const columns = page.columns;
5429
+ const rows = page.items.map((r) => {
5430
+ const row = { "Lead ID": r.idLead };
5431
+ for (const c of columns) row[c.label] = r.values[c.id] ?? null;
5432
+ return row;
5433
+ });
5434
+ if (global.format === "csv") output(rows, global);
5435
+ else outputPaginated(rows, page.total, page.limit, page.offset, global);
5436
+ } catch (err) {
5437
+ outputError(satvoltError(err));
5438
+ }
5439
+ });
5440
+ tables.command("export <tableId>").description(
5441
+ "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"
5442
+ ).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) => {
5443
+ const global = getGlobalOpts17(tables);
5444
+ try {
5445
+ const format = String(opts.fileFormat).toLowerCase();
5446
+ if (format !== "xlsx" && format !== "csv") throw new Error("--file-format must be xlsx or csv");
5447
+ const client = satvoltClient(global, 3e5);
5448
+ const res = await client.get(`/export-tables/${tableId}/export`, {
5449
+ params: { format },
5450
+ responseType: "arraybuffer"
5451
+ });
5452
+ const disposition = String(res.headers["content-disposition"] ?? "");
5453
+ const suggested = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `export-${tableId}.${format}`;
5454
+ const path = resolve(opts.out ?? suggested);
5455
+ const buffer = Buffer.from(res.data);
5456
+ writeFileSync5(path, buffer);
5457
+ output(
5458
+ {
5459
+ saved: path,
5460
+ format,
5461
+ bytes: buffer.length,
5462
+ rows: res.headers["x-row-count"] !== void 0 ? Number(res.headers["x-row-count"]) : null
5463
+ },
5464
+ global
5465
+ );
5466
+ } catch (err) {
5467
+ outputError(satvoltError(err));
5468
+ }
5469
+ });
5470
+ }
5471
+ var normalizeLabel = (s) => s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toLowerCase();
5472
+ function resolveColumn(columns, ref) {
5473
+ const byId = columns.find((c) => c.id === ref);
5474
+ if (byId) return byId;
5475
+ const byLabel = columns.filter((c) => normalizeLabel(c.label) === normalizeLabel(ref));
5476
+ if (byLabel.length === 1) return byLabel[0];
5477
+ if (byLabel.length > 1) {
5478
+ throw new Error(`Label "${ref}" matches several columns; use one of the ids: ${byLabel.map((c) => c.id).join(", ")}`);
5479
+ }
5480
+ throw new Error(
5481
+ `No column "${ref}". Columns: ${columns.map((c) => `${c.id} (${c.label})`).join(", ") || "(none)"}`
5482
+ );
5483
+ }
5484
+ function targetPosition(columns, opts, moving) {
5485
+ const given = [opts.position, opts.before, opts.after].filter((v) => v !== void 0).length;
5486
+ if (given > 1) throw new Error("Use only one of --position, --before or --after");
5487
+ if (opts.position !== void 0) return parseIntOption(opts.position, "--position");
5488
+ const anchorRef = opts.before ?? opts.after;
5489
+ if (anchorRef === void 0) return void 0;
5490
+ const others = columns.filter((c) => c.id !== moving?.id);
5491
+ const anchor = resolveColumn(others, anchorRef);
5492
+ const index = others.findIndex((c) => c.id === anchor.id);
5493
+ return opts.before !== void 0 ? index : index + 1;
5494
+ }
5495
+ function registerColumnCommands(tables) {
5496
+ const columns = tables.command("columns").summary("Add, edit, move, reorder or remove single columns of an export table.").description(
5497
+ "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>"
5498
+ );
5499
+ const list = async (client, tableId) => call(client, "get", `/export-tables/${tableId}/columns`);
5500
+ columns.command("list <tableId>").description("List the columns of a table in order, with their position.").action(async (tableId) => {
5501
+ const global = getGlobalOpts17(tables);
5502
+ try {
5503
+ const cols = await list(satvoltClient(global), tableId);
5504
+ output(cols.map((c, position) => ({ position, ...c })), global);
5505
+ } catch (err) {
5506
+ outputError(satvoltError(err));
5507
+ }
5508
+ });
5509
+ columns.command("add <tableId>").summary("Add one or more columns, optionally at a position.").description(
5510
+ '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"'
5511
+ ).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) => {
5512
+ const global = getGlobalOpts17(tables);
5513
+ try {
5514
+ const client = satvoltClient(global);
5515
+ const specs = opts.columns ? parseColumns(opts.columns) : [{ label: opts.label, path: opts.path, ...opts.type ? { type: opts.type } : {}, ...opts.id ? { id: opts.id } : {} }];
5516
+ if (opts.columns && (opts.label || opts.path || opts.type || opts.id)) {
5517
+ throw new Error("--columns cannot be combined with --label/--path/--type/--id");
5518
+ }
5519
+ if (specs.some((c) => !c.label || !c.path)) throw new Error("--label and --path are required (or --columns)");
5520
+ let position = targetPosition(await list(client, tableId), opts);
5521
+ let result;
5522
+ const added = [];
5523
+ const warnings = [];
5524
+ for (const spec of specs) {
5525
+ result = await call(client, "post", `/export-tables/${tableId}/columns`, {
5526
+ data: { ...spec, ...position !== void 0 ? { position } : {} }
5527
+ });
5528
+ added.push(result.column);
5529
+ warnings.push(...result.warnings ?? []);
5530
+ if (position !== void 0) position++;
5531
+ }
5532
+ output(specs.length === 1 ? result : { table: result.table, columns: added, warnings }, global);
5533
+ } catch (err) {
5534
+ outputError(satvoltError(err));
5535
+ }
5536
+ });
5537
+ columns.command("set <tableId> <column>").summary("Change the label, path, type or position of one column.").description(
5538
+ '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'
5539
+ ).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) => {
5540
+ const global = getGlobalOpts17(tables);
5541
+ try {
5542
+ const client = satvoltClient(global);
5543
+ const cols = await list(client, tableId);
5544
+ const column = resolveColumn(cols, columnRef);
5545
+ const position = targetPosition(cols, opts, column);
5546
+ const body = {};
5547
+ if (opts.label !== void 0) body.label = opts.label;
5548
+ if (opts.path !== void 0) body.path = opts.path;
5549
+ if (opts.type !== void 0) body.type = opts.type;
5550
+ if (position !== void 0) body.position = position;
5551
+ if (Object.keys(body).length === 0) throw new Error("Nothing to change: give --label, --path, --type or a position");
5552
+ output(await call(client, "patch", `/export-tables/${tableId}/columns/${encodeURIComponent(column.id)}`, { data: body }), global);
5553
+ } catch (err) {
5554
+ outputError(satvoltError(err));
5555
+ }
5556
+ });
5557
+ columns.command("move <tableId> <column>").summary("Move one column to a position, or before/after another.").description(
5558
+ "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"
5559
+ ).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) => {
5560
+ const global = getGlobalOpts17(tables);
5561
+ try {
5562
+ const client = satvoltClient(global);
5563
+ const cols = await list(client, tableId);
5564
+ const column = resolveColumn(cols, columnRef);
5565
+ const position = targetPosition(cols, opts, column);
5566
+ if (position === void 0) throw new Error("Give --position, --before or --after");
5567
+ output(
5568
+ await call(client, "patch", `/export-tables/${tableId}/columns/${encodeURIComponent(column.id)}`, { data: { position } }),
5569
+ global
5570
+ );
5571
+ } catch (err) {
5572
+ outputError(satvoltError(err));
5573
+ }
5574
+ });
5575
+ columns.command("reorder <tableId> <columns...>").description(
5576
+ "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"
5577
+ ).action(async (tableId, refs) => {
5578
+ const global = getGlobalOpts17(tables);
5579
+ try {
5580
+ const client = satvoltClient(global);
5581
+ const cols = await list(client, tableId);
5582
+ const columnIds = refs.map((ref) => resolveColumn(cols, ref).id);
5583
+ output(await call(client, "put", `/export-tables/${tableId}/columns/order`, { data: { columnIds } }), global);
5584
+ } catch (err) {
5585
+ outputError(satvoltError(err));
5586
+ }
5587
+ });
5588
+ columns.command("remove <tableId> <columns...>").summary("Remove one or more columns (ids or labels).").description(
5589
+ "Remove one or more columns (ids or labels).\nExample:\n suntropy satvolt export-tables columns remove 6650... Maps c_1a2b3c4d"
5590
+ ).action(async (tableId, refs) => {
5591
+ const global = getGlobalOpts17(tables);
5592
+ try {
5593
+ const client = satvoltClient(global);
5594
+ const cols = await list(client, tableId);
5595
+ const targets = [...new Map(refs.map((ref) => resolveColumn(cols, ref)).map((c) => [c.id, c])).values()];
5596
+ let result;
5597
+ for (const column of targets) {
5598
+ result = await call(client, "delete", `/export-tables/${tableId}/columns/${encodeURIComponent(column.id)}`);
5599
+ }
5600
+ output({ table: result.table, removed: targets }, global);
5601
+ } catch (err) {
5602
+ outputError(satvoltError(err));
5603
+ }
5604
+ });
5605
+ }
5606
+
5607
+ // src/commands/satvolt/templates.ts
5608
+ var TEMPLATE_LIST_FIELDS = "id,name,description,steps,businessGroups,searchQuery,maxLeads,sourceCampaignId";
5609
+ var splitList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
5610
+ var templatePath = (idOrName) => `/campaign-templates/${encodeURIComponent(idOrName)}`;
5611
+ function registerSatvoltTemplateCommands(satvolt) {
5612
+ const templates = satvolt.command("templates").summary("Campaign templates: reuse a pipeline for probes, final campaigns or new areas.").description(
5613
+ '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'
5614
+ );
5615
+ templates.command("list").description("List the campaign templates of your company.").option("--search <text>", "Filter by name").action(async (opts) => {
5616
+ const global = getGlobalOpts17(templates);
5617
+ try {
5618
+ const data = await call(satvoltClient(global), "get", "/campaign-templates", {
5619
+ params: { search: opts.search }
5620
+ });
5621
+ if (global.format === "human" && !global.fields) {
5622
+ output(
5623
+ data.map((t) => ({ ...t, steps: t.steps.map((s) => s.action).join(" \u2192 "), businessGroups: t.businessGroups.join(",") })),
5624
+ { ...global, fields: TEMPLATE_LIST_FIELDS }
5625
+ );
5626
+ return;
5627
+ }
5628
+ output(data, global);
5629
+ } catch (err) {
5630
+ outputError(satvoltError(err));
5631
+ }
5632
+ });
5633
+ templates.command("get <idOrName>").description("Template detail, with its full steps and config.").action(async (idOrName) => {
5634
+ const global = getGlobalOpts17(templates);
5635
+ try {
5636
+ output(await call(satvoltClient(global), "get", templatePath(idOrName)), global);
5637
+ } catch (err) {
5638
+ outputError(satvoltError(err));
5639
+ }
5640
+ });
5641
+ templates.command("create").summary("Create a template from a campaign or from JSON steps.").description(
5642
+ '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'
5643
+ ).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) => {
5644
+ const global = getGlobalOpts17(templates);
5645
+ try {
5646
+ const body = opts.data ? readJsonArg(opts.data, "--data") : {};
5647
+ if (opts.name) body.name = opts.name;
5648
+ if (opts.description !== void 0) body.description = opts.description;
5649
+ if (opts.fromCampaign) body.fromCampaignId = parseId(opts.fromCampaign, "--from-campaign");
5650
+ if (opts.steps) body.steps = readJsonArg(opts.steps, "--steps");
5651
+ if (opts.businessGroups) body.businessGroups = splitList(opts.businessGroups);
5652
+ if (opts.configurationDescription !== void 0) body.configurationDescription = opts.configurationDescription;
5653
+ if (opts.searchQuery) body.searchQuery = opts.searchQuery;
5654
+ if (opts.maxLeads !== void 0) body.maxLeads = parseIntOption(opts.maxLeads, "--max-leads");
5655
+ if (!body.name) throw new Error("--name is required");
5656
+ if (body.fromCampaignId === void 0 && body.steps === void 0) {
5657
+ throw new Error("Pass --from-campaign <id> or --steps <json>");
5658
+ }
5659
+ output(await call(satvoltClient(global), "post", "/campaign-templates", { data: body }), global);
5660
+ } catch (err) {
5661
+ outputError(satvoltError(err));
5662
+ }
5663
+ });
5664
+ templates.command("update <idOrName>").summary("Replace a whole template (PUT).").description(
5665
+ "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."
5666
+ ).requiredOption("--data <json>", "Template body (JSON, @file or -)").action(async (idOrName, opts) => {
5667
+ const global = getGlobalOpts17(templates);
5668
+ try {
5669
+ output(
5670
+ await call(satvoltClient(global), "put", templatePath(idOrName), { data: readJsonArg(opts.data, "--data") }),
5671
+ global
5672
+ );
5673
+ } catch (err) {
5674
+ outputError(satvoltError(err));
5675
+ }
5676
+ });
5677
+ templates.command("patch <idOrName>").summary("Change some fields or steps of a template.").description(
5678
+ '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"}}]\''
5679
+ ).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) => {
5680
+ const global = getGlobalOpts17(templates);
5681
+ try {
5682
+ const body = opts.data ? readJsonArg(opts.data, "--data") : {};
5683
+ if (opts.name) body.name = opts.name;
5684
+ if (opts.description !== void 0) body.description = opts.description;
5685
+ if (opts.steps) body.steps = readJsonArg(opts.steps, "--steps");
5686
+ if (opts.businessGroups !== void 0) body.businessGroups = splitList(opts.businessGroups);
5687
+ if (opts.configurationDescription !== void 0) body.configurationDescription = opts.configurationDescription;
5688
+ if (opts.searchQuery !== void 0) body.searchQuery = opts.searchQuery === "" ? null : opts.searchQuery;
5689
+ if (opts.maxLeads === false) body.maxLeads = null;
5690
+ else if (opts.maxLeads !== void 0 && opts.maxLeads !== true) body.maxLeads = parseIntOption(opts.maxLeads, "--max-leads");
5691
+ if (Object.keys(body).length === 0) throw new Error("Nothing to change: pass at least one option");
5692
+ output(await call(satvoltClient(global), "patch", templatePath(idOrName), { data: body }), global);
5693
+ } catch (err) {
5694
+ outputError(satvoltError(err));
5695
+ }
5696
+ });
5697
+ 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) => {
5698
+ const global = getGlobalOpts17(templates);
5699
+ try {
5700
+ if (!opts.yes) throw new Error("Re-run with --yes to delete the template.");
5701
+ output(await call(satvoltClient(global), "delete", templatePath(idOrName)), global);
5702
+ } catch (err) {
5703
+ outputError(satvoltError(err));
5704
+ }
5705
+ });
5706
+ }
5707
+
5708
+ // src/commands/satvolt/index.ts
5709
+ function registerSatvoltCommands(program2) {
5710
+ const satvolt = program2.command("satvolt").description(
5711
+ `Satvolt lead-generation campaigns (public API /satvolt/api/v1), with the same auth token.
5712
+ Preliminary testing phase: every call is pinned to the dev cluster (${SATVOLT_DEV_BASE_URL}),
5713
+ whatever the active profile says. Override with SUNTROPY_SATVOLT_URL, or use a localhost server.
5714
+ Typical flow:
5715
+ satvolt catalog actions actions and their config schema
5716
+ satvolt campaigns create --circle ... --steps @steps.json
5717
+ satvolt campaigns start <id> \xB7 satvolt campaigns logs <id> --follow
5718
+ satvolt campaigns funnel <id> \xB7 satvolt leads list <id> --step QUALIFY
5719
+ satvolt export-tables create <id> ... \xB7 satvolt export-tables export <tableId>
5720
+ satvolt campaigns resume <id> --action AI_AGENT --config @step.json
5721
+ satvolt templates create --name <n> --from-campaign <id> \xB7 campaigns create --template <n>
5722
+ satvolt campaigns extend <id> --max-leads N \xB7 leads run-step <id> <leadId> <step>`
5723
+ );
5724
+ registerSatvoltCampaignCommands(satvolt);
5725
+ registerSatvoltPipelineCommands(satvolt);
5726
+ registerSatvoltLeadCommands(satvolt);
5727
+ registerSatvoltExportTableCommands(satvolt);
5728
+ registerSatvoltTemplateCommands(satvolt);
5729
+ const catalog = satvolt.command("catalog").description("Reference data for building campaigns and pipelines.");
5730
+ const catalogEntries = [
5731
+ ["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>."],
5732
+ ["ai-agents", "/catalog/ai-agents", "AI agents allowed in AI_AGENT config.agentId: id, name and description. Every agent costs the AI_AGENT credits."],
5733
+ ["business-groups", "/catalog/business-groups", "Business category groups for --business-groups."],
5734
+ ["states", "/catalog/states", "Campaign and lead states."]
5735
+ ];
5736
+ for (const [name, path, description] of catalogEntries) {
5737
+ catalog.command(name).description(description).action(async () => {
5738
+ const global = getGlobalOpts17(catalog);
5739
+ try {
5740
+ output(await call(satvoltClient(global), "get", path), global);
5741
+ } catch (err) {
5742
+ outputError(satvoltError(err));
5743
+ }
5744
+ });
5745
+ }
5746
+ addSummaries(satvolt);
5747
+ }
5748
+ function addSummaries(cmd) {
5749
+ for (const sub of cmd.commands) {
5750
+ const description = sub.description();
5751
+ if (!sub.summary() && description.includes("\n")) {
5752
+ const first = description.split("\n")[0].trim();
5753
+ sub.summary(/[.:)]$/.test(first) ? first.replace(/:$/, ".") : `${first}\u2026`);
5754
+ }
5755
+ addSummaries(sub);
5756
+ }
5757
+ }
5758
+
4570
5759
  // src/access.ts
4571
5760
  var COMMAND_TIERS = ["read", "write", "delete"];
4572
5761
  var TIER_VALUE = { read: 0, write: 1, delete: 2 };
@@ -4602,7 +5791,7 @@ function setStoredCommandProfile(tier) {
4602
5791
  else cfg.commandProfile = tier;
4603
5792
  saveConfig(cfg);
4604
5793
  }
4605
- var DELETE_VERBS = /* @__PURE__ */ new Set(["delete", "delete-batch", "archive"]);
5794
+ var DELETE_VERBS = /* @__PURE__ */ new Set(["delete", "delete-batch", "archive", "reset"]);
4606
5795
  var WRITE_VERBS = /* @__PURE__ */ new Set([
4607
5796
  "create",
4608
5797
  "update",
@@ -4619,7 +5808,17 @@ var WRITE_VERBS = /* @__PURE__ */ new Set([
4619
5808
  "comment",
4620
5809
  "send",
4621
5810
  "calculate-results",
4622
- "optimize-peakpower"
5811
+ "optimize-peakpower",
5812
+ // satvolt: these launch pipeline work (spending credits) or change configuration.
5813
+ "patch",
5814
+ "start",
5815
+ "resume",
5816
+ "run",
5817
+ "run-step",
5818
+ "duplicate",
5819
+ "extend",
5820
+ "move",
5821
+ "reorder"
4623
5822
  ]);
4624
5823
  var PATH_OVERRIDES = {
4625
5824
  "studies calculate production": "write"
@@ -4666,7 +5865,7 @@ function keepCommand(cmd, segs, max) {
4666
5865
  }
4667
5866
 
4668
5867
  // src/commands/command-profile.ts
4669
- function getGlobalOpts17(cmd) {
5868
+ function getGlobalOpts18(cmd) {
4670
5869
  let root = cmd;
4671
5870
  while (root.parent) root = root.parent;
4672
5871
  return root.opts();
@@ -4674,7 +5873,7 @@ function getGlobalOpts17(cmd) {
4674
5873
  function registerCommandProfileCommand(program2) {
4675
5874
  program2.command("command-profile [tier]", { hidden: true }).description("Admin: get/set the command access profile (read | write | delete | reset)").action((tier) => {
4676
5875
  try {
4677
- const global = getGlobalOpts17(program2);
5876
+ const global = getGlobalOpts18(program2);
4678
5877
  if (!tier) {
4679
5878
  const resolved = getActiveCommandProfile();
4680
5879
  output(
@@ -4729,7 +5928,7 @@ function registerCommandProfileCommand(program2) {
4729
5928
  }
4730
5929
 
4731
5930
  // src/index.ts
4732
- var CLI_VERSION = true ? "0.11.8" : "0.0.0-dev";
5931
+ var CLI_VERSION = true ? "0.12.0" : "0.0.0-dev";
4733
5932
  function createProgram() {
4734
5933
  const program2 = new Command4();
4735
5934
  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)");
@@ -4745,6 +5944,7 @@ function createProgram() {
4745
5944
  registerTemplatesCommands(program2);
4746
5945
  registerGeocodeCommands(program2);
4747
5946
  registerNotificationsCommands(program2);
5947
+ registerSatvoltCommands(program2);
4748
5948
  registerCommandProfileCommand(program2);
4749
5949
  applyCommandProfile(program2);
4750
5950
  return program2;