@enerlence/suntropy-cli 0.12.0 → 0.14.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.
@@ -1872,7 +1872,7 @@ function registerInventoryCommands(program2) {
1872
1872
  import { Option } from "commander";
1873
1873
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync4, existsSync as existsSync2 } from "fs";
1874
1874
 
1875
- // node_modules/uuid/dist/esm-node/rng.js
1875
+ // ../suntropy-cli/node_modules/uuid/dist/esm-node/rng.js
1876
1876
  import crypto from "crypto";
1877
1877
  var rnds8Pool = new Uint8Array(256);
1878
1878
  var poolPtr = rnds8Pool.length;
@@ -1884,16 +1884,16 @@ function rng() {
1884
1884
  return rnds8Pool.slice(poolPtr, poolPtr += 16);
1885
1885
  }
1886
1886
 
1887
- // node_modules/uuid/dist/esm-node/regex.js
1887
+ // ../suntropy-cli/node_modules/uuid/dist/esm-node/regex.js
1888
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;
1889
1889
 
1890
- // node_modules/uuid/dist/esm-node/validate.js
1890
+ // ../suntropy-cli/node_modules/uuid/dist/esm-node/validate.js
1891
1891
  function validate(uuid) {
1892
1892
  return typeof uuid === "string" && regex_default.test(uuid);
1893
1893
  }
1894
1894
  var validate_default = validate;
1895
1895
 
1896
- // node_modules/uuid/dist/esm-node/stringify.js
1896
+ // ../suntropy-cli/node_modules/uuid/dist/esm-node/stringify.js
1897
1897
  var byteToHex = [];
1898
1898
  for (let i = 0; i < 256; ++i) {
1899
1899
  byteToHex.push((i + 256).toString(16).substr(1));
@@ -1907,7 +1907,7 @@ function stringify(arr, offset = 0) {
1907
1907
  }
1908
1908
  var stringify_default = stringify;
1909
1909
 
1910
- // node_modules/uuid/dist/esm-node/v4.js
1910
+ // ../suntropy-cli/node_modules/uuid/dist/esm-node/v4.js
1911
1911
  function v4(options, buf, offset) {
1912
1912
  options = options || {};
1913
1913
  const rnds = options.random || (options.rng || rng)();
@@ -4645,6 +4645,21 @@ async function call(client, method, path, options = {}) {
4645
4645
  });
4646
4646
  return res.data?.data;
4647
4647
  }
4648
+ async function callMultipart(client, path, filePath, payload, params) {
4649
+ const FormData = (await import("form-data")).default;
4650
+ const form = new FormData();
4651
+ form.append("file", readFileSync6(filePath), { filename: filePath.split("/").pop() });
4652
+ if (payload) form.append("payload", JSON.stringify(payload));
4653
+ const res = await client.request({
4654
+ method: "post",
4655
+ url: path,
4656
+ params: dropEmpty(params),
4657
+ data: form,
4658
+ headers: form.getHeaders(),
4659
+ maxBodyLength: Infinity
4660
+ });
4661
+ return res.data?.data;
4662
+ }
4648
4663
  function dropEmpty(params) {
4649
4664
  if (!params) return void 0;
4650
4665
  return Object.fromEntries(
@@ -4753,7 +4768,7 @@ function buildArea(opts) {
4753
4768
  return { type: "polygon", coordinates: parsed };
4754
4769
  }
4755
4770
  function registerSatvoltCampaignCommands(satvolt) {
4756
- const campaigns = satvolt.command("campaigns").description("Satvolt campaigns: list, create from an area, start, reset, resume, usage, logs and funnel.");
4771
+ const campaigns = satvolt.command("campaigns").description("Satvolt campaigns: list, create from an area, start, pause, cancel, reset, resume, usage, logs and funnel.");
4757
4772
  campaigns.command("list").description(
4758
4773
  'List campaigns, newest first.\nExamples:\n suntropy satvolt campaigns list --state completed,inProgress\n suntropy satvolt campaigns list --search "Alcobendas" --format human'
4759
4774
  ).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) => {
@@ -4831,6 +4846,94 @@ search query and lead limit come from it; any flag you pass wins.
4831
4846
  outputError(satvoltError(err));
4832
4847
  }
4833
4848
  });
4849
+ campaigns.command("estimate").summary("Count the businesses a campaign would find over an area, without creating it.").description(
4850
+ '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"'
4851
+ ).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) => {
4852
+ const global = getGlobalOpts17(campaigns);
4853
+ try {
4854
+ const body = { area: buildArea(opts) };
4855
+ if (opts.template && opts.fromCampaign) throw new Error("Use either --template or --from-campaign, not both");
4856
+ if (opts.template) body.templateId = opts.template;
4857
+ if (opts.fromCampaign) body.fromCampaignId = parseId(opts.fromCampaign, "--from-campaign");
4858
+ if (opts.businessGroups) body.businessGroups = opts.businessGroups.split(",").map((s) => s.trim()).filter(Boolean);
4859
+ if (opts.searchQuery) body.searchQuery = opts.searchQuery;
4860
+ if (opts.sample !== void 0) body.sample = parseIntOption(opts.sample, "--sample");
4861
+ if (opts.offset !== void 0) body.offset = parseIntOption(opts.offset, "--offset");
4862
+ const data = await call(satvoltClient(global, 12e4), "post", "/campaigns/estimate", { data: body });
4863
+ output(data, global);
4864
+ } catch (err) {
4865
+ outputError(satvoltError(err));
4866
+ }
4867
+ });
4868
+ const columnFlag = (value) => value ? { column: value } : void 0;
4869
+ const columnsList = (value) => value ? value.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
4870
+ campaigns.command("excel-preview <file>").summary("Headers and first rows of an Excel, to decide the column mapping.").description(
4871
+ 'Reads the first sheet of an .xlsx (headers in row 1) and returns `headers`,\n`sampleRows` and `totalRows`, without creating anything. Run it before\n`create-from-excel` to see which column holds the name, the address parts,\nthe coordinates ("lat,lng"), the phone, the website or the email.\n\nExample:\n suntropy satvolt campaigns excel-preview empresas.xlsx --sample 10 --format human'
4872
+ ).option("--sample <n>", "Rows to return (1-50, default 5)").action(async (file, opts) => {
4873
+ const global = getGlobalOpts17(campaigns);
4874
+ try {
4875
+ const data = await callMultipart(satvoltClient(global), "/campaigns/excel/preview", file, void 0, {
4876
+ sampleSize: opts.sample !== void 0 ? parseIntOption(opts.sample, "--sample") : void 0
4877
+ });
4878
+ output(data, global);
4879
+ } catch (err) {
4880
+ outputError(satvoltError(err));
4881
+ }
4882
+ });
4883
+ campaigns.command("excel-geocode-test <file>").summary("Geocode the first rows with the chosen address columns, before creating.").description(
4884
+ 'When the Excel has no coordinates, the campaign geocodes each row from the\ncolumns you choose (concatenated with commas). This runs that geocoding on the\nfirst rows only and shows the query, the coordinates and the formatted address\nfound, so you can check the columns are right before paying for every lead.\nCosts one Google geocoding request per sampled row.\n\nExample:\n suntropy satvolt campaigns excel-geocode-test empresas.xlsx \\\n --columns "Direcci\xF3n,CP,Municipio" --region Cantabria --sample 5 --format human'
4885
+ ).requiredOption("--columns <headers>", "Comma-separated Excel headers that form the address, in order").option("--sample <n>", "Rows to test (1-25, default 5)").option("--region <text>", "Region appended to every query to disambiguate (province, country)").action(async (file, opts) => {
4886
+ const global = getGlobalOpts17(campaigns);
4887
+ try {
4888
+ const data = await callMultipart(satvoltClient(global, 12e4), "/campaigns/excel/geocode-test", file, {
4889
+ columns: columnsList(opts.columns),
4890
+ sampleSize: opts.sample !== void 0 ? parseIntOption(opts.sample, "--sample") : void 0,
4891
+ region: opts.region
4892
+ });
4893
+ output(data, global);
4894
+ } catch (err) {
4895
+ outputError(satvoltError(err));
4896
+ }
4897
+ });
4898
+ campaigns.command("create-from-excel <file>").summary("Create a campaign whose leads come from an Excel (no Maps search).").description(
4899
+ 'Imports the rows of the first sheet as leads (one row = one lead) and builds the\npipeline on them. The campaign stays queued unless --start is passed, and it\ncannot be extended later: the leads are fixed at creation.\n\nMapping (see `excel-preview` for the headers):\n --name-column <h> required: commercial name of the business\n --coordinates-column <h> column with "lat,lng" \u2014 OR \u2014\n --geocode-columns <h1,h2> address columns to geocode per lead (GEOCODE_ADDRESS,\n 10 credits per lead; test them with excel-geocode-test)\n --address-columns <h1,h2> address shown on the lead (joined with ", ")\n --phone-column, --url-column, --email-column, --type-column <h>\n --country <text> literal applied to every row\n --mapping <json|@file> full columnMapping object instead of the flags above\n\nPipeline: --template, --from-campaign or --steps, as in `campaigns create`.\n\nExamples:\n suntropy satvolt campaigns create-from-excel empresas.xlsx --name "Clientes CRM" \\\n --name-column Empresa --geocode-columns "Direcci\xF3n,CP,Municipio" --region Cantabria \\\n --template "Industria" --max-leads 100\n suntropy satvolt campaigns create-from-excel leads.xlsx --name "Con coordenadas" \\\n --name-column Nombre --coordinates-column Coordenadas --phone-column Tel\xE9fono \\\n --steps @steps.json'
4900
+ ).requiredOption("--name <name>", "Campaign name").option("--name-column <header>", "Column with the commercial name (required unless --mapping)").option("--coordinates-column <header>", 'Column with "lat,lng" coordinates').option("--geocode-columns <headers>", "Comma-separated address columns to geocode when there are no coordinates").option("--address-columns <headers>", "Comma-separated columns joined as the lead address").option("--phone-column <header>", "Column with the phone").option("--url-column <header>", "Column with the website").option("--email-column <header>", "Column with the email (stored in fullData.importMetadata.email)").option("--type-column <header>", "Column with the business type (googlePlacesType)").option("--country <text>", "Country applied to every lead").option("--mapping <json>", "columnMapping as JSON, @file or - (overrides the *-column flags)").option("--template <idOrName>", "Base the pipeline on a campaign template").option("--from-campaign <id>", "Copy the pipeline of another campaign").option("--steps <json>", "LEAD steps as JSON array, @file or -").option("--max-leads <n>", "Import only the first n rows").option("--region <text>", "Region of the leads (also biases the geocoding)").option("--description <text>", "Natural language description of the configuration").option("--start", "Start the pipeline right after creating it (spends credits)").action(async (file, opts) => {
4901
+ const global = getGlobalOpts17(campaigns);
4902
+ try {
4903
+ const columnMapping = opts.mapping ? readJsonArg(opts.mapping, "--mapping") : {
4904
+ commercialName: columnFlag(opts.nameColumn),
4905
+ coordinates: columnFlag(opts.coordinatesColumn),
4906
+ address: columnsList(opts.addressColumns)?.map((column) => ({ column })),
4907
+ phone: columnFlag(opts.phoneColumn),
4908
+ url: columnFlag(opts.urlColumn),
4909
+ email: columnFlag(opts.emailColumn),
4910
+ googlePlacesType: columnFlag(opts.typeColumn),
4911
+ country: opts.country ? { literal: opts.country } : void 0
4912
+ };
4913
+ if (!columnMapping.commercialName) throw new Error("--name-column is required (or a --mapping with commercialName)");
4914
+ const geocodeColumns = columnsList(opts.geocodeColumns);
4915
+ if (!columnMapping.coordinates && !geocodeColumns) {
4916
+ throw new Error("Pass --coordinates-column <header> or --geocode-columns <h1,h2,...>");
4917
+ }
4918
+ if (opts.template && opts.fromCampaign) throw new Error("Use either --template or --from-campaign, not both");
4919
+ const payload = {
4920
+ name: opts.name,
4921
+ columnMapping,
4922
+ geocoding: geocodeColumns ? { enabled: true, columns: geocodeColumns } : void 0,
4923
+ templateId: opts.template,
4924
+ fromCampaignId: opts.fromCampaign ? parseId(opts.fromCampaign, "--from-campaign") : void 0,
4925
+ steps: opts.steps ? readJsonArg(opts.steps, "--steps") : void 0,
4926
+ maxLeads: opts.maxLeads !== void 0 ? parseIntOption(opts.maxLeads, "--max-leads") : void 0,
4927
+ region: opts.region,
4928
+ description: opts.description,
4929
+ start: opts.start ? true : void 0
4930
+ };
4931
+ const data = await callMultipart(satvoltClient(global, 3e5), "/campaigns/from-excel", file, payload);
4932
+ output(data, global);
4933
+ } catch (err) {
4934
+ outputError(satvoltError(err));
4935
+ }
4936
+ });
4834
4937
  campaigns.command("delete <campaignId>").summary("Delete a campaign and everything it generated (irreversible, --yes).").description(
4835
4938
  "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
4939
  ).option("--yes", "Confirm the deletion (required)").action(async (campaignId, opts) => {
@@ -4852,6 +4955,40 @@ search query and lead limit come from it; any flag you pass wins.
4852
4955
  outputError(satvoltError(err));
4853
4956
  }
4854
4957
  });
4958
+ campaigns.command("pause <campaignId>").summary("Pause a running campaign: nothing else runs or is charged until unpause.").description(
4959
+ "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"
4960
+ ).action(async (campaignId) => {
4961
+ const global = getGlobalOpts17(campaigns);
4962
+ try {
4963
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/pause`);
4964
+ output(data, global);
4965
+ } catch (err) {
4966
+ outputError(satvoltError(err));
4967
+ }
4968
+ });
4969
+ campaigns.command("unpause <campaignId>").summary("Resume a paused campaign where it stopped (no step is run twice).").description(
4970
+ "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"
4971
+ ).action(async (campaignId) => {
4972
+ const global = getGlobalOpts17(campaigns);
4973
+ try {
4974
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/unpause`);
4975
+ output(data, global);
4976
+ } catch (err) {
4977
+ outputError(satvoltError(err));
4978
+ }
4979
+ });
4980
+ campaigns.command("cancel <campaignId>").summary("Cancel a running or paused campaign for good, keeping its leads and data (--yes).").description(
4981
+ "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"
4982
+ ).option("--yes", "Confirm the cancellation (required)").action(async (campaignId, opts) => {
4983
+ const global = getGlobalOpts17(campaigns);
4984
+ try {
4985
+ if (!opts.yes) throw new Error("Cancelling a campaign is irreversible. Re-run with --yes to confirm.");
4986
+ const data = await call(satvoltClient(global, 12e4), "post", `/campaigns/${parseId(campaignId, "campaignId")}/cancel`);
4987
+ output(data, global);
4988
+ } catch (err) {
4989
+ outputError(satvoltError(err));
4990
+ }
4991
+ });
4855
4992
  campaigns.command("reset <campaignId>").summary("Relaunch a campaign from scratch: deletes its leads and results (--yes).").description(
4856
4993
  "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
4994
  ).option("--start", "Start the pipeline again after resetting").option("--yes", "Confirm the reset (required)").action(async (campaignId, opts) => {
@@ -5719,13 +5856,40 @@ Typical flow:
5719
5856
  satvolt export-tables create <id> ... \xB7 satvolt export-tables export <tableId>
5720
5857
  satvolt campaigns resume <id> --action AI_AGENT --config @step.json
5721
5858
  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>`
5859
+ satvolt campaigns extend <id> --max-leads N \xB7 leads run-step <id> <leadId> <step>
5860
+ satvolt campaigns pause <id> \xB7 unpause <id> \xB7 cancel <id> --yes
5861
+ satvolt usage [--month YYYY-MM] credits spent by the account this month`
5723
5862
  );
5724
5863
  registerSatvoltCampaignCommands(satvolt);
5725
5864
  registerSatvoltPipelineCommands(satvolt);
5726
5865
  registerSatvoltLeadCommands(satvolt);
5727
5866
  registerSatvoltExportTableCommands(satvolt);
5728
5867
  registerSatvoltTemplateCommands(satvolt);
5868
+ satvolt.command("usage").summary("Credits spent by the whole account in a calendar month, by campaign and by step.").description(
5869
+ "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"
5870
+ ).option("--month <YYYY-MM>", "Month to report (default: current month)").action(async (opts) => {
5871
+ const global = getGlobalOpts17(satvolt);
5872
+ try {
5873
+ if (opts.month !== void 0 && !/^\d{4}-\d{2}$/.test(String(opts.month))) {
5874
+ throw new Error("--month must be YYYY-MM, e.g. 2026-08");
5875
+ }
5876
+ const data = await call(satvoltClient(global, 12e4), "get", "/usage", { params: { month: opts.month } });
5877
+ if (global.format === "human" || global.format === "csv") {
5878
+ if (global.format === "human") {
5879
+ process.stderr.write(
5880
+ `${data.month}: ${data.credits} credits \xB7 ${data.executions} executions \xB7 previous month (${data.previous.month}): ${data.previous.credits} credits
5881
+
5882
+ `
5883
+ );
5884
+ }
5885
+ output(data.byCampaign, global);
5886
+ return;
5887
+ }
5888
+ output(data, global);
5889
+ } catch (err) {
5890
+ outputError(satvoltError(err));
5891
+ }
5892
+ });
5729
5893
  const catalog = satvolt.command("catalog").description("Reference data for building campaigns and pipelines.");
5730
5894
  const catalogEntries = [
5731
5895
  ["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>."],
@@ -5818,7 +5982,10 @@ var WRITE_VERBS = /* @__PURE__ */ new Set([
5818
5982
  "duplicate",
5819
5983
  "extend",
5820
5984
  "move",
5821
- "reorder"
5985
+ "reorder",
5986
+ "pause",
5987
+ "unpause",
5988
+ "cancel"
5822
5989
  ]);
5823
5990
  var PATH_OVERRIDES = {
5824
5991
  "studies calculate production": "write"
@@ -5928,7 +6095,7 @@ function registerCommandProfileCommand(program2) {
5928
6095
  }
5929
6096
 
5930
6097
  // src/index.ts
5931
- var CLI_VERSION = true ? "0.12.0" : "0.0.0-dev";
6098
+ var CLI_VERSION = true ? "0.14.0" : "0.0.0-dev";
5932
6099
  function createProgram() {
5933
6100
  const program2 = new Command4();
5934
6101
  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)");