@withone/cli 1.37.3 → 1.39.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
@@ -330,6 +330,8 @@ one sync init stripe balanceTransactions --config '{"onInsert":"one flow execute
330
330
  one sync run stripe --full-refresh
331
331
  ```
332
332
 
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
+
333
335
  | Subcommand | What it does |
334
336
  |------------|-------------|
335
337
  | `install` / `doctor` | Install + verify the SQLite engine |
@@ -343,7 +345,7 @@ one sync run stripe --full-refresh
343
345
  | `schedule add/list/status/remove/repair` | Cron-backed scheduled syncs with drift detection |
344
346
  | `remove <platform>` | Delete local data (`--dry-run` to preview) |
345
347
 
346
- Change hooks (`onInsert`, `onUpdate`, `onChange`) fire per-page during sync — pipe to a shell command, a flow, or an event log. Run `one guide sync` for the full reference.
348
+ Change hooks (`onInsert`, `onUpdate`, `onChange`) fire per-page during sync — pipe to a shell command, a flow, or an event log. Root-array responses (e.g. Hacker News `/v0/topstories.json` → `[9129911, 9129199, ...]`) are supported by setting `resultsPath` to `""`, `"$"`, or `"."`; primitive elements are auto-wrapped as `{ [idField]: value }`. Run `one guide sync` for the full reference.
347
349
 
348
350
  ### `one guide [topic]`
349
351
 
package/dist/index.js CHANGED
@@ -4666,6 +4666,9 @@ function parseActionType(actionKey) {
4666
4666
  if (parts.length < 5) return null;
4667
4667
  return parts[4];
4668
4668
  }
4669
+ function isCustomAction(tags) {
4670
+ return !!tags && tags.includes("custom");
4671
+ }
4669
4672
  async function discoverModels(api, platform) {
4670
4673
  const actions2 = await api.listAvailableActions(platform);
4671
4674
  const listActionTypes = /* @__PURE__ */ new Set(["get_many", "list", "get_all"]);
@@ -4674,6 +4677,7 @@ async function discoverModels(api, platform) {
4674
4677
  const actionType = parseActionType(action.key);
4675
4678
  if (!actionType) continue;
4676
4679
  if (!listActionTypes.has(actionType)) continue;
4680
+ if (isCustomAction(action.tags)) continue;
4677
4681
  const modelName = action.modelName;
4678
4682
  if (!modelName) continue;
4679
4683
  if (modelMap.has(modelName)) {
@@ -4697,7 +4701,7 @@ async function discoverModels(api, platform) {
4697
4701
  try {
4698
4702
  const searchResults = await api.searchActions(platform, model.displayName, "execute");
4699
4703
  const resolved = searchResults.find(
4700
- (a) => a.path === model.listAction.path && a.method === model.listAction.method
4704
+ (a) => a.path === model.listAction.path && a.method === model.listAction.method && !isCustomAction(a.tags)
4701
4705
  );
4702
4706
  if (resolved?.systemId) {
4703
4707
  model.listAction.actionId = resolved.systemId;
@@ -4706,7 +4710,8 @@ async function discoverModels(api, platform) {
4706
4710
  }
4707
4711
  })
4708
4712
  );
4709
- return models.sort((a, b) => a.name.localeCompare(b.name));
4713
+ const syncable = models.filter((m) => m.listAction.actionId.startsWith("conn_mod_def::"));
4714
+ return syncable.sort((a, b) => a.name.localeCompare(b.name));
4710
4715
  }
4711
4716
 
4712
4717
  // src/lib/sync/profile.ts
@@ -4727,12 +4732,15 @@ function readProfile(platform, model) {
4727
4732
  }
4728
4733
  }
4729
4734
  function writeProfile(profile) {
4730
- const required = ["platform", "model", "connectionKey", "actionId", "resultsPath", "idField", "pagination"];
4735
+ const required = ["platform", "model", "connectionKey", "actionId", "idField", "pagination"];
4731
4736
  for (const field of required) {
4732
4737
  if (!profile[field]) {
4733
4738
  throw new Error(`Missing required field: ${field}`);
4734
4739
  }
4735
4740
  }
4741
+ if (profile.resultsPath === void 0) {
4742
+ throw new Error('Missing required field: resultsPath (use "" or "$" for root-array responses)');
4743
+ }
4736
4744
  if (!profile.pagination.type) {
4737
4745
  throw new Error("Missing required field: pagination.type");
4738
4746
  }
@@ -5521,6 +5529,11 @@ async function enrichPhase(api, db, config2, model, idField, connectionKey, plat
5521
5529
  `Enrich: could not load action ${config2.actionId}: ${err instanceof Error ? err.message : String(err)}`
5522
5530
  );
5523
5531
  }
5532
+ if (detailAction.tags?.includes("custom")) {
5533
+ throw new Error(
5534
+ `Enrich does not support custom actions. Action ${config2.actionId} is tagged "custom". Use a passthrough detail endpoint \u2014 run 'one actions search ${platform} "<model> get"' to find one.`
5535
+ );
5536
+ }
5524
5537
  let concurrency = config2.concurrency ?? DEFAULT_CONCURRENCY;
5525
5538
  let enriched = 0;
5526
5539
  let skipped = 0;
@@ -5694,6 +5707,50 @@ function detectColumnType2(value) {
5694
5707
  return "TEXT";
5695
5708
  }
5696
5709
 
5710
+ // src/lib/sync/extract.ts
5711
+ var ROOT_PATH_TOKENS = /* @__PURE__ */ new Set(["", "$", "."]);
5712
+ function isRootPath(resultsPath) {
5713
+ return resultsPath === void 0 || resultsPath === null || ROOT_PATH_TOKENS.has(resultsPath);
5714
+ }
5715
+ function isPrimitive(value) {
5716
+ const t = typeof value;
5717
+ return t === "string" || t === "number" || t === "boolean";
5718
+ }
5719
+ function describeType(value) {
5720
+ if (value === null) return "null";
5721
+ if (Array.isArray(value)) return "array";
5722
+ return typeof value;
5723
+ }
5724
+ function extractRecords(responseData, resultsPath, idField, profileLabel) {
5725
+ let candidate;
5726
+ if (isRootPath(resultsPath)) {
5727
+ candidate = responseData;
5728
+ } else {
5729
+ candidate = getByDotPath(responseData, resultsPath);
5730
+ }
5731
+ if (!Array.isArray(candidate)) {
5732
+ const topKeys = typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) ? Object.keys(responseData).slice(0, 10) : [];
5733
+ const pathLabel = isRootPath(resultsPath) ? "<root>" : `'${resultsPath}'`;
5734
+ const typeLabel = describeType(responseData);
5735
+ const keyHint = topKeys.length > 0 ? ` Top-level keys: [${topKeys.join(", ")}].` : "";
5736
+ throw new Error(
5737
+ `Could not find results at path ${pathLabel} for profile ${profileLabel}. Response top-level type is ${typeLabel}.${keyHint} Set resultsPath to the array field, or use "" / "$" / "." for root arrays.`
5738
+ );
5739
+ }
5740
+ if (candidate.length === 0) {
5741
+ return { records: [], wrappedPrimitives: false };
5742
+ }
5743
+ if (isPrimitive(candidate[0])) {
5744
+ const wrapped = [];
5745
+ for (const value of candidate) {
5746
+ if (!isPrimitive(value)) continue;
5747
+ wrapped.push({ [idField]: String(value) });
5748
+ }
5749
+ return { records: wrapped, wrappedPrimitives: true };
5750
+ }
5751
+ return { records: candidate, wrappedPrimitives: false };
5752
+ }
5753
+
5697
5754
  // src/lib/sync/runner.ts
5698
5755
  var MAX_RETRIES_PER_PAGE = 3;
5699
5756
  var DEFAULT_SINCE_DAYS = 90;
@@ -5866,6 +5923,11 @@ async function syncModel(api, profile, options) {
5866
5923
  }
5867
5924
  }
5868
5925
  const actionDetails = await api.getActionDetails(profile.actionId);
5926
+ if (actionDetails.tags?.includes("custom")) {
5927
+ throw new Error(
5928
+ `Sync does not support custom actions. Action ${profile.actionId} is tagged "custom". Use a passthrough action \u2014 run 'one actions search ${platform} "${model}"' to find one, or compose a flow that chains passthrough calls if the logic is complex.`
5929
+ );
5930
+ }
5869
5931
  const maxPages = options.maxPages ?? Infinity;
5870
5932
  let tableCreated = tableExists(db, model);
5871
5933
  let currentPageQueryParams = { ...queryParams };
@@ -5938,13 +6000,13 @@ async function syncModel(api, profile, options) {
5938
6000
  throw err;
5939
6001
  }
5940
6002
  }
5941
- const records = getByDotPath(responseData, profile.resultsPath);
5942
- if (!Array.isArray(records)) {
5943
- const topKeys = typeof responseData === "object" && responseData !== null ? Object.keys(responseData) : [];
5944
- throw new Error(
5945
- `Could not find results at path '${profile.resultsPath}' in API response. Check your sync profile. Response keys: [${topKeys.join(", ")}]`
5946
- );
5947
- }
6003
+ const extraction = extractRecords(
6004
+ responseData,
6005
+ profile.resultsPath,
6006
+ profile.idField,
6007
+ `${platform}/${model}`
6008
+ );
6009
+ const records = extraction.records;
5948
6010
  if (records.length === 0 && page === 0) {
5949
6011
  if (!isAgentMode()) {
5950
6012
  process.stderr.write(`No records found for ${model} with the given filters.
@@ -6243,23 +6305,36 @@ async function testSyncProfile(api, profile) {
6243
6305
  return report;
6244
6306
  }
6245
6307
  let resolvedResultsPath = profile.resultsPath;
6246
- let records = getByDotPath(responseData, resolvedResultsPath);
6247
- if (!Array.isArray(records) && typeof responseData === "object" && responseData !== null) {
6248
- const topObj = responseData;
6249
- const arrayKey = Object.keys(topObj).find((k) => Array.isArray(topObj[k]));
6250
- if (arrayKey) {
6251
- resolvedResultsPath = arrayKey;
6252
- records = topObj[arrayKey];
6308
+ const rawCandidate = isRootPath(resolvedResultsPath) ? responseData : getByDotPath(responseData, resolvedResultsPath);
6309
+ let records = Array.isArray(rawCandidate) ? rawCandidate : null;
6310
+ if (records === null) {
6311
+ if (Array.isArray(responseData)) {
6312
+ resolvedResultsPath = "";
6313
+ records = responseData;
6253
6314
  report.autoFixed = report.autoFixed ?? {};
6254
- report.autoFixed.resultsPath = arrayKey;
6315
+ report.autoFixed.resultsPath = "";
6255
6316
  checks.push({
6256
- name: `resultsPath auto-discovered \u2192 "${arrayKey}"`,
6317
+ name: `resultsPath auto-discovered \u2192 <root>`,
6257
6318
  ok: true,
6258
- detail: `Profile had "${profile.resultsPath}" which didn't resolve; found "${arrayKey}" in response`
6319
+ detail: `Response is a root-level array \u2014 profile had "${profile.resultsPath}"`
6259
6320
  });
6321
+ } else if (typeof responseData === "object" && responseData !== null) {
6322
+ const topObj = responseData;
6323
+ const arrayKey = Object.keys(topObj).find((k) => Array.isArray(topObj[k]));
6324
+ if (arrayKey) {
6325
+ resolvedResultsPath = arrayKey;
6326
+ records = topObj[arrayKey];
6327
+ report.autoFixed = report.autoFixed ?? {};
6328
+ report.autoFixed.resultsPath = arrayKey;
6329
+ checks.push({
6330
+ name: `resultsPath auto-discovered \u2192 "${arrayKey}"`,
6331
+ ok: true,
6332
+ detail: `Profile had "${profile.resultsPath}" which didn't resolve; found "${arrayKey}" in response`
6333
+ });
6334
+ }
6260
6335
  }
6261
6336
  }
6262
- if (!Array.isArray(records)) {
6337
+ if (records === null) {
6263
6338
  const topKeys = typeof responseData === "object" && responseData !== null ? Object.keys(responseData) : [];
6264
6339
  checks.push({
6265
6340
  name: `resultsPath "${resolvedResultsPath}" \u2192 array`,
@@ -6268,10 +6343,22 @@ async function testSyncProfile(api, profile) {
6268
6343
  });
6269
6344
  return report;
6270
6345
  }
6346
+ let wrappedPrimitives = false;
6347
+ if (records.length > 0 && typeof records[0] !== "object") {
6348
+ const idField = profile.idField || "id";
6349
+ const wrapped = [];
6350
+ for (const v of records) {
6351
+ if (typeof v === "object") continue;
6352
+ wrapped.push({ [idField]: String(v) });
6353
+ }
6354
+ records = wrapped;
6355
+ wrappedPrimitives = true;
6356
+ }
6357
+ const pathLabel = isRootPath(resolvedResultsPath) ? "<root>" : `"${resolvedResultsPath}"`;
6271
6358
  checks.push({
6272
- name: `resultsPath "${resolvedResultsPath}" \u2192 array`,
6359
+ name: `resultsPath ${pathLabel} \u2192 array`,
6273
6360
  ok: true,
6274
- detail: `${records.length} records`
6361
+ detail: wrappedPrimitives ? `${records.length} primitive records (wrapped as { ${profile.idField || "id"}: value })` : `${records.length} records`
6275
6362
  });
6276
6363
  if (records.length === 0) {
6277
6364
  checks.push({ name: "sample record available", ok: false, detail: "empty result set" });
@@ -8510,23 +8597,29 @@ one --agent sync profiles stripe # filter by platform
8510
8597
 
8511
8598
  When a built-in exists, \`sync init\` uses it automatically \u2014 no inference needed, no manual config. The agent just needs to match the user's intent to a profile description.
8512
8599
 
8513
- ## Action Resolution
8600
+ ## Action Resolution \u2014 custom actions are hard-blocked
8514
8601
 
8515
- Sync profiles MUST prefer passthrough actions over custom actions.
8516
- Custom actions add server-side fan-out and transformation that causes timeouts
8517
- and payload size failures at scale. The sync engine handles pagination, retries,
8518
- rate limiting, and enrichment locally \u2014 a server-side middleware layer on top
8519
- of that creates problems, not value.
8602
+ Sync refuses to run against custom/composer actions (tag \`custom\`). Both
8603
+ the list action and any enrich detail action in a profile MUST be passthrough.
8604
+ \`sync run\` loads the action's knowledge, checks for the \`custom\` tag, and
8605
+ aborts with a clear error pointing at the passthrough alternative.
8520
8606
 
8521
- When resolving actions for sync profiles:
8522
- 1. Search with knowledge mode (not execute mode) to include passthrough actions
8523
- 2. Prefer GET passthrough endpoints (e.g. /gmail/v1/users/{userId}/threads)
8524
- over POST custom endpoints (e.g. /gmail/get-threads)
8525
- 3. Use enrich config for per-record detail fetching instead of relying on
8526
- custom actions that fan out server-side
8527
- 4. Only fall back to custom actions when no passthrough equivalent exists
8607
+ Why the block:
8608
+ - Custom actions run on a small shared fleet that collapses under sync-scale load
8609
+ - Custom list endpoints often expect filters in the body and silently return
8610
+ unfiltered or empty results (sync sends params as query/path only by design)
8611
+ - The sync engine already handles pagination, retry, rate limiting, and per-record
8612
+ enrichment locally \u2014 server-side fan-out on top of that creates 5xx, not value
8528
8613
 
8529
- This applies to sync models discovery, sync init, and enrich action selection.
8614
+ How to build a profile:
8615
+ 1. \`one actions search <platform> "<model>"\` surfaces passthrough actions.
8616
+ \`sync init\`'s auto-infer also drops customs before offering choices.
8617
+ 2. Prefer GET passthrough endpoints (e.g. /gmail/v1/users/{userId}/threads)
8618
+ over POST custom endpoints (e.g. /gmail/get-threads).
8619
+ 3. If no passthrough list action exists for a model, that model can't be
8620
+ synced. Compose a flow that chains passthrough calls instead. Custom
8621
+ actions are for one-off agent use only \u2014 never sync, never flow.
8622
+ 4. The enrich \`actionId\` is held to the same rule: must be passthrough.
8530
8623
 
8531
8624
  ## Workflow: init \u2192 run \u2192 query
8532
8625
 
@@ -8558,7 +8651,7 @@ one --agent sync sql stripe "SELECT count(*) FROM balanceTransactions"
8558
8651
  \`sync init\` without \`--config\` does all of this automatically:
8559
8652
  - **connectionKey** \u2014 auto-resolved when there's exactly one connection for the platform
8560
8653
  - **Pagination** \u2014 Stripe id-pagination, Notion body-cursor, HubSpot/Google token, offset, link. Inapplicable fields stripped (no nextPath for offset, no passAs for none)
8561
- - **resultsPath** \u2014 generic keys (data, results, items) + platform-specific (model name stripped of platform prefix: attioCompanies \u2192 companies)
8654
+ - **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 }\`.
8562
8655
  - **idField** \u2014 id, _id, uuid
8563
8656
  - **pathVars** \u2014 extracted from URL template with smart defaults (calendarId="primary", userId="me"). Internal keys (INTERNAL_SIGNING_KEY) and record-level IDs (record_id) are stripped automatically
8564
8657
  - **dateFilter** \u2014 updated_since, created_after, etc.
@@ -8723,7 +8816,7 @@ Fetches ALL records and deletes local rows whose IDs are no longer in the source
8723
8816
  |-------|----------|-------------|
8724
8817
  | connectionKey | yes | From \`one list\` |
8725
8818
  | actionId | yes | Auto-resolved by \`sync init\` |
8726
- | resultsPath | yes | Auto-inferred or auto-discovered by \`sync test\` |
8819
+ | resultsPath | yes | Auto-inferred or auto-discovered by \`sync test\`. Use \`""\` / \`"$"\` / \`"."\` for root-array responses |
8727
8820
  | idField | yes | Auto-inferred or auto-discovered by \`sync test\` |
8728
8821
  | pagination | yes | Auto-inferred (cursor/token/offset/id/link/none) |
8729
8822
  | pathVars | no | Auto-extracted from URL template |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/cli",
3
- "version": "1.37.3",
3
+ "version": "1.39.0",
4
4
  "description": "CLI for managing One",
5
5
  "type": "module",
6
6
  "files": [
@@ -16,7 +16,8 @@
16
16
  "build": "tsup",
17
17
  "dev": "tsup --watch",
18
18
  "start": "node bin/cli.js",
19
- "typecheck": "tsc --noEmit"
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "tsx --test \"src/**/*.test.ts\""
20
21
  },
21
22
  "dependencies": {
22
23
  "@clack/prompts": "^0.9.1",
@@ -0,0 +1,11 @@
1
+ {
2
+ "description": "Hacker News top story IDs — up to 500 item IDs at /v0/topstories.json (root-array response, primitives wrapped as { id })",
3
+ "platform": "hacker-news",
4
+ "model": "topStories",
5
+ "connectionKey": "AUTO",
6
+ "actionId": "conn_mod_def::GJ3108Dwmm4::avAMAq7HQtW6PT8JhPg5vA",
7
+ "resultsPath": "",
8
+ "idField": "id",
9
+ "pagination": { "type": "none" },
10
+ "limitParam": ""
11
+ }
@@ -173,6 +173,8 @@ one --agent sync list stripe # progress + freshness
173
173
  one sync schedule add stripe --every 1h
174
174
  ```
175
175
 
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
+
176
178
  **Advanced features** (enrich, transform, exclude, identityKey, hooks, --full-refresh, --where-sql delete, cursor resume): run `one guide sync` for the full reference.
177
179
 
178
180
  ## Beyond Single Actions