@withone/cli 1.39.1 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -312,9 +312,9 @@ one sync install && one sync doctor
312
312
  ```
313
313
 
314
314
  ```bash
315
- # Discover → init (one command: infer + auto-resolve key + auto-test) → run
315
+ # Discover → init (one command: infer + late-bound connection + auto-test) → run
316
316
  one sync models stripe
317
- one sync init stripe balanceTransactions # connectionKey auto-resolved, test auto-run
317
+ one sync init stripe balanceTransactions # connection: { platform } baked in, test auto-run
318
318
  one sync run stripe --since 90d
319
319
 
320
320
  # Query, search, SQL
@@ -332,6 +332,8 @@ one sync run stripe --full-refresh
332
332
 
333
333
  > **Sync uses passthrough actions only.** Profiles referencing a custom/composer action are rejected at runtime. `sync models` already filters to passthrough-only; if a model has no passthrough list endpoint, compose a flow instead of syncing.
334
334
 
335
+ > **Connections are late-bound.** Profiles use `"connection": { "platform": "<name>", "tag"?: "..." }` instead of literal `connectionKey` strings. The key is resolved at sync time, so `one add <platform>` (re-auth) doesn't break the profile. `tag` only needed for multi-account platforms (e.g. two Gmail accounts).
336
+
335
337
  | Subcommand | What it does |
336
338
  |------------|-------------|
337
339
  | `install` / `doctor` | Install + verify the SQLite engine |
@@ -98,6 +98,42 @@ var OneApi = class {
98
98
  async deleteConnection(id) {
99
99
  await this.requestFull({ path: `/vault/connections/${id}`, method: "DELETE" });
100
100
  }
101
+ /**
102
+ * Resolve a late-bound `ConnectionRef` to a current `Connection`. Pass
103
+ * `cache` (a pre-fetched connection list) when resolving many refs in a
104
+ * loop, so each resolve doesn't repeat the listConnections round-trip.
105
+ *
106
+ * Errors are deliberately verbose: a sync profile or flow that fails to
107
+ * resolve a connection should surface *why* (no match / wrong tag /
108
+ * ambiguous) so the agent can fix the ref without trial and error.
109
+ */
110
+ async resolveConnection(ref, cache) {
111
+ const all = cache ?? await this.listConnections();
112
+ const platformLower = ref.platform.toLowerCase();
113
+ const candidates = all.filter((c) => c.platform.toLowerCase() === platformLower);
114
+ if (candidates.length === 0) {
115
+ throw new Error(
116
+ `No connection found for platform "${ref.platform}". Run 'one add ${ref.platform}' to connect.`
117
+ );
118
+ }
119
+ let matches = candidates;
120
+ if (ref.tag) {
121
+ matches = candidates.filter((c) => c.tags?.includes(ref.tag));
122
+ if (matches.length === 0) {
123
+ const availableTags = candidates.flatMap((c) => c.tags ?? []);
124
+ throw new Error(
125
+ `No "${ref.platform}" connection has tag "${ref.tag}". Available tags: ${availableTags.length > 0 ? availableTags.join(", ") : "(none)"}.`
126
+ );
127
+ }
128
+ }
129
+ if (matches.length > 1) {
130
+ const tagList = matches.map((c) => c.tags?.length ? c.tags.join(",") : "(no tag)").join("; ");
131
+ throw new Error(
132
+ `Multiple "${ref.platform}" connections found (tags: ${tagList}). Add a "tag" field to the connection ref to disambiguate.`
133
+ );
134
+ }
135
+ return matches[0];
136
+ }
101
137
  async listPlatforms() {
102
138
  const allPlatforms = [];
103
139
  let page = 1;
@@ -1079,7 +1115,7 @@ async function executeSubflowStep(step, context, api, permissions, allowedAction
1079
1115
  if (flowStack.includes(resolvedKey)) {
1080
1116
  throw new Error(`Circular flow detected: ${[...flowStack, resolvedKey].join(" \u2192 ")}`);
1081
1117
  }
1082
- const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-OVOGDXNR.js");
1118
+ const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-2KVAPP6E.js");
1083
1119
  const { flow: subFlow, rootDir: subRootDir } = loadFlowWithMeta2(resolvedKey);
1084
1120
  const subContext = await executeFlow(
1085
1121
  subFlow,
@@ -11,7 +11,7 @@ import {
11
11
  saveFlow,
12
12
  summarizeFlowInputs,
13
13
  walkSteps
14
- } from "./chunk-MNJJE4PJ.js";
14
+ } from "./chunk-SSEDFOVV.js";
15
15
  export {
16
16
  FlowRunner,
17
17
  collectStepTypes,
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  resolveFlowPath,
20
20
  saveFlow,
21
21
  validateActionInput
22
- } from "./chunk-MNJJE4PJ.js";
22
+ } from "./chunk-SSEDFOVV.js";
23
23
 
24
24
  // src/index.ts
25
25
  import { createRequire as createRequire2 } from "module";
@@ -4732,12 +4732,24 @@ function readProfile(platform, model) {
4732
4732
  }
4733
4733
  }
4734
4734
  function writeProfile(profile) {
4735
- const required = ["platform", "model", "connectionKey", "actionId", "idField", "pagination"];
4735
+ const required = ["platform", "model", "actionId", "idField", "pagination"];
4736
4736
  for (const field of required) {
4737
4737
  if (!profile[field]) {
4738
4738
  throw new Error(`Missing required field: ${field}`);
4739
4739
  }
4740
4740
  }
4741
+ const hasKey = !!profile.connectionKey;
4742
+ const hasRef = !!profile.connection?.platform;
4743
+ if (hasKey && hasRef) {
4744
+ throw new Error(
4745
+ "Profile has both `connectionKey` and `connection` \u2014 set exactly one. Prefer `connection: { platform, tag? }` so re-auth doesn't break the profile."
4746
+ );
4747
+ }
4748
+ if (!hasKey && !hasRef) {
4749
+ throw new Error(
4750
+ 'Missing connection: set `connection: { platform: "<name>" }` (or legacy `connectionKey: "<key>"`).'
4751
+ );
4752
+ }
4741
4753
  if (profile.resultsPath === void 0) {
4742
4754
  throw new Error('Missing required field: resultsPath (use "" or "$" for root-array responses)');
4743
4755
  }
@@ -4748,6 +4760,16 @@ function writeProfile(profile) {
4748
4760
  const filePath = profilePath(profile.platform, profile.model);
4749
4761
  fs8.writeFileSync(filePath, JSON.stringify(profile, null, 2));
4750
4762
  }
4763
+ async function resolveProfileConnectionKey(api, profile, cache2) {
4764
+ if (profile.connectionKey) return profile.connectionKey;
4765
+ if (!profile.connection?.platform) {
4766
+ throw new Error(
4767
+ `Profile ${profile.platform}/${profile.model} has no connectionKey or connection ref.`
4768
+ );
4769
+ }
4770
+ const conn = await api.resolveConnection(profile.connection, cache2);
4771
+ return conn.key;
4772
+ }
4751
4773
  function writeDraftProfile(platform, model, draft) {
4752
4774
  fs8.mkdirSync(PROFILES_DIR, { recursive: true });
4753
4775
  const filePath = profilePath(platform, model);
@@ -4773,7 +4795,9 @@ function generateTemplate(platform, model, actionId) {
4773
4795
  return {
4774
4796
  platform,
4775
4797
  model,
4776
- connectionKey: "FILL_IN",
4798
+ // Late-bound ref — survives re-auth. Use { platform, tag } when the
4799
+ // platform has multiple connections (e.g. multiple Gmail accounts).
4800
+ connection: { platform },
4777
4801
  actionId: actionId ?? "FILL_IN",
4778
4802
  resultsPath: "FILL_IN",
4779
4803
  idField: "FILL_IN",
@@ -5833,6 +5857,7 @@ async function syncModel(api, profile, options) {
5833
5857
  "--full-refresh and --since cannot be used together. --full-refresh always fetches the whole collection."
5834
5858
  );
5835
5859
  }
5860
+ const connectionKey = await resolveProfileConnectionKey(api, profile);
5836
5861
  const lock = options.dryRun ? null : acquireSyncLock(platform, model);
5837
5862
  const existingState = getModelState(platform, model);
5838
5863
  if (existingState?.status === "syncing" && !options.dryRun && !isAgentMode()) {
@@ -5960,7 +5985,7 @@ async function syncModel(api, profile, options) {
5960
5985
  const result = await api.executePassthroughRequest({
5961
5986
  platform,
5962
5987
  actionId: profile.actionId,
5963
- connectionKey: profile.connectionKey,
5988
+ connectionKey,
5964
5989
  pathVariables: profile.pathVars,
5965
5990
  queryParams: currentPageQueryParams,
5966
5991
  headers: currentPageHeaders,
@@ -6169,7 +6194,7 @@ async function syncModel(api, profile, options) {
6169
6194
  profile.enrich,
6170
6195
  model,
6171
6196
  profile.idField,
6172
- profile.connectionKey,
6197
+ connectionKey,
6173
6198
  platform,
6174
6199
  {
6175
6200
  transform: profile.transform,
@@ -6275,6 +6300,18 @@ async function testSyncProfile(api, profile) {
6275
6300
  if (limitLocation === "body") bodyParams[limitParam] = pageSize;
6276
6301
  else queryParams[limitParam] = pageSize;
6277
6302
  }
6303
+ let connectionKey;
6304
+ try {
6305
+ connectionKey = await resolveProfileConnectionKey(api, profile);
6306
+ checks.push({ name: "connection resolves", ok: true });
6307
+ } catch (err) {
6308
+ checks.push({
6309
+ name: "connection resolves",
6310
+ ok: false,
6311
+ detail: err instanceof Error ? err.message : String(err)
6312
+ });
6313
+ return report;
6314
+ }
6278
6315
  let actionDetails;
6279
6316
  try {
6280
6317
  actionDetails = await api.getActionDetails(profile.actionId);
@@ -6300,7 +6337,7 @@ async function testSyncProfile(api, profile) {
6300
6337
  const result = await api.executePassthroughRequest({
6301
6338
  platform: profile.platform,
6302
6339
  actionId: profile.actionId,
6303
- connectionKey: profile.connectionKey,
6340
+ connectionKey,
6304
6341
  pathVariables: profile.pathVars,
6305
6342
  queryParams,
6306
6343
  data: Object.keys(bodyParams).length > 0 ? bodyParams : void 0
@@ -7459,11 +7496,11 @@ async function syncInitCommand(platform, model, options) {
7459
7496
  (c) => c.platform === platform
7460
7497
  );
7461
7498
  if (platformConns.length === 1) {
7462
- template.connectionKey = platformConns[0].key;
7463
- inferred?.reasoning.push(`connectionKey: auto-resolved (only one ${platform} connection)`);
7499
+ inferred?.reasoning.push(`connection: { platform: "${platform}" } resolves to the single available connection`);
7464
7500
  } else if (platformConns.length > 1) {
7501
+ const tags = platformConns.map((c) => c.tags?.join(",") ?? "(no tag)").join("; ");
7465
7502
  inferred?.reasoning.push(
7466
- `connectionKey: ${platformConns.length} connections found \u2014 pick one from \`one list\``
7503
+ `connection: ${platformConns.length} ${platform} connections found (tags: ${tags}). Add a \`tag\` field to the connection ref to disambiguate.`
7467
7504
  );
7468
7505
  }
7469
7506
  } catch {
@@ -7547,6 +7584,11 @@ Run with --config to save:
7547
7584
  ...patch.pagination ?? {}
7548
7585
  }
7549
7586
  };
7587
+ if (patch.connection && !patch.connectionKey) {
7588
+ delete profile.connectionKey;
7589
+ } else if (patch.connectionKey && !patch.connection) {
7590
+ delete profile.connection;
7591
+ }
7550
7592
  try {
7551
7593
  writeProfile(profile);
7552
7594
  if (isAgentMode()) {
@@ -8638,12 +8680,12 @@ one --agent sync models stripe
8638
8680
  # 2. Init \u2014 one command does everything:
8639
8681
  # - resolves action ID
8640
8682
  # - infers pagination, resultsPath, idField, pathVars from knowledge
8641
- # - auto-resolves connectionKey (when only one connection exists)
8683
+ # - sets connection: { platform } so the profile survives re-auth
8642
8684
  # - auto-runs sync test if profile is complete
8643
8685
  one --agent sync init stripe balanceTransactions
8644
8686
  # Response includes _complete:true and _test results when fully resolved.
8645
- # If connectionKey wasn't auto-resolved (multiple connections), patch it:
8646
- one --agent sync init stripe balanceTransactions --config '{"connectionKey":"<from one list>"}'
8687
+ # Multi-account platforms (e.g. two Gmail connections) need a tag:
8688
+ one --agent sync init gmail gmailThreads --config '{"connection":{"platform":"gmail","tag":"work@example.com"}}'
8647
8689
 
8648
8690
  # 3. Sync
8649
8691
  one --agent sync run stripe
@@ -8654,10 +8696,29 @@ one --agent sync search "refund" --platform stripe
8654
8696
  one --agent sync sql stripe "SELECT count(*) FROM balanceTransactions"
8655
8697
  \`\`\`
8656
8698
 
8699
+ ## Connection Resolution \u2014 late-bound by default
8700
+
8701
+ Sync profiles use a late-bound connection ref instead of a hardcoded key, so re-auth (which always mints a new key) doesn't break the profile:
8702
+
8703
+ \`\`\`json
8704
+ // recommended \u2014 survives re-auth
8705
+ "connection": { "platform": "gmail" }
8706
+
8707
+ // multi-account: disambiguate with the connection's tag
8708
+ "connection": { "platform": "gmail", "tag": "work@example.com" }
8709
+
8710
+ // legacy \u2014 still works for backwards compat, but breaks on re-auth
8711
+ "connectionKey": "live::gmail::default::abc123..."
8712
+ \`\`\`
8713
+
8714
+ The resolver runs at \`sync test\` and \`sync run\` time. Resolution errors (no connection, ambiguous tag, missing tag with multiple connections) surface as the first check in the test report, before any HTTP call.
8715
+
8716
+ To migrate an existing profile: replace the \`connectionKey\` field with \`connection: { platform: "<platform>" }\`. Tags only needed when more than one connection exists for the platform.
8717
+
8657
8718
  ## Auto-Inference
8658
8719
 
8659
8720
  \`sync init\` without \`--config\` does all of this automatically:
8660
- - **connectionKey** \u2014 auto-resolved when there's exactly one connection for the platform
8721
+ - **connection** \u2014 defaults to \`{ platform: "<platform>" }\` (late-bound). When multiple connections exist, init surfaces the available tags so the agent can add one to the ref.
8661
8722
  - **Pagination** \u2014 Stripe id-pagination, Notion body-cursor, HubSpot/Google token, offset, link. Inapplicable fields stripped (no nextPath for offset, no passAs for none)
8662
8723
  - **resultsPath** \u2014 generic keys (data, results, items) + platform-specific (model name stripped of platform prefix: attioCompanies \u2192 companies). Use \`""\`, \`"$"\`, or \`"."\` for responses that return a bare array at the root (e.g. Hacker News \`/v0/topstories.json\`); primitive array elements are auto-wrapped as \`{ [idField]: value }\`.
8663
8724
  - **idField** \u2014 id, _id, uuid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/cli",
3
- "version": "1.39.1",
3
+ "version": "1.40.0",
4
4
  "description": "CLI for managing One",
5
5
  "type": "module",
6
6
  "files": [
@@ -2,7 +2,7 @@
2
2
  "description": "Attio companies — CRM company records with domains, industry, and custom attributes",
3
3
  "platform": "attio",
4
4
  "model": "attioCompanies",
5
- "connectionKey": "AUTO",
5
+ "connection": { "platform": "attio" },
6
6
  "actionId": "conn_mod_def::GJt0lFZ6kpk::attio-companies-list",
7
7
  "resultsPath": "companies",
8
8
  "idField": "id",
@@ -2,7 +2,7 @@
2
2
  "description": "Attio people — CRM contact records with emails, phone numbers, and custom attributes",
3
3
  "platform": "attio",
4
4
  "model": "attioPeople",
5
- "connectionKey": "AUTO",
5
+ "connection": { "platform": "attio" },
6
6
  "identityKey": "primary_email_address",
7
7
  "actionId": "conn_mod_def::GJt0lFZ6kpk::attio-people-list",
8
8
  "resultsPath": "people",
@@ -2,7 +2,7 @@
2
2
  "description": "Fathom meetings — recorded meetings with transcripts, summaries, action items, and attendees",
3
3
  "platform": "fathom",
4
4
  "model": "meetings",
5
- "connectionKey": "AUTO",
5
+ "connection": { "platform": "fathom" },
6
6
  "actionId": "conn_mod_def::fathom::meetings-list",
7
7
  "resultsPath": "items",
8
8
  "idField": "recording_id",
@@ -2,7 +2,7 @@
2
2
  "description": "Gmail email threads — primary inbox with full message bodies (no attachments)",
3
3
  "platform": "gmail",
4
4
  "model": "gmailThreads",
5
- "connectionKey": "AUTO",
5
+ "connection": { "platform": "gmail" },
6
6
  "actionId": "conn_mod_def::GJ3ok-Q0D40::oLWNlcx4QDORaL_18z-MsQ",
7
7
  "resultsPath": "threads",
8
8
  "idField": "id",
@@ -2,7 +2,7 @@
2
2
  "description": "Google Calendar events — meetings, appointments, and all-day events with attendees and location",
3
3
  "platform": "google-calendar",
4
4
  "model": "events",
5
- "connectionKey": "AUTO",
5
+ "connection": { "platform": "google-calendar" },
6
6
  "actionId": "conn_mod_def::GJ5x5pOh2TU::gcal-events-list",
7
7
  "resultsPath": "items",
8
8
  "idField": "id",
@@ -2,7 +2,7 @@
2
2
  "description": "Hacker News top story IDs — up to 500 item IDs at /v0/topstories.json (root-array response, primitives wrapped as { id })",
3
3
  "platform": "hacker-news",
4
4
  "model": "topStories",
5
- "connectionKey": "AUTO",
5
+ "connection": { "platform": "hacker-news" },
6
6
  "actionId": "conn_mod_def::GJ3108Dwmm4::avAMAq7HQtW6PT8JhPg5vA",
7
7
  "resultsPath": "",
8
8
  "idField": "id",
@@ -2,7 +2,7 @@
2
2
  "description": "Notion pages and databases — full workspace search with titles, properties, and metadata",
3
3
  "platform": "notion",
4
4
  "model": "search",
5
- "connectionKey": "AUTO",
5
+ "connection": { "platform": "notion" },
6
6
  "actionId": "conn_mod_def::GJ5En67fz04::-CJAS419SVWm7L2l6brp6A",
7
7
  "resultsPath": "results",
8
8
  "idField": "id",
@@ -2,7 +2,7 @@
2
2
  "description": "Stripe balance transactions — payments, refunds, payouts, and fees with amount, currency, and status",
3
3
  "platform": "stripe",
4
4
  "model": "balanceTransactions",
5
- "connectionKey": "AUTO",
5
+ "connection": { "platform": "stripe" },
6
6
  "actionId": "conn_mod_def::GGx6clhYjSQ::3kEaM3HQTA2JRfW3DzXC4g",
7
7
  "resultsPath": "data",
8
8
  "idField": "id",
@@ -2,7 +2,7 @@
2
2
  "description": "Stripe customers — customer records with email, name, payment methods, and subscription status",
3
3
  "platform": "stripe",
4
4
  "model": "customers",
5
- "connectionKey": "AUTO",
5
+ "connection": { "platform": "stripe" },
6
6
  "actionId": "conn_mod_def::GGx6clhYjSQ::customers-list",
7
7
  "resultsPath": "data",
8
8
  "idField": "id",
@@ -175,6 +175,8 @@ one sync schedule add stripe --every 1h
175
175
 
176
176
  **Sync rejects custom actions** — profiles must use passthrough. `sync init` only surfaces passthrough models; `sync run` aborts if the list or enrich action is tagged `custom`. If no passthrough exists, compose a flow instead.
177
177
 
178
+ **Connections are late-bound** — profiles use `"connection": { "platform": "<name>" }`, not literal `connectionKey` strings. The key is resolved at sync time, so `one add <platform>` (re-auth) doesn't break the profile. For multi-account platforms, add `"tag": "<connection-tag>"` to disambiguate. Don't hardcode connection keys in profiles.
179
+
178
180
  **Advanced features** (enrich, transform, exclude, identityKey, hooks, --full-refresh, --where-sql delete, cursor resume): run `one guide sync` for the full reference.
179
181
 
180
182
  ## Beyond Single Actions