@erdoai/cli 0.83.0 → 0.85.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.
Files changed (2) hide show
  1. package/dist/index.js +293 -6
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,5 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/refresh.ts
4
+ function describeRefreshTrigger(state) {
5
+ if (state.disarmed) return "disarmed \u2014 the experiment has settled";
6
+ if (state.strategy === "live_webhook") return "incoming webhook events \u2014 page views do not trigger this feed";
7
+ if (state.strategy === "live_native_poll") return "not running \u2014 native polling is unavailable; configure script_js with a schedule or live_webhook";
8
+ if (!state.job_id) return "not running \u2014 no linked job; configure the refresh again";
9
+ if (state.job_enabled === false) return "not running \u2014 the linked job is disabled; configure the refresh again";
10
+ if (state.schedule) return state.schedule_enabled === false ? `cron ${state.schedule} is disabled` : `cron: ${state.schedule}`;
11
+ return state.refresh_on_view_enabled !== false ? "runs on view \u2014 when a bound page is opened and the data is stale" : "manual only \u2014 page views do not trigger this refresh";
12
+ }
13
+ function describeRefreshWrites(strategy, mode, key, liveMode) {
14
+ if (strategy?.startsWith("live_")) return liveMode === "append" ? "append \u2014 each incoming event adds rows" : "replace \u2014 each incoming event replaces the current rows";
15
+ return mode === "upsert" ? `upsert${key ? ` on ${key}` : ""} \u2014 rows outside what the recipe returns are kept` : "replace \u2014 rows the recipe does not return are deleted";
16
+ }
17
+
3
18
  // src/index.ts
4
19
  import { readFileSync as readFileSync4 } from "fs";
5
20
  import { basename } from "path";
@@ -1072,6 +1087,35 @@ var ErdoClient = class {
1072
1087
  listDatasetRevisions(slug) {
1073
1088
  return this.request("GET", `/v1/datasets/${encodeURIComponent(slug)}/revisions`);
1074
1089
  }
1090
+ // --- dataset refreshes ---
1091
+ // The configuration that keeps one dataset current — the recipe and the
1092
+ // triggers that run it. A dataset nothing refreshes answers configured:false
1093
+ // rather than 404, so an empty answer is still an answer.
1094
+ getDatasetRefresh(slug) {
1095
+ return this.request(
1096
+ "GET",
1097
+ `/v1/datasets/${encodeURIComponent(slug)}/refresh`
1098
+ );
1099
+ }
1100
+ // Install or replace the whole configuration. Deterministic strategies are
1101
+ // test-run before this returns and the outcome comes back on test_run — a
1102
+ // recipe that saved but cannot run is not a working refresh.
1103
+ setDatasetRefresh(slug, body) {
1104
+ return this.request(
1105
+ "PUT",
1106
+ `/v1/datasets/${encodeURIComponent(slug)}/refresh`,
1107
+ body
1108
+ );
1109
+ }
1110
+ // Run the configured refresh once, now. It is asynchronous: this answers with
1111
+ // the execution it started, and the outcome is read back from
1112
+ // getDatasetRefresh.
1113
+ runDatasetRefresh(slug) {
1114
+ return this.request(
1115
+ "POST",
1116
+ `/v1/datasets/${encodeURIComponent(slug)}/refresh/run`
1117
+ );
1118
+ }
1075
1119
  // --- bounded outreach ---
1076
1120
  putOutreachBatch(batch, body) {
1077
1121
  return this.request(
@@ -1693,6 +1737,12 @@ async function resolveSecretInput(options) {
1693
1737
  return void 0;
1694
1738
  }
1695
1739
 
1740
+ // src/integrations.ts
1741
+ function formatIntegrationListRow(i) {
1742
+ const provided = i.provided_by_organization_slug ? ` provided by ${i.provided_by_organization_slug}` : "";
1743
+ return `${i.app} ${i.status} ${i.auth_type} ${i.name} ${i.id}${provided}`;
1744
+ }
1745
+
1696
1746
  // src/scope.ts
1697
1747
  import { Option } from "commander";
1698
1748
  var ORG_ENV = "ERDO_ORG";
@@ -5681,6 +5731,232 @@ datasetsCmd.command("configure-integration <dataset-id>").description("Set an in
5681
5731
  fail(e);
5682
5732
  }
5683
5733
  });
5734
+ var refreshCmd = datasetsCmd.command("refresh").description(
5735
+ "The recipe that keeps a dataset current, and the triggers that run it \u2014 read it with `show`, install or replace it with `set`, and run it once now with `run`"
5736
+ );
5737
+ var REFRESH_STRATEGIES = ["script", "script_js", "agent_run", "live_webhook"];
5738
+ function readRefreshJSONFile(value, label) {
5739
+ return readJSONObject(value.startsWith("@") ? value.slice(1) : value, label);
5740
+ }
5741
+ refreshCmd.command("show <slug>").description(
5742
+ "Show what keeps a dataset current \u2014 which recipe runs, whether it replaces or merges rows, what triggers it, and how its last run went. A dataset nothing refreshes says so plainly instead of erroring, because 'nothing keeps this current' is the answer most worth reading. The recipe's own source is not printed here; --json carries it."
5743
+ ).option("--json", "print the raw JSON result instead of a table").action(async (slug, opts) => {
5744
+ try {
5745
+ const res = await new ErdoClient().getDatasetRefresh(slug);
5746
+ if (opts.json) {
5747
+ print(res);
5748
+ return;
5749
+ }
5750
+ if (!res.configured) {
5751
+ console.log(
5752
+ `Nothing keeps ${res.dataset_slug} current \u2014 its rows change only when something writes to it. Install a recipe with: erdo datasets refresh set ${res.dataset_slug} --strategy script_js --transform-js @refresh.js`
5753
+ );
5754
+ return;
5755
+ }
5756
+ const rows = [];
5757
+ rows.push(["strategy", res.strategy || "(none recorded)"]);
5758
+ rows.push(["writes", describeRefreshWrites(res.strategy, res.refresh_mode, res.key_column, res.live_write_mode)]);
5759
+ rows.push(["trigger", describeRefreshTrigger(res)]);
5760
+ if (res.schedule) {
5761
+ const zone = res.schedule_timezone || "UTC";
5762
+ rows.push([
5763
+ "cron",
5764
+ res.schedule_enabled ? `${res.schedule} (${zone})` : `${res.schedule} (${zone}) \u2014 DISARMED, this cron is not firing`
5765
+ ]);
5766
+ } else {
5767
+ rows.push(["cron", "none"]);
5768
+ }
5769
+ rows.push([
5770
+ "on view",
5771
+ res.refresh_on_view_enabled ? `yes \u2014 opening a page bound to this dataset refreshes it once the data is over ${res.refresh_on_view_stale_after_seconds}s old, at most once every ${res.refresh_on_view_debounce_seconds}s` : "no \u2014 opening a page bound to this dataset never triggers a refresh"
5772
+ ]);
5773
+ if (res.disarmed) {
5774
+ rows.push([
5775
+ "disarmed",
5776
+ "yes \u2014 the platform stopped this schedule after the measurement it fed settled"
5777
+ ]);
5778
+ }
5779
+ if (res.last_refresh_at || res.last_refresh_status) {
5780
+ const parts = [res.last_refresh_status || "status not recorded"];
5781
+ if (res.last_refresh_at) parts.push(res.last_refresh_at);
5782
+ if (res.last_refresh_duration_ms !== void 0) {
5783
+ parts.push(`${res.last_refresh_duration_ms} ms`);
5784
+ }
5785
+ rows.push(["last run", parts.join(" ")]);
5786
+ if (res.last_refresh_error) rows.push(["last error", res.last_refresh_error]);
5787
+ } else {
5788
+ rows.push(["last run", "never \u2014 this refresh has not run yet"]);
5789
+ }
5790
+ if (res.live_write_mode) rows.push(["live write mode", res.live_write_mode]);
5791
+ if (res.action_key) rows.push(["action", res.action_key]);
5792
+ if (res.poll_interval_ms !== void 0) {
5793
+ rows.push(["poll interval", `${res.poll_interval_ms} ms`]);
5794
+ }
5795
+ if (res.context_dataset_ids?.length) {
5796
+ rows.push([
5797
+ "context datasets",
5798
+ `${res.context_dataset_ids.length} other dataset(s) the recipe reads`
5799
+ ]);
5800
+ }
5801
+ for (const [label, source] of [
5802
+ ["script (python)", res.script],
5803
+ ["transform_js", res.transform_js],
5804
+ ["agent prompt", res.agent_prompt]
5805
+ ]) {
5806
+ if (source) rows.push(["recipe", `${label}, ${source.length} characters`]);
5807
+ }
5808
+ if (res.job_id) rows.push(["job", res.job_id]);
5809
+ printAlignedTable(["setting", "value"], rows);
5810
+ if (!res.strategy?.startsWith("live_") && res.job_id && res.job_enabled !== false && !res.disarmed && !res.schedule && !res.refresh_on_view_enabled) {
5811
+ console.log(
5812
+ `
5813
+ Nothing triggers this refresh on its own \u2014 it runs only when asked: erdo datasets refresh run ${res.dataset_slug}`
5814
+ );
5815
+ }
5816
+ if (res.script || res.transform_js || res.agent_prompt) {
5817
+ console.log(`
5818
+ Read the recipe with: erdo datasets refresh show ${res.dataset_slug} --json`);
5819
+ }
5820
+ } catch (e) {
5821
+ fail(e);
5822
+ }
5823
+ });
5824
+ refreshCmd.command("set <slug>").description(
5825
+ "Install or replace the refresh that keeps a dataset current. This writes the whole configuration, so pass everything the strategy needs in one call \u2014 what you omit is what that strategy does not use. Omitting --schedule is the default and cheapest behaviour: the dataset refreshes when somebody opens a page bound to it and the data is stale, and never runs while nobody is looking; add a cron only when the data must be fresh for something that is not a viewer. --mode replace deletes the rows the recipe did not return, so anything time-series or incremental wants --mode upsert --key-column <c>, which merges by that key and leaves rows outside the returned window alone. Deterministic strategies are test-run before this returns; a recipe that saves but cannot run exits non-zero rather than reporting success."
5826
+ ).requiredOption(
5827
+ "--strategy <s>",
5828
+ `how the refresh runs: ${REFRESH_STRATEGIES.join(", ")}. script_js is JavaScript and the usual choice for fetch-and-reshape; script is Python, for when the recipe needs pandas or numpy; agent_run hands the job to an LLM agent and is the expensive last resort; live_webhook is a push-driven feed; native polling is unavailable`
5829
+ ).option(
5830
+ "--mode <replace|upsert>",
5831
+ "replace overwrites every row with what the recipe returned; upsert merges by --key-column and keeps rows outside the returned window. Omit it and you get upsert when you passed --key-column and replace when you did not, since a key column is only meaningful to a merge"
5832
+ ).option("--key-column <c>", "the column an upsert merges on; required with --mode upsert").option(
5833
+ "--schedule <cron>",
5834
+ "a cron expression to run the refresh on a fixed cadence. Omit it and the refresh runs on view instead \u2014 cheaper, because it never runs while nobody is looking"
5835
+ ).option("--timezone <tz>", "IANA timezone the cron expression is read in (default UTC)").option(
5836
+ "--script <pyOr@file>",
5837
+ "Python source for the script strategy, inline or @path; it must call emit_dataset(df) or emit_rows(rows)"
5838
+ ).option("--script-entrypoint <f>", "entrypoint filename for the script strategy (default main.py)").option(
5839
+ "--transform-js <jsOr@file>",
5840
+ "JavaScript source, inline or @path. script_js enters at function refresh(ctx) returning rows; the live strategies enter at function transform(raw) reshaping one event"
5841
+ ).option("--agent-prompt <textOr@file>", "instructions for the agent_run strategy, inline or @path").option(
5842
+ "--context-datasets <csv>",
5843
+ "slugs of other datasets the recipe reads besides the one it writes"
5844
+ ).option(
5845
+ "--on-view",
5846
+ "let a page view trigger this refresh (the platform default); pass --no-on-view to turn it off. Sent only when you pass one of the two"
5847
+ ).option("--no-on-view", "stop page views from triggering this refresh").option(
5848
+ "--stale-after <seconds>",
5849
+ "how old the data must be before a page view triggers a refresh",
5850
+ (v) => parseInt(v, 10)
5851
+ ).option(
5852
+ "--debounce <seconds>",
5853
+ "minimum gap between two view-triggered refreshes",
5854
+ (v) => parseInt(v, 10)
5855
+ ).option(
5856
+ "--live-write-mode <append|replace>",
5857
+ "for the live strategies: append for a time-series that accumulates, replace for current state that overwrites on each event"
5858
+ ).option("--payload-schema <@file>", "JSON file: a schema the transformed row must match").option(
5859
+ "--sample-payload <@file>",
5860
+ "JSON file: an example upstream payload, used to test-run transform_js at configure time"
5861
+ ).option(
5862
+ "--parameters <@file>",
5863
+ "JSON file: all configuration values for this recipe; omitting clears previous PARAMETERS values"
5864
+ ).action(
5865
+ async (slug, opts) => {
5866
+ try {
5867
+ if (!REFRESH_STRATEGIES.includes(opts.strategy)) {
5868
+ fail(
5869
+ new Error(
5870
+ `--strategy must be one of: ${REFRESH_STRATEGIES.join(", ")} (got ${JSON.stringify(opts.strategy)})`
5871
+ )
5872
+ );
5873
+ return;
5874
+ }
5875
+ if (opts.mode && opts.mode !== "replace" && opts.mode !== "upsert") {
5876
+ fail(new Error("--mode must be replace or upsert"));
5877
+ return;
5878
+ }
5879
+ if (opts.mode === "upsert" && !opts.keyColumn) {
5880
+ fail(new Error("--mode upsert needs --key-column <c>: the column the merge keys on"));
5881
+ return;
5882
+ }
5883
+ const body = { strategy: opts.strategy };
5884
+ if (opts.mode) body.refresh_mode = opts.mode;
5885
+ if (opts.keyColumn) body.key_column = opts.keyColumn;
5886
+ if (opts.schedule) body.schedule = opts.schedule;
5887
+ if (opts.timezone) body.timezone = opts.timezone;
5888
+ const script = readMaybeFile(opts.script);
5889
+ if (script !== void 0) body.script = script;
5890
+ if (opts.scriptEntrypoint) body.script_entrypoint = opts.scriptEntrypoint;
5891
+ const transformJS = readMaybeFile(opts.transformJs);
5892
+ if (transformJS !== void 0) body.transform_js = transformJS;
5893
+ const agentPrompt = readMaybeFile(opts.agentPrompt);
5894
+ if (agentPrompt !== void 0) body.agent_prompt = agentPrompt;
5895
+ if (opts.contextDatasets) {
5896
+ const slugs = opts.contextDatasets.split(",").map((s) => s.trim()).filter(Boolean);
5897
+ if (slugs.length) body.context_dataset_slugs = slugs;
5898
+ }
5899
+ if (typeof opts.onView === "boolean") body.refresh_on_view_enabled = opts.onView;
5900
+ if (opts.staleAfter !== void 0) body.refresh_on_view_stale_after_seconds = opts.staleAfter;
5901
+ if (opts.debounce !== void 0) body.refresh_on_view_debounce_seconds = opts.debounce;
5902
+ if (opts.liveWriteMode) body.live_write_mode = opts.liveWriteMode;
5903
+ if (opts.payloadSchema) {
5904
+ body.payload_schema = readRefreshJSONFile(opts.payloadSchema, "--payload-schema");
5905
+ }
5906
+ if (opts.samplePayload) {
5907
+ body.sample_payload = readRefreshJSONFile(opts.samplePayload, "--sample-payload");
5908
+ }
5909
+ if (opts.parameters) {
5910
+ body.parameters = readRefreshJSONFile(opts.parameters, "--parameters");
5911
+ }
5912
+ const res = await new ErdoClient().setDatasetRefresh(slug, body);
5913
+ console.log(
5914
+ `Installed a ${res.strategy} refresh on ${res.dataset_slug}${res.previous_strategy ? ` (replacing ${res.previous_strategy})` : ""}`
5915
+ );
5916
+ console.log(`writes: ${describeRefreshWrites(res.strategy, res.refresh_mode, body.key_column, body.live_write_mode)}`);
5917
+ console.log(describeRefreshTrigger({ ...res, refresh_on_view_enabled: body.refresh_on_view_enabled }));
5918
+ if (res.job_id) console.log(`job: ${res.job_id}`);
5919
+ if (res.webhook_url) {
5920
+ console.log(`webhook url: ${res.webhook_url}`);
5921
+ if (res.webhook_secret) {
5922
+ console.log(`webhook secret: ${res.webhook_secret}`);
5923
+ console.log("The secret is shown once, here \u2014 it is never returned again; store it now.");
5924
+ }
5925
+ }
5926
+ if (res.test_run) {
5927
+ if (res.test_run.success) {
5928
+ console.log(`test run: passed in ${res.test_run.duration_ms} ms`);
5929
+ } else {
5930
+ console.error(
5931
+ `test run: FAILED after ${res.test_run.duration_ms} ms \u2014 ${res.test_run.error || "no error reported"}`
5932
+ );
5933
+ console.error(
5934
+ "The configuration is saved but the recipe does not run. Fix it and re-run this command."
5935
+ );
5936
+ process.exitCode = 1;
5937
+ }
5938
+ }
5939
+ } catch (e) {
5940
+ fail(e);
5941
+ }
5942
+ }
5943
+ );
5944
+ refreshCmd.command("run <slug>").description(
5945
+ "Run a dataset's configured refresh once, now \u2014 the same recipe a cron or a page view would run, without waiting for either. It is asynchronous: this answers with the execution it started, and `datasets refresh show <slug>` carries the outcome once it finishes."
5946
+ ).option("--json", "print the raw JSON result instead of a summary").action(async (slug, opts) => {
5947
+ try {
5948
+ const res = await new ErdoClient().runDatasetRefresh(slug);
5949
+ if (opts.json) {
5950
+ print(res);
5951
+ return;
5952
+ }
5953
+ console.log(`Started a ${res.strategy} refresh of ${res.dataset_slug} (${res.status})`);
5954
+ console.log(`execution: ${res.job_execution_id}`);
5955
+ console.log(`Check how it went with: erdo datasets refresh show ${res.dataset_slug}`);
5956
+ } catch (e) {
5957
+ fail(e);
5958
+ }
5959
+ });
5684
5960
  var analytics = program.command("analytics").description("Page analytics \u2014 what is tracking your published pages, and how they perform with real visitors");
5685
5961
  analytics.command("query <hogql>").description("Run a read-only HogQL query against this org's page-analytics events").option("--json", "print the raw JSON result instead of a table").action(async (hogql, opts) => {
5686
5962
  try {
@@ -5759,13 +6035,10 @@ filterCmd.command("list <slug>").description("List the default filters on a data
5759
6035
  }
5760
6036
  });
5761
6037
  var integrationsCmd = program.command("integrations").description("Connect and inspect integrations");
5762
- integrationsCmd.command("list").description("List connected integrations").action(async () => {
6038
+ integrationsCmd.command("list").description("List connected integrations \u2014 app, status, auth type, name, then the connection's id (what --from-connection takes)").action(async () => {
5763
6039
  try {
5764
6040
  const { integrations } = await new ErdoClient().listIntegrations();
5765
- for (const i of integrations) {
5766
- const provided = i.provided_by_organization_slug ? ` provided by ${i.provided_by_organization_slug}` : "";
5767
- console.log(`${i.app} ${i.status} ${i.auth_type} ${i.name}${provided}`);
5768
- }
6041
+ for (const i of integrations) console.log(formatIntegrationListRow(i));
5769
6042
  } catch (e) {
5770
6043
  fail(e);
5771
6044
  }
@@ -5781,8 +6054,21 @@ integrationsCmd.command("apps [query]").description("Search connectable apps (na
5781
6054
  integrationsCmd.command("connect <app>").description("Connect an app \u2014 pass its API key or other credentials with -c, or omit them to get a browser connect URL for OAuth apps").option("-c, --credential <key=value...>", "credential field, e.g. -c api_key=\u2026 (repeatable); rejected by OAuth apps, which have no credential to pass", (v, acc) => acc.concat(v), []).option("-n, --name <name>", "display name for the connection").option("--rotate", "replace the credentials of the app's existing connection instead of adding a second one \u2014 for a key that was rotated at the provider; requires -c").option("--share", "share the connection with the whole organization instead of keeping it private to you \u2014 for org-level credentials teammates and agents should see").option(
5782
6055
  "--provide-to-managed",
5783
6056
  "let the organizations you manage use this connection as if it were their own \u2014 for a vendor key your agency pays for. Key-authenticated native integrations only, connected in a manager organization"
6057
+ ).option(
6058
+ "--from-connection <integration-id>",
6059
+ "reuse a credential you verifiably supplied \u2014 find its connection id in column 5 of `erdo integrations list --org <that-org>`. The credential is copied inside Erdo and never shown. Legacy keys without supplier records require an authenticated credential update"
5784
6060
  ).action(async (app, opts) => {
5785
6061
  try {
6062
+ if (opts.fromConnection && opts.credential.length) {
6063
+ fail(new Error("pass either -c or --from-connection, not both \u2014 otherwise the connection would hold a credential you did not choose"));
6064
+ }
6065
+ if (opts.fromConnection && opts.rotate) {
6066
+ fail(
6067
+ new Error(
6068
+ "--from-connection creates a new connection from a credential you already hold, so it cannot be combined with --rotate, which replaces the credential on an existing one"
6069
+ )
6070
+ );
6071
+ }
5786
6072
  let credentials;
5787
6073
  if (opts.credential.length) {
5788
6074
  credentials = {};
@@ -5798,7 +6084,8 @@ integrationsCmd.command("connect <app>").description("Connect an app \u2014 pass
5798
6084
  credentials,
5799
6085
  rotate_credentials: opts.rotate || void 0,
5800
6086
  share_with_org: opts.share || void 0,
5801
- managed_access: opts.provideToManaged ? "managed_organizations" : void 0
6087
+ managed_access: opts.provideToManaged ? "managed_organizations" : void 0,
6088
+ credentials_from_integration_id: opts.fromConnection || void 0
5802
6089
  });
5803
6090
  print(res);
5804
6091
  const notes = [];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erdoai/cli",
3
- "version": "0.83.0",
4
- "description": "Erdo CLI \u2014 drive datasets, pages, and evals from the terminal or CI",
3
+ "version": "0.85.0",
4
+ "description": "Erdo CLI drive datasets, pages, and evals from the terminal or CI",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "erdo": "dist/index.js"
@@ -53,4 +53,4 @@
53
53
  "overrides": {
54
54
  "esbuild": "^0.28.1"
55
55
  }
56
- }
56
+ }