@withone/cli 1.39.1 → 1.41.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;
@@ -775,7 +811,29 @@ async function executeActionStep(step, context, api, permissions, allowedActionI
775
811
  const action = step.action;
776
812
  const platform = resolveValue(action.platform, context);
777
813
  const actionId = resolveValue(action.actionId, context);
778
- const connectionKey = resolveValue(action.connectionKey, context);
814
+ const hasKey = action.connectionKey !== void 0 && action.connectionKey !== null && action.connectionKey !== "";
815
+ const hasRef = !!action.connection?.platform;
816
+ if (hasKey && hasRef) {
817
+ throw new Error(
818
+ `Action step "${step.id}" has both "connectionKey" and "connection" \u2014 set exactly one. Prefer "connection: { platform, tag? }" so re-auth doesn't break the flow.`
819
+ );
820
+ }
821
+ if (!hasKey && !hasRef) {
822
+ throw new Error(
823
+ `Action step "${step.id}" must set "connection: { platform: <name> }" (or legacy "connectionKey: <key>").`
824
+ );
825
+ }
826
+ let connectionKey;
827
+ if (hasKey) {
828
+ connectionKey = resolveValue(action.connectionKey, context);
829
+ } else {
830
+ const ref = resolveValue(action.connection, context);
831
+ if (!context._connections) {
832
+ context._connections = await api.listConnections();
833
+ }
834
+ const conn = await api.resolveConnection(ref, context._connections);
835
+ connectionKey = conn.key;
836
+ }
779
837
  const data = action.data ? resolveValue(action.data, context) : void 0;
780
838
  const pathVars = action.pathVars ? resolveValue(action.pathVars, context) : void 0;
781
839
  const queryParams = action.queryParams ? resolveValue(action.queryParams, context) : void 0;
@@ -1079,7 +1137,7 @@ async function executeSubflowStep(step, context, api, permissions, allowedAction
1079
1137
  if (flowStack.includes(resolvedKey)) {
1080
1138
  throw new Error(`Circular flow detected: ${[...flowStack, resolvedKey].join(" \u2192 ")}`);
1081
1139
  }
1082
- const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-OVOGDXNR.js");
1140
+ const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-26LNPU4F.js");
1083
1141
  const { flow: subFlow, rootDir: subRootDir } = loadFlowWithMeta2(resolvedKey);
1084
1142
  const subContext = await executeFlow(
1085
1143
  subFlow,
@@ -1625,7 +1683,8 @@ var FLOW_SCHEMA = {
1625
1683
  fields: {
1626
1684
  platform: { type: "string", required: true, description: "Platform name (kebab-case)" },
1627
1685
  actionId: { type: "string", required: true, description: "Action ID from `actions search`" },
1628
- connectionKey: { type: "string", required: true, description: "Connection key (use $.input selector)" },
1686
+ connection: { type: "object", required: false, description: "Late-bound connection ref { platform, tag? } \u2014 survives re-auth. Exactly one of `connection` or `connectionKey` must be set." },
1687
+ connectionKey: { type: "string", required: false, description: "Literal connection key (or $.input selector). Legacy form \u2014 prefer `connection: { platform, tag? }`. Exactly one of `connection` or `connectionKey` must be set." },
1629
1688
  data: { type: "object", required: false, description: "Request body (POST/PUT/PATCH)" },
1630
1689
  pathVars: { type: "object", required: false, description: "URL path variables" },
1631
1690
  queryParams: { type: "object", required: false, description: "Query parameters" },
@@ -1638,7 +1697,7 @@ var FLOW_SCHEMA = {
1638
1697
  action: {
1639
1698
  platform: "stripe",
1640
1699
  actionId: "conn_mod_def::xxx::yyy",
1641
- connectionKey: "$.input.stripeConnectionKey",
1700
+ connection: { platform: "stripe" },
1642
1701
  data: { query: "email:'{{$.input.customerEmail}}'" }
1643
1702
  }
1644
1703
  }
@@ -1687,7 +1746,7 @@ var FLOW_SCHEMA = {
1687
1746
  type: "condition",
1688
1747
  condition: {
1689
1748
  expression: "$.steps.search.response.data.length > 0",
1690
- then: [{ id: "notify", name: "Send notification", type: "action", action: { platform: "slack", actionId: "...", connectionKey: "$.input.slackKey", data: { text: "Found!" } } }],
1749
+ then: [{ id: "notify", name: "Send notification", type: "action", action: { platform: "slack", actionId: "...", connection: { platform: "slack" }, data: { text: "Found!" } } }],
1691
1750
  else: [{ id: "logMiss", name: "Log not found", type: "transform", transform: { expression: "'Not found'" } }]
1692
1751
  }
1693
1752
  }
@@ -1711,7 +1770,7 @@ var FLOW_SCHEMA = {
1711
1770
  loop: {
1712
1771
  over: "$.steps.listOrders.response.data",
1713
1772
  as: "order",
1714
- steps: [{ id: "createInvoice", name: "Create invoice", type: "action", action: { platform: "stripe", actionId: "...", connectionKey: "$.input.stripeKey", data: { amount: "$.loop.order.total" } } }]
1773
+ steps: [{ id: "createInvoice", name: "Create invoice", type: "action", action: { platform: "stripe", actionId: "...", connection: { platform: "stripe" }, data: { amount: "$.loop.order.total" } } }]
1715
1774
  }
1716
1775
  }
1717
1776
  },
@@ -1729,8 +1788,8 @@ var FLOW_SCHEMA = {
1729
1788
  type: "parallel",
1730
1789
  parallel: {
1731
1790
  steps: [
1732
- { id: "getStripe", name: "Get Stripe data", type: "action", action: { platform: "stripe", actionId: "...", connectionKey: "$.input.stripeKey" } },
1733
- { id: "getSlack", name: "Get Slack data", type: "action", action: { platform: "slack", actionId: "...", connectionKey: "$.input.slackKey" } }
1791
+ { id: "getStripe", name: "Get Stripe data", type: "action", action: { platform: "stripe", actionId: "...", connection: { platform: "stripe" } } },
1792
+ { id: "getSlack", name: "Get Slack data", type: "action", action: { platform: "slack", actionId: "...", connection: { platform: "slack" } } }
1734
1793
  ]
1735
1794
  }
1736
1795
  }
@@ -1782,7 +1841,7 @@ var FLOW_SCHEMA = {
1782
1841
  while: {
1783
1842
  condition: "$.steps.paginate.output.lastResult.nextPageToken != null",
1784
1843
  maxIterations: 50,
1785
- steps: [{ id: "fetchPage", name: "Fetch next page", type: "action", action: { platform: "gmail", actionId: "...", connectionKey: "$.input.gmailKey" } }]
1844
+ steps: [{ id: "fetchPage", name: "Fetch next page", type: "action", action: { platform: "gmail", actionId: "...", connection: { platform: "gmail" } } }]
1786
1845
  }
1787
1846
  }
1788
1847
  },
@@ -1806,7 +1865,7 @@ var FLOW_SCHEMA = {
1806
1865
  configKey: "paginate",
1807
1866
  description: "Auto-paginate API results into a single array",
1808
1867
  fields: {
1809
- action: { type: "object", required: true, description: "Action config (same shape as action step: platform, actionId, connectionKey)" },
1868
+ action: { type: "object", required: true, description: "Action config (same shape as action step: platform, actionId, plus exactly one of `connection` or `connectionKey`)" },
1810
1869
  pageTokenField: { type: "string", required: true, description: "Dot-path in response to next page token" },
1811
1870
  resultsField: { type: "string", required: true, description: "Dot-path in response to results array" },
1812
1871
  inputTokenParam: { type: "string", required: true, description: "Dot-path in action config where page token is injected" },
@@ -1817,7 +1876,7 @@ var FLOW_SCHEMA = {
1817
1876
  name: "Fetch all Gmail messages",
1818
1877
  type: "paginate",
1819
1878
  paginate: {
1820
- action: { platform: "gmail", actionId: "...", connectionKey: "$.input.gmailKey", queryParams: { maxResults: 100 } },
1879
+ action: { platform: "gmail", actionId: "...", connection: { platform: "gmail" }, queryParams: { maxResults: 100 } },
1821
1880
  pageTokenField: "nextPageToken",
1822
1881
  resultsField: "messages",
1823
1882
  inputTokenParam: "queryParams.pageToken",
@@ -2015,12 +2074,6 @@ The only differences: (1) prepend the stdin-read line, (2) replace \`return X\`
2015
2074
  "description": "What this flow does",
2016
2075
  "version": "1",
2017
2076
  "inputs": {
2018
- "connectionKey": {
2019
- "type": "string",
2020
- "required": true,
2021
- "description": "Platform connection key",
2022
- "connection": { "platform": "stripe" }
2023
- },
2024
2077
  "param": {
2025
2078
  "type": "string",
2026
2079
  "required": true,
@@ -2035,7 +2088,7 @@ The only differences: (1) prepend the stdin-read line, (2) replace \`return X\`
2035
2088
  "action": {
2036
2089
  "platform": "stripe",
2037
2090
  "actionId": "conn_mod_def::xxx::yyy",
2038
- "connectionKey": "$.input.connectionKey",
2091
+ "connection": { "platform": "stripe" },
2039
2092
  "data": { "query": "{{$.input.param}}" }
2040
2093
  }
2041
2094
  }
@@ -2129,13 +2182,13 @@ Pipes can be applied to any value (objects/arrays are JSON-stringified first for
2129
2182
 
2130
2183
  ### When to use bare selectors vs \`{{...}}\` interpolation
2131
2184
 
2132
- - **Bare selectors** (\`$.input.x\`): Use for fields the engine resolves directly \u2014 \`connectionKey\`, \`over\`, \`path\`, \`expression\`, \`condition\`, and any field where the entire value is a single selector. The resolved value keeps its original type (object, array, number).
2185
+ - **Bare selectors** (\`$.input.x\`): Use for fields the engine resolves directly \u2014 \`connectionKey\`, the \`tag\` (or \`platform\`) inside \`connection\`, \`over\`, \`path\`, \`expression\`, \`condition\`, and any field where the entire value is a single selector. The resolved value keeps its original type (object, array, number).
2133
2186
  - **Interpolation** (\`{{$.input.x}}\`): Use inside string values where the selector is embedded in text \u2014 e.g., \`"Hello {{$.steps.getUser.response.name}}"\`. The resolved value is always stringified. Use this in \`data\`, \`pathVars\`, and \`queryParams\` when mixing selectors with literal text.
2134
2187
  - **Rule of thumb**: If the value is purely a selector, use bare. If it's a string containing a selector, use \`{{...}}\`.
2135
2188
 
2136
2189
  ### Selectors vs expressions
2137
2190
 
2138
- Selectors in data fields (\`data\`, \`queryParams\`, \`pathVars\`, \`connectionKey\`) are **dot-path lookups only** \u2014 they do not support JavaScript operators like \`||\` or \`&&\`. For default values, use the \`default\` field on the input definition:
2191
+ Selectors in data fields (\`data\`, \`queryParams\`, \`pathVars\`, \`connectionKey\`, \`connection.tag\`) are **dot-path lookups only** \u2014 they do not support JavaScript operators like \`||\` or \`&&\`. For default values, use the \`default\` field on the input definition:
2139
2192
 
2140
2193
  \`\`\`json
2141
2194
  { "inputs": { "maxResults": { "type": "number", "default": 10 } } }
@@ -2255,9 +2308,30 @@ A \`flow\` step's \`flow.key\` accepts selectors and Handlebars interpolations,
2255
2308
 
2256
2309
  Conditional execution: \`"if": "$.steps.prev.response.data.length > 0"\`
2257
2310
 
2258
- ## Input Connection Auto-Resolution
2311
+ ## Connection Resolution \u2014 late-bound by default
2312
+
2313
+ Action steps reference a platform connection in one of two forms:
2314
+
2315
+ \`\`\`json
2316
+ // preferred \u2014 late-bound, survives re-auth
2317
+ "connection": { "platform": "gmail" }
2318
+
2319
+ // multi-account: disambiguate with the connection's tag
2320
+ "connection": { "platform": "gmail", "tag": "work@example.com" }
2321
+
2322
+ // legacy \u2014 works for backwards compat, breaks on re-auth
2323
+ "connectionKey": "live::gmail::default::abc123..."
2324
+ \`\`\`
2325
+
2326
+ The engine resolves the \`connection\` ref once per flow run (cached for the run's lifetime) by calling \`listConnections\` and matching on platform + optional tag. Resolution errors fail the step with a clear message \u2014 \`No connection found for platform "X"\`, \`Multiple "X" connections found (tags: ...). Add a "tag" field\`, or \`No "X" connection has tag "Y"\`.
2259
2327
 
2260
- When an input has \`"connection": { "platform": "stripe" }\`, the flow engine can automatically resolve the connection key at execution time. If the user has exactly one connection for that platform, the engine fills in the key without requiring \`-i connectionKey=...\`. If multiple connections exist, the user must specify which one. This is metadata for tooling \u2014 it does not affect the flow JSON structure, but it makes execution more convenient.
2328
+ Both \`platform\` and \`tag\` accept \`$.input.x\` selectors so a flow can be parameterised per-execution (e.g. multi-tenant orchestrators that pass the user's email as the tag).
2329
+
2330
+ The validator rejects any action that sets both forms or neither, at \`flow validate\` and \`flow execute\` time.
2331
+
2332
+ ### Optional input metadata: connection-key auto-resolve (legacy)
2333
+
2334
+ For flows that still use literal \`connectionKey\` strings via inputs, an input declaration can carry a \`"connection": { "platform": "..." }\` hint so \`flow execute\` auto-fills a single matching connection's key. New flows don't need this \u2014 switch the action's connection form to \`{ platform, tag? }\` and skip the input entirely.
2261
2335
 
2262
2336
  ## Complete Example: Fetch Data, Transform, Notify
2263
2337
 
@@ -2268,18 +2342,6 @@ When an input has \`"connection": { "platform": "stripe" }\`, the flow engine ca
2268
2342
  "description": "Fetch recent contacts from CRM, build a summary, post to Slack",
2269
2343
  "version": "1",
2270
2344
  "inputs": {
2271
- "crmConnectionKey": {
2272
- "type": "string",
2273
- "required": true,
2274
- "description": "CRM platform connection key",
2275
- "connection": { "platform": "attio" }
2276
- },
2277
- "slackConnectionKey": {
2278
- "type": "string",
2279
- "required": true,
2280
- "description": "Slack connection key",
2281
- "connection": { "platform": "slack" }
2282
- },
2283
2345
  "slackChannel": {
2284
2346
  "type": "string",
2285
2347
  "required": true,
@@ -2294,7 +2356,7 @@ When an input has \`"connection": { "platform": "stripe" }\`, the flow engine ca
2294
2356
  "action": {
2295
2357
  "platform": "attio",
2296
2358
  "actionId": "ATTIO_LIST_PEOPLE_ACTION_ID",
2297
- "connectionKey": "$.input.crmConnectionKey",
2359
+ "connection": { "platform": "attio" },
2298
2360
  "queryParams": { "limit": "10" }
2299
2361
  }
2300
2362
  },
@@ -2313,7 +2375,7 @@ When an input has \`"connection": { "platform": "stripe" }\`, the flow engine ca
2313
2375
  "action": {
2314
2376
  "platform": "slack",
2315
2377
  "actionId": "SLACK_SEND_MESSAGE_ACTION_ID",
2316
- "connectionKey": "$.input.slackConnectionKey",
2378
+ "connection": { "platform": "slack" },
2317
2379
  "data": {
2318
2380
  "channel": "$.input.slackChannel",
2319
2381
  "text": "{{$.steps.buildSummary.output.summary}}"
@@ -11,7 +11,7 @@ import {
11
11
  saveFlow,
12
12
  summarizeFlowInputs,
13
13
  walkSteps
14
- } from "./chunk-MNJJE4PJ.js";
14
+ } from "./chunk-A5AFLKCQ.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-A5AFLKCQ.js";
23
23
 
24
24
  // src/index.ts
25
25
  import { createRequire as createRequire2 } from "module";
@@ -3269,10 +3269,13 @@ function validateStepsArray(steps, pathPrefix, errors) {
3269
3269
  const a = value;
3270
3270
  if (!a.platform) errors.push({ path: `${fieldPath}.platform`, message: 'Action must have "platform"' });
3271
3271
  if (!a.actionId) errors.push({ path: `${fieldPath}.actionId`, message: 'Action must have "actionId"' });
3272
- if (!a.connectionKey) errors.push({ path: `${fieldPath}.connectionKey`, message: 'Action must have "connectionKey"' });
3272
+ validateConnectionForm(a, fieldPath, errors);
3273
3273
  }
3274
3274
  }
3275
3275
  }
3276
+ if (descriptor.type === "action") {
3277
+ validateConnectionForm(config2, `${path17}.${configKey}`, errors);
3278
+ }
3276
3279
  if (descriptor.type === "code") {
3277
3280
  const hasSource = typeof config2.source === "string" && config2.source.length > 0;
3278
3281
  const hasModule = typeof config2.module === "string" && config2.module.length > 0;
@@ -3300,6 +3303,43 @@ function validateStepsArray(steps, pathPrefix, errors) {
3300
3303
  }
3301
3304
  }
3302
3305
  }
3306
+ function validateConnectionForm(config2, pathPrefix, errors) {
3307
+ const hasKey = config2.connectionKey !== void 0 && config2.connectionKey !== null && config2.connectionKey !== "";
3308
+ const conn = config2.connection;
3309
+ const hasRef = !!conn && typeof conn === "object" && !Array.isArray(conn) && typeof conn.platform === "string" && conn.platform.length > 0;
3310
+ if (hasKey && hasRef) {
3311
+ errors.push({
3312
+ path: pathPrefix,
3313
+ message: `Action has both "connectionKey" and "connection" \u2014 set exactly one. Prefer "connection: { platform, tag? }" so re-auth doesn't break the flow.`
3314
+ });
3315
+ return;
3316
+ }
3317
+ if (!hasKey && !hasRef) {
3318
+ errors.push({
3319
+ path: pathPrefix,
3320
+ message: 'Action must set "connection: { platform: <name> }" (or legacy "connectionKey: <key>").'
3321
+ });
3322
+ return;
3323
+ }
3324
+ if (hasRef) {
3325
+ const c = conn;
3326
+ if (c.tag !== void 0 && typeof c.tag !== "string") {
3327
+ errors.push({
3328
+ path: `${pathPrefix}.connection.tag`,
3329
+ message: '"connection.tag" must be a string when set'
3330
+ });
3331
+ }
3332
+ const allowed = /* @__PURE__ */ new Set(["platform", "tag"]);
3333
+ for (const k of Object.keys(c)) {
3334
+ if (!allowed.has(k)) {
3335
+ errors.push({
3336
+ path: `${pathPrefix}.connection.${k}`,
3337
+ message: `Unknown field "${k}" in connection ref. Allowed: platform, tag.`
3338
+ });
3339
+ }
3340
+ }
3341
+ }
3342
+ }
3303
3343
  function detectFlatConfigHint(step, descriptor) {
3304
3344
  const requiredFields = Object.entries(descriptor.fields).filter(([, fd]) => fd.required).map(([name]) => name);
3305
3345
  const flatFields = requiredFields.filter((f) => f in step);
@@ -4107,14 +4147,7 @@ var SCAFFOLD_TEMPLATES = {
4107
4147
  name: "My Workflow",
4108
4148
  description: "A basic workflow with a single action step",
4109
4149
  version: "1",
4110
- inputs: {
4111
- connectionKey: {
4112
- type: "string",
4113
- required: true,
4114
- description: "Connection key for the platform",
4115
- connection: { platform: "PLATFORM_NAME" }
4116
- }
4117
- },
4150
+ inputs: {},
4118
4151
  steps: [
4119
4152
  {
4120
4153
  id: "step1",
@@ -4123,7 +4156,7 @@ var SCAFFOLD_TEMPLATES = {
4123
4156
  action: {
4124
4157
  platform: "PLATFORM_NAME",
4125
4158
  actionId: "ACTION_ID_FROM_SEARCH",
4126
- connectionKey: "$.input.connectionKey",
4159
+ connection: { platform: "PLATFORM_NAME" },
4127
4160
  data: {}
4128
4161
  }
4129
4162
  }
@@ -4134,14 +4167,7 @@ var SCAFFOLD_TEMPLATES = {
4134
4167
  name: "Conditional Workflow",
4135
4168
  description: "Fetch data, then branch based on results",
4136
4169
  version: "1",
4137
- inputs: {
4138
- connectionKey: {
4139
- type: "string",
4140
- required: true,
4141
- description: "Connection key",
4142
- connection: { platform: "PLATFORM_NAME" }
4143
- }
4144
- },
4170
+ inputs: {},
4145
4171
  steps: [
4146
4172
  {
4147
4173
  id: "fetch",
@@ -4150,7 +4176,7 @@ var SCAFFOLD_TEMPLATES = {
4150
4176
  action: {
4151
4177
  platform: "PLATFORM_NAME",
4152
4178
  actionId: "ACTION_ID_FROM_SEARCH",
4153
- connectionKey: "$.input.connectionKey"
4179
+ connection: { platform: "PLATFORM_NAME" }
4154
4180
  }
4155
4181
  },
4156
4182
  {
@@ -4184,14 +4210,7 @@ var SCAFFOLD_TEMPLATES = {
4184
4210
  name: "Loop Workflow",
4185
4211
  description: "Fetch a list, then process each item",
4186
4212
  version: "1",
4187
- inputs: {
4188
- connectionKey: {
4189
- type: "string",
4190
- required: true,
4191
- description: "Connection key",
4192
- connection: { platform: "PLATFORM_NAME" }
4193
- }
4194
- },
4213
+ inputs: {},
4195
4214
  steps: [
4196
4215
  {
4197
4216
  id: "fetchList",
@@ -4200,7 +4219,7 @@ var SCAFFOLD_TEMPLATES = {
4200
4219
  action: {
4201
4220
  platform: "PLATFORM_NAME",
4202
4221
  actionId: "ACTION_ID_FROM_SEARCH",
4203
- connectionKey: "$.input.connectionKey"
4222
+ connection: { platform: "PLATFORM_NAME" }
4204
4223
  }
4205
4224
  },
4206
4225
  {
@@ -4233,14 +4252,7 @@ var SCAFFOLD_TEMPLATES = {
4233
4252
  name: "AI Analysis Workflow",
4234
4253
  description: "Fetch data, analyze with Claude, and send results",
4235
4254
  version: "1",
4236
- inputs: {
4237
- connectionKey: {
4238
- type: "string",
4239
- required: true,
4240
- description: "Connection key for data source",
4241
- connection: { platform: "PLATFORM_NAME" }
4242
- }
4243
- },
4255
+ inputs: {},
4244
4256
  steps: [
4245
4257
  {
4246
4258
  id: "fetchData",
@@ -4249,7 +4261,7 @@ var SCAFFOLD_TEMPLATES = {
4249
4261
  action: {
4250
4262
  platform: "PLATFORM_NAME",
4251
4263
  actionId: "ACTION_ID_FROM_SEARCH",
4252
- connectionKey: "$.input.connectionKey"
4264
+ connection: { platform: "PLATFORM_NAME" }
4253
4265
  }
4254
4266
  },
4255
4267
  {
@@ -4732,12 +4744,24 @@ function readProfile(platform, model) {
4732
4744
  }
4733
4745
  }
4734
4746
  function writeProfile(profile) {
4735
- const required = ["platform", "model", "connectionKey", "actionId", "idField", "pagination"];
4747
+ const required = ["platform", "model", "actionId", "idField", "pagination"];
4736
4748
  for (const field of required) {
4737
4749
  if (!profile[field]) {
4738
4750
  throw new Error(`Missing required field: ${field}`);
4739
4751
  }
4740
4752
  }
4753
+ const hasKey = !!profile.connectionKey;
4754
+ const hasRef = !!profile.connection?.platform;
4755
+ if (hasKey && hasRef) {
4756
+ throw new Error(
4757
+ "Profile has both `connectionKey` and `connection` \u2014 set exactly one. Prefer `connection: { platform, tag? }` so re-auth doesn't break the profile."
4758
+ );
4759
+ }
4760
+ if (!hasKey && !hasRef) {
4761
+ throw new Error(
4762
+ 'Missing connection: set `connection: { platform: "<name>" }` (or legacy `connectionKey: "<key>"`).'
4763
+ );
4764
+ }
4741
4765
  if (profile.resultsPath === void 0) {
4742
4766
  throw new Error('Missing required field: resultsPath (use "" or "$" for root-array responses)');
4743
4767
  }
@@ -4748,6 +4772,16 @@ function writeProfile(profile) {
4748
4772
  const filePath = profilePath(profile.platform, profile.model);
4749
4773
  fs8.writeFileSync(filePath, JSON.stringify(profile, null, 2));
4750
4774
  }
4775
+ async function resolveProfileConnectionKey(api, profile, cache2) {
4776
+ if (profile.connectionKey) return profile.connectionKey;
4777
+ if (!profile.connection?.platform) {
4778
+ throw new Error(
4779
+ `Profile ${profile.platform}/${profile.model} has no connectionKey or connection ref.`
4780
+ );
4781
+ }
4782
+ const conn = await api.resolveConnection(profile.connection, cache2);
4783
+ return conn.key;
4784
+ }
4751
4785
  function writeDraftProfile(platform, model, draft) {
4752
4786
  fs8.mkdirSync(PROFILES_DIR, { recursive: true });
4753
4787
  const filePath = profilePath(platform, model);
@@ -4773,7 +4807,9 @@ function generateTemplate(platform, model, actionId) {
4773
4807
  return {
4774
4808
  platform,
4775
4809
  model,
4776
- connectionKey: "FILL_IN",
4810
+ // Late-bound ref — survives re-auth. Use { platform, tag } when the
4811
+ // platform has multiple connections (e.g. multiple Gmail accounts).
4812
+ connection: { platform },
4777
4813
  actionId: actionId ?? "FILL_IN",
4778
4814
  resultsPath: "FILL_IN",
4779
4815
  idField: "FILL_IN",
@@ -5833,6 +5869,7 @@ async function syncModel(api, profile, options) {
5833
5869
  "--full-refresh and --since cannot be used together. --full-refresh always fetches the whole collection."
5834
5870
  );
5835
5871
  }
5872
+ const connectionKey = await resolveProfileConnectionKey(api, profile);
5836
5873
  const lock = options.dryRun ? null : acquireSyncLock(platform, model);
5837
5874
  const existingState = getModelState(platform, model);
5838
5875
  if (existingState?.status === "syncing" && !options.dryRun && !isAgentMode()) {
@@ -5960,7 +5997,7 @@ async function syncModel(api, profile, options) {
5960
5997
  const result = await api.executePassthroughRequest({
5961
5998
  platform,
5962
5999
  actionId: profile.actionId,
5963
- connectionKey: profile.connectionKey,
6000
+ connectionKey,
5964
6001
  pathVariables: profile.pathVars,
5965
6002
  queryParams: currentPageQueryParams,
5966
6003
  headers: currentPageHeaders,
@@ -6169,7 +6206,7 @@ async function syncModel(api, profile, options) {
6169
6206
  profile.enrich,
6170
6207
  model,
6171
6208
  profile.idField,
6172
- profile.connectionKey,
6209
+ connectionKey,
6173
6210
  platform,
6174
6211
  {
6175
6212
  transform: profile.transform,
@@ -6275,6 +6312,18 @@ async function testSyncProfile(api, profile) {
6275
6312
  if (limitLocation === "body") bodyParams[limitParam] = pageSize;
6276
6313
  else queryParams[limitParam] = pageSize;
6277
6314
  }
6315
+ let connectionKey;
6316
+ try {
6317
+ connectionKey = await resolveProfileConnectionKey(api, profile);
6318
+ checks.push({ name: "connection resolves", ok: true });
6319
+ } catch (err) {
6320
+ checks.push({
6321
+ name: "connection resolves",
6322
+ ok: false,
6323
+ detail: err instanceof Error ? err.message : String(err)
6324
+ });
6325
+ return report;
6326
+ }
6278
6327
  let actionDetails;
6279
6328
  try {
6280
6329
  actionDetails = await api.getActionDetails(profile.actionId);
@@ -6300,7 +6349,7 @@ async function testSyncProfile(api, profile) {
6300
6349
  const result = await api.executePassthroughRequest({
6301
6350
  platform: profile.platform,
6302
6351
  actionId: profile.actionId,
6303
- connectionKey: profile.connectionKey,
6352
+ connectionKey,
6304
6353
  pathVariables: profile.pathVars,
6305
6354
  queryParams,
6306
6355
  data: Object.keys(bodyParams).length > 0 ? bodyParams : void 0
@@ -7459,11 +7508,11 @@ async function syncInitCommand(platform, model, options) {
7459
7508
  (c) => c.platform === platform
7460
7509
  );
7461
7510
  if (platformConns.length === 1) {
7462
- template.connectionKey = platformConns[0].key;
7463
- inferred?.reasoning.push(`connectionKey: auto-resolved (only one ${platform} connection)`);
7511
+ inferred?.reasoning.push(`connection: { platform: "${platform}" } resolves to the single available connection`);
7464
7512
  } else if (platformConns.length > 1) {
7513
+ const tags = platformConns.map((c) => c.tags?.join(",") ?? "(no tag)").join("; ");
7465
7514
  inferred?.reasoning.push(
7466
- `connectionKey: ${platformConns.length} connections found \u2014 pick one from \`one list\``
7515
+ `connection: ${platformConns.length} ${platform} connections found (tags: ${tags}). Add a \`tag\` field to the connection ref to disambiguate.`
7467
7516
  );
7468
7517
  }
7469
7518
  } catch {
@@ -7547,6 +7596,11 @@ Run with --config to save:
7547
7596
  ...patch.pagination ?? {}
7548
7597
  }
7549
7598
  };
7599
+ if (patch.connection && !patch.connectionKey) {
7600
+ delete profile.connectionKey;
7601
+ } else if (patch.connectionKey && !patch.connection) {
7602
+ delete profile.connection;
7603
+ }
7550
7604
  try {
7551
7605
  writeProfile(profile);
7552
7606
  if (isAgentMode()) {
@@ -8638,12 +8692,12 @@ one --agent sync models stripe
8638
8692
  # 2. Init \u2014 one command does everything:
8639
8693
  # - resolves action ID
8640
8694
  # - infers pagination, resultsPath, idField, pathVars from knowledge
8641
- # - auto-resolves connectionKey (when only one connection exists)
8695
+ # - sets connection: { platform } so the profile survives re-auth
8642
8696
  # - auto-runs sync test if profile is complete
8643
8697
  one --agent sync init stripe balanceTransactions
8644
8698
  # 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>"}'
8699
+ # Multi-account platforms (e.g. two Gmail connections) need a tag:
8700
+ one --agent sync init gmail gmailThreads --config '{"connection":{"platform":"gmail","tag":"work@example.com"}}'
8647
8701
 
8648
8702
  # 3. Sync
8649
8703
  one --agent sync run stripe
@@ -8654,10 +8708,29 @@ one --agent sync search "refund" --platform stripe
8654
8708
  one --agent sync sql stripe "SELECT count(*) FROM balanceTransactions"
8655
8709
  \`\`\`
8656
8710
 
8711
+ ## Connection Resolution \u2014 late-bound by default
8712
+
8713
+ 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:
8714
+
8715
+ \`\`\`json
8716
+ // recommended \u2014 survives re-auth
8717
+ "connection": { "platform": "gmail" }
8718
+
8719
+ // multi-account: disambiguate with the connection's tag
8720
+ "connection": { "platform": "gmail", "tag": "work@example.com" }
8721
+
8722
+ // legacy \u2014 still works for backwards compat, but breaks on re-auth
8723
+ "connectionKey": "live::gmail::default::abc123..."
8724
+ \`\`\`
8725
+
8726
+ 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.
8727
+
8728
+ 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.
8729
+
8657
8730
  ## Auto-Inference
8658
8731
 
8659
8732
  \`sync init\` without \`--config\` does all of this automatically:
8660
- - **connectionKey** \u2014 auto-resolved when there's exactly one connection for the platform
8733
+ - **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
8734
  - **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
8735
  - **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
8736
  - **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.41.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
@@ -282,12 +282,19 @@ The `if`, `unless`, `condition.expression`, `while.condition`, `transform.expres
282
282
  "action": {
283
283
  "platform": "stripe",
284
284
  "actionId": "conn_mod_def::xxx::yyy",
285
- "connectionKey": "$.input.stripeConnectionKey",
285
+ "connection": { "platform": "stripe" },
286
286
  "data": { "query": "email:'{{$.input.customerEmail}}'" }
287
287
  }
288
288
  }
289
289
  ```
290
290
 
291
+ **Connection forms.** Each action step sets exactly one of:
292
+
293
+ - **`connection: { platform: "<name>", "tag"?: "<tag>" }`** (preferred) — late-bound, resolved at flow-execute time. Survives re-auth (which always mints a new key). Use `tag` to disambiguate when a platform has multiple connections (e.g. multi-account Gmail). Both `platform` and `tag` accept `$.input.x` selectors so flows can be parameterised per-execution.
294
+ - **`connectionKey: "<literal-or-selector>"`** (legacy) — passes the key string straight through. Still supported for backwards compat, but breaks on re-auth and forces manual edits across every flow that references the stale key. Migrate to `connection` when convenient.
295
+
296
+ The validator rejects an action that sets both forms (or neither) at `flow validate` and `flow execute` time.
297
+
291
298
  ### `transform` — JS expression (implicit return)
292
299
 
293
300
  ```json