@formo/cli 1.2.1 → 1.3.1

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
@@ -91,10 +91,12 @@ Fetch a single wallet profile by address or ENS name.
91
91
  | Option | Description |
92
92
  |---|---|
93
93
  | `--expand` | Comma-separated fields: `apps`, `chains`, `tokens`, `labels` |
94
+ | `--timestamp` | ISO-8601 timestamp; return the closest stored wallet-enrichment snapshot |
94
95
 
95
96
  ```bash
96
97
  formo profiles get 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
97
98
  formo profiles get vitalik.eth --expand labels,chains
99
+ formo profiles get vitalik.eth --timestamp 2025-06-21T10:03:00Z
98
100
  ```
99
101
 
100
102
  ### `profiles search`
@@ -105,6 +107,7 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat
105
107
  |---|---|
106
108
  | `--address` | Filter by wallet address |
107
109
  | `--search` | Free-text search across address and identity fields |
110
+ | `--timestamp` | ISO-8601 timestamp; requires `--address` and returns the closest stored wallet-enrichment snapshot |
108
111
  | `--page` | Page number (1-indexed, default `1`) |
109
112
  | `--size` | Page size (default `100`, max `1000`) |
110
113
  | `--order-by` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` |
@@ -115,6 +118,7 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat
115
118
 
116
119
  ```bash
117
120
  formo profiles search --size 10
121
+ formo profiles search --address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --timestamp 2025-06-21T10:03:00Z
118
122
  formo profiles search --order-by net_worth_usd --order-dir desc --size 5
119
123
  formo profiles search --page 2 --size 20
120
124
  formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
@@ -122,6 +126,8 @@ formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","valu
122
126
  formo profiles search --filters '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]' --size 20
123
127
  ```
124
128
 
129
+ With `--timestamp`, wallet-enrichment fields come from the stored snapshot closest to that instant. Exact ties select the later snapshot. Expanded chains, apps, and tokens come from the selected profiling batch, while project engagement fields, identity overrides, and labels remain current. `profiles search --timestamp` requires `--address`.
130
+
125
131
  ### Lifecycle tuning (advanced)
126
132
 
127
133
  Both `profiles get` and `profiles search` accept optional flags to override the lifecycle stage thresholds used when computing `lifecycle`:
@@ -142,7 +148,8 @@ Merge-update identity properties on a wallet profile.
142
148
 
143
149
  | Option | Description |
144
150
  |---|---|
145
- | `--properties` | JSON object of properties to merge |
151
+ | `--properties` | JSON object of properties to merge; use `null` to unset a property |
152
+ | `--unset` | Comma-separated property keys to unset (`user_id` cannot be unset) |
146
153
 
147
154
  **Allowed property keys:** `user_id`, `display_name`, `email`, `farcaster`, `discord`, `twitter`, `telegram`, `instagram`, `website`, `github`, `linkedin`, `facebook`, `tiktok`, `youtube`, `reddit`, `avatar`, `description`, `location`, `ens`, `lens`, `basenames`, `linea`. Unknown keys are rejected server-side.
148
155
 
@@ -151,6 +158,8 @@ formo profiles update 0xd8dA... --properties '{"display_name":"Vitalik","twitter
151
158
  formo profiles update vitalik.eth --properties '{"email":"alice@example.com"}'
152
159
  ```
153
160
 
161
+ `--unset` takes precedence over matching keys in `--properties`. Deletions also mask enriched fallback values and historical snapshot reads until a new value is set.
162
+
154
163
  > Requires `profiles:write` scope.
155
164
 
156
165
  ### `profiles properties batch`
@@ -241,13 +250,6 @@ Toggle an alert between `active` and `inactive`.
241
250
  formo alerts toggle alert_abc123 --status inactive
242
251
  ```
243
252
 
244
- ### `alerts test <alertId>`
245
- Send a test alert delivery with optional sample payloads.
246
-
247
- ```bash
248
- formo alerts test alert_abc123 --sample-event '{"event":"transaction","revenue":250}'
249
- ```
250
-
251
253
  ---
252
254
 
253
255
  ## `formo boards`
@@ -266,7 +268,7 @@ Get a single board by ID.
266
268
  |---|---|
267
269
  | `--title` | Board title |
268
270
  | `--description` | Optional board description |
269
- | `--is-public` | Make the board publicly viewable |
271
+ | `--is-public` | Make the board publicly viewable. Omit to keep it private (the default). |
270
272
 
271
273
  ```bash
272
274
  formo boards create --title "Revenue Metrics" --description "Weekly revenue tracking"
@@ -278,7 +280,7 @@ formo boards create --title "Revenue Metrics" --description "Weekly revenue trac
278
280
  |---|---|
279
281
  | `--title` | New board title |
280
282
  | `--description` | New board description |
281
- | `--is-public` | Update public visibility |
283
+ | `--is-public` | Make the board publicly viewable. Omit to keep the stored setting; pass `--is-public=false` to make it private. |
282
284
 
283
285
  ### `boards delete <boardId>`
284
286
  Delete a board.
@@ -290,7 +292,7 @@ Delete a board.
290
292
  Chart commands. Charts live inside a board. Requires `boards:read` / `boards:write`.
291
293
 
292
294
  ### `charts list --board-id <boardId>`
293
- List all charts in a board.
295
+ List charts in a board. Returns lightweight summaries by default; pass `--results` to execute each chart’s query and include full results.
294
296
 
295
297
  ### `charts get <chartId> --board-id <boardId>`
296
298
  Get a single chart by ID.
@@ -342,8 +344,6 @@ List all tracked contracts. Returns `{ data: Contract[], deploy: { last_deployed
342
344
  ### `contracts get <chain> <address>`
343
345
  Get a single tracked contract.
344
346
 
345
- ### `contracts recommendations`
346
- List contracts the project already interacts with but has not added yet.
347
347
 
348
348
  ### `contracts create`
349
349
 
@@ -354,8 +354,8 @@ List contracts the project already interacts with but has not added yet.
354
354
  | `--name` | Human-readable contract name |
355
355
  | `--abi` | Contract ABI as a JSON string; sent stringified to the API |
356
356
  | `--events` | JSON array of ABI event objects to monitor |
357
- | `--start-block` | Optional start block |
358
- | `--include-in-pipeline` | Include this contract in the Goldsky events pipeline (`true` by default in the API) |
357
+ | `--start-block` | Non-negative safe integer recorded on the contract; does not backfill historical events |
358
+ | `--include-in-pipeline` | Deploy this contract to the events pipeline. Omit for decode-only (the default). |
359
359
 
360
360
  ```bash
361
361
  formo contracts create --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --chain 1 \
@@ -370,11 +370,9 @@ formo contracts create --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --ch
370
370
  | `--name` | Updated contract name |
371
371
  | `--abi` | Updated ABI |
372
372
  | `--events` | Updated JSON array of ABI event objects |
373
- | `--start-block` | Optional start block |
374
- | `--include-in-pipeline` | Include or exclude this contract from the Goldsky events pipeline |
373
+ | `--start-block` | Non-negative safe integer recorded on the contract; does not backfill historical events |
374
+ | `--include-in-pipeline` | Deploy this contract to the events pipeline. Omit to keep the stored setting; pass `--include-in-pipeline=false` to exclude it. |
375
375
 
376
- ### `contracts pipeline <chain> <address> --include-in-pipeline <true|false>`
377
- Toggle pipeline inclusion without re-sending the full ABI/events payload.
378
376
 
379
377
  ### `contracts delete <chain> <address>`
380
378
  Remove a tracked contract.
@@ -440,6 +438,12 @@ formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"
440
438
 
441
439
  On `kpis`, `top_*`, `revenue_*` and `volume_by_metric`, `--params '{"page_scope":"session"}'` widens a `page` filter from page-scoped metrics (the default) to the legacy session scope.
442
440
 
441
+ On user-aggregate pipes such as `lifecycle` and `frequency`, either-touch attribution filters can use a `fields` pair in place of `field`, e.g. `{"fields":["first_utm_source","last_utm_source"],"op":"eq","value":"twitter"}`.
442
+
443
+ Overview Data source filters use `field: "channel"` with `web`, `mobile`, `api`, `import`, `server`, or `onchain`; acquisition channel uses `channel_type`. User/lifecycle source filters use `source_filter` through `--params` with `field: "source"`.
444
+
445
+ Retention defaults to rolling (active in week N or later); use `--params '{"retention_type":"recurring"}'` for activity in exactly week N. Funnel steps accept `events` OR alternatives with member-level `filters`; the primary `type`/`event` is always included and step-level `filters` apply to the whole group.
446
+
443
447
  All user-attribute, profile, social, lifecycle and resource predicates go in the single `--filters` array, using the canonical envelope with named qualifiers (`chain_id`, `app_id`, `token_address`, `scope`, `tag_id`). The retired per-family params — `socials`, `chain_filters`, `app_filters`, `token_filters`, `label_filters`, `profile_filters`, `lifecycle_filter` — are rejected with a `400` if passed through `--params`.
444
448
 
445
449
  ---
@@ -554,7 +558,7 @@ Every command supports the standard incur output flags:
554
558
  | `--verbose` | Include the full envelope (`ok`, `data`, `meta`) |
555
559
  | `--filter-output <keys>` | Filter output by key paths (e.g. `data,meta.duration`) |
556
560
 
557
- Every list endpoint returns a `PaginatedResponse<T>` envelope: `{ data: [...], total, page, size, has_more }`. Every error follows: `{ error: { code, message, doc_url, param?, details? } }` — branch on `error.code`, not `message`.
561
+ Every list endpoint returns a `PaginatedResponse<T>` envelope: `{ data: [...], total, page, size, has_more }`. Most API errors follow: `{ error: { code, message, doc_url, param?, details? } }` — branch on `error.code` when available. Event-ingestion errors can be `{error:"..."}`, and rate-limit responses can be plain text; HTTP status is authoritative.
558
562
 
559
563
  ---
560
564
 
@@ -19,10 +19,3 @@ export declare function createAlertRun(options: CreateAlertOptions): Promise<unk
19
19
  export declare function updateAlertRun(alertId: string, options: AlertBodyOptions): Promise<unknown>;
20
20
  export declare function deleteAlertRun(alertId: string): Promise<unknown>;
21
21
  export declare function toggleAlertRun(alertId: string, status: string): Promise<unknown>;
22
- export interface TestAlertOptions {
23
- sampleEvent?: string;
24
- sampleUser?: string;
25
- recipientOverrides?: string;
26
- }
27
- export declare function buildTestAlertBody(options: TestAlertOptions): Record<string, unknown> | undefined;
28
- export declare function testAlertRun(alertId: string, options?: TestAlertOptions): Promise<unknown>;
@@ -8,8 +8,6 @@ exports.createAlertRun = createAlertRun;
8
8
  exports.updateAlertRun = updateAlertRun;
9
9
  exports.deleteAlertRun = deleteAlertRun;
10
10
  exports.toggleAlertRun = toggleAlertRun;
11
- exports.buildTestAlertBody = buildTestAlertBody;
12
- exports.testAlertRun = testAlertRun;
13
11
  const incur_1 = require("incur");
14
12
  const client_1 = require("../lib/client");
15
13
  const filters_1 = require("../lib/filters");
@@ -201,54 +199,3 @@ exports.alerts.command('toggle', {
201
199
  return toggleAlertRun(args.alertId, options.status);
202
200
  },
203
201
  });
204
- function buildTestAlertBody(options) {
205
- const body = {};
206
- if (options.sampleEvent !== undefined) {
207
- body.sampleEvent = (0, json_1.parseJsonObject)(options.sampleEvent, '--sample-event');
208
- }
209
- if (options.sampleUser !== undefined) {
210
- body.sampleUser = (0, json_1.parseJsonObject)(options.sampleUser, '--sample-user');
211
- }
212
- if (options.recipientOverrides !== undefined) {
213
- body.recipientOverrides = (0, json_1.parseJsonArray)(options.recipientOverrides, '--recipient-overrides');
214
- }
215
- return Object.keys(body).length > 0 ? body : undefined;
216
- }
217
- function testAlertRun(alertId, options = {}) {
218
- (0, client_1.requireApiKey)();
219
- const client = (0, client_1.createClient)();
220
- return client.post(`/v0/alerts/${encodeURIComponent(alertId)}/test`, buildTestAlertBody(options));
221
- }
222
- exports.alerts.command('test', {
223
- description: 'Send a test delivery for an alert',
224
- args: incur_1.z.object({
225
- alertId: incur_1.z.string().describe('Alert ID to test'),
226
- }),
227
- options: incur_1.z.object({
228
- sampleEvent: incur_1.z
229
- .string()
230
- .optional()
231
- .describe('Optional JSON object to use as the sample event'),
232
- sampleUser: incur_1.z
233
- .string()
234
- .optional()
235
- .describe('Optional JSON object to use as the sample user/profile'),
236
- recipientOverrides: incur_1.z
237
- .string()
238
- .optional()
239
- .describe('Optional JSON array of recipient objects to test instead of saved recipients'),
240
- }),
241
- examples: [
242
- {
243
- args: { alertId: 'alert_abc123' },
244
- options: {
245
- sampleEvent: '{"event":"transaction","revenue":250}',
246
- },
247
- description: 'Send a test alert with a sample event',
248
- },
249
- ],
250
- hint: 'Requires alerts:write scope on your API key.',
251
- run({ args, options }) {
252
- return testAlertRun(args.alertId, options);
253
- },
254
- });
@@ -17,15 +17,15 @@ exports.analytics = incur_1.Cli.create('analytics', {
17
17
  const PIPES = [
18
18
  { name: 'kpis', description: 'Traffic KPIs: visitors, pageviews, bounce rate, session duration' },
19
19
  { name: 'event_timeseries', description: 'Event counts over time' },
20
- { name: 'funnel', description: 'Conversion funnel across ordered steps. --params: steps (JSON array of {type,event,name,filters?}), window_seconds, funnel_type, group_by, limit, attribution' },
20
+ { name: 'funnel', description: 'Conversion funnel across ordered steps. --params: steps (JSON array of {type,event,name,filters?,events?}; events adds OR alternatives with member filters), window_seconds, funnel_type, group_by, limit, attribution' },
21
21
  { name: 'flow', description: 'User path/flow analysis. --params: start_step / end_step (JSON {type,event,...}), global_filters, window_seconds, max_steps' },
22
22
  { name: 'frequency', description: 'Engagement frequency distribution' },
23
- { name: 'lifecycle', description: 'User lifecycle stages (new, returning, power, resurrected, churned)' },
24
- { name: 'retention', description: 'Retention cohort analysis (params: id_type, event_type, event_name, min_users)' },
25
- { name: 'revenue_overview', description: 'Revenue overview with optional breakdown (params: group_by, rank_by)' },
26
- { name: 'revenue_by_metric', description: 'Revenue ranked by a metric column (params: metric_column, limit, offset)' },
23
+ { name: 'lifecycle', description: 'User lifecycle stages (New, Returning, Power user, At Risk, Churned, Resurrected)' },
24
+ { name: 'retention', description: 'Retention cohort analysis (params: retention_type — rolling by default or recurring, id_type, event_type, event_name, min_users)' },
25
+ { name: 'revenue_overview', description: 'Revenue overview with optional breakdown (params: group_by — incl. channel_type and paid_source for ad network, rank_by)' },
26
+ { name: 'revenue_by_metric', description: 'Revenue ranked by a metric column (params: metric_column — incl. channel and paid_source for ad network, limit, offset)' },
27
27
  { name: 'revenue_timeseries', description: 'Revenue over time (params: address)' },
28
- { name: 'volume_by_metric', description: 'Trading volume ranked by a metric column (params: metric_column, limit, offset)' },
28
+ { name: 'volume_by_metric', description: 'Trading volume ranked by a metric column (params: metric_column — incl. channel and paid_source for ad network, limit, offset)' },
29
29
  { name: 'top_chains', description: 'Top chains by activity (params: limit, offset)' },
30
30
  { name: 'top_events', description: 'Top events by count (params: limit, offset, type)' },
31
31
  { name: 'top_locations', description: 'Top locations (params: limit, offset)' },
@@ -45,8 +45,11 @@ const RESERVED_PARAM_KEYS = new Set([
45
45
  'dateTo',
46
46
  'filters',
47
47
  ]);
48
- const ANALYTICS_FILTER_KEYS = new Set(['field', 'op', 'value', 'filters']);
49
- const ANALYTICS_NESTED_FILTER_KEYS = new Set(['field', 'op', 'value']);
48
+ const ANALYTICS_FILTER_KEYS = new Set([
49
+ 'field', 'fields', 'op', 'value', 'filters',
50
+ 'chain_id', 'app_id', 'token_address', 'scope', 'tag_id',
51
+ ]);
52
+ const ANALYTICS_NESTED_FILTER_KEYS = new Set(['field', 'fields', 'op', 'value']);
50
53
  function validateAnalyticsFilter(filter, path, allowNested) {
51
54
  if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
52
55
  throw new Error(`${path} must be a {field, op, value} object`);
@@ -59,11 +62,19 @@ function validateAnalyticsFilter(filter, path, allowNested) {
59
62
  ? ANALYTICS_FILTER_KEYS
60
63
  : ANALYTICS_NESTED_FILTER_KEYS;
61
64
  if (Object.keys(record).some((key) => !allowedKeys.has(key))) {
62
- throw new Error(`${path} may only contain field, op, value${allowNested ? ', and filters' : ''}`);
65
+ throw new Error(`${path} may only contain field, op, value, fields${allowNested ? ', filters, and resource qualifiers (chain_id, app_id, token_address, scope, tag_id)' : ''}`);
66
+ }
67
+ if (record.field !== undefined && record.fields !== undefined) {
68
+ throw new Error(`${path} must use only one of field or fields`);
69
+ }
70
+ if (record.fields !== undefined && (!Array.isArray(record.fields) || record.fields.length !== 2 ||
71
+ !record.fields.every((field) => typeof field === 'string' && field.length > 0))) {
72
+ throw new Error(`${path}.fields must name exactly two non-empty columns`);
63
73
  }
64
- if (typeof record.field !== 'string' || record.field.length === 0) {
65
- throw new Error(`${path} requires a non-empty string "field"`);
74
+ if (record.fields === undefined && (typeof record.field !== 'string' || record.field.length === 0)) {
75
+ throw new Error(`${path} requires a non-empty string "field" or a "fields" pair`);
66
76
  }
77
+ (0, filters_1.validateQualifiers)(record, typeof record.field === 'string' ? record.field : '', path);
67
78
  if (!(0, filters_1.isCanonicalFilterOperator)(record.op)) {
68
79
  throw new Error(`${path} requires a canonical "op"`);
69
80
  }
@@ -163,7 +174,7 @@ const sharedOptions = incur_1.z.object({
163
174
  .string()
164
175
  .optional()
165
176
  .describe('JSON array of filter conditions: [{"field","op","value"}]. ' +
166
- 'Use op "in"/"nin" with an array value (e.g. ["chrome","firefox"]); pipe-delimited strings are also accepted. Array string members cannot contain "|".'),
177
+ 'Use op "in"/"nin" with an array value (e.g. ["chrome","firefox"]); pipe-delimited strings are also accepted. Array string members cannot contain "|". Resource filters accept chain_id, app_id, token_address, scope, and tag_id qualifiers.'),
167
178
  params: incur_1.z
168
179
  .string()
169
180
  .optional()
@@ -15,7 +15,7 @@ export interface ChartBodyOptions {
15
15
  settings?: string;
16
16
  }
17
17
  export declare function buildChartBody(options: ChartBodyOptions): Record<string, unknown>;
18
- export declare function listChartsRun(boardId: string, options?: PaginationOptions): Promise<unknown>;
18
+ export declare function listChartsRun(boardId: string, options?: PaginationOptions, results?: boolean): Promise<unknown>;
19
19
  export declare function listChartSummariesRun(boardId: string, options?: PaginationOptions): Promise<unknown>;
20
20
  export declare function getChartRun(boardId: string, chartId: string): Promise<unknown>;
21
21
  export interface QueryChartOptions {
@@ -109,35 +109,48 @@ const chartBodyOptions = incur_1.z.object({
109
109
  .describe('JSON object of type-specific chart settings (for example, user_paths anchors/maxSteps/nodesPerStep or retention entryFilter/retentionFilter)'),
110
110
  });
111
111
  // ── List charts for a board ──
112
- function listChartsRun(boardId, options = {}) {
112
+ function listChartsRun(boardId, options = {}, results = false) {
113
113
  (0, client_1.requireApiKey)();
114
114
  const client = (0, client_1.createClient)();
115
115
  return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/`, {
116
- params: (0, pagination_1.buildPaginationParams)(options),
116
+ params: {
117
+ ...(0, pagination_1.buildPaginationParams)(options),
118
+ // The public API returns lightweight summaries unless asked to execute.
119
+ ...(results ? { include: 'results' } : {}),
120
+ },
117
121
  });
118
122
  }
119
123
  exports.charts.command('list', {
120
- description: 'List all charts for a board, including executed results',
124
+ description: 'List charts for a board (summaries by default; --results executes each chart query)',
121
125
  options: incur_1.z.object({
122
126
  boardId: incur_1.z.string().describe('Board ID to list charts from'),
127
+ results: incur_1.z
128
+ .boolean()
129
+ .optional()
130
+ .describe('Execute each chart query and include results (slower)'),
123
131
  ...pagination_1.paginationOptionsSchema,
124
132
  }),
125
133
  examples: [
126
134
  {
127
135
  options: { boardId: 'board_abc123' },
128
- description: 'List all charts in a board',
136
+ description: 'List chart summaries for a board',
137
+ },
138
+ {
139
+ options: { boardId: 'board_abc123', results: true },
140
+ description: 'List charts with executed query results',
129
141
  },
130
142
  ],
131
143
  hint: 'Requires boards:read scope on your API key.',
132
144
  run({ options }) {
133
- return listChartsRun(options.boardId, options);
145
+ return listChartsRun(options.boardId, options, options.results ?? false);
134
146
  },
135
147
  });
136
148
  // ── List chart metadata for a board ──
137
149
  function listChartSummariesRun(boardId, options = {}) {
138
150
  (0, client_1.requireApiKey)();
139
151
  const client = (0, client_1.createClient)();
140
- return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/meta`, {
152
+ // Summaries are the list endpoint's default; /charts/meta is internal-only.
153
+ return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/`, {
141
154
  params: (0, pagination_1.buildPaginationParams)(options),
142
155
  });
143
156
  }
@@ -4,7 +4,6 @@ export type { PaginationOptions };
4
4
  export declare const contracts: Cli.Cli<{}, undefined, undefined, undefined>;
5
5
  export declare function listContractsRun(options?: PaginationOptions): Promise<unknown>;
6
6
  export declare function getContractRun(chain: string, address: string): Promise<unknown>;
7
- export declare function getContractRecommendationsRun(): Promise<unknown>;
8
7
  export interface CreateContractOptions {
9
8
  address: string;
10
9
  chain: number;
@@ -25,8 +24,4 @@ export interface UpdateContractOptions {
25
24
  }
26
25
  export declare function buildUpdateContractBody(chain: string | number, address: string, options: UpdateContractOptions): Record<string, unknown>;
27
26
  export declare function updateContractRun(chain: string, address: string, options: UpdateContractOptions): Promise<unknown>;
28
- export declare function updateContractPipelineRun(chain: string, address: string, includeInPipeline: boolean): Promise<unknown>;
29
- export declare function buildUpdateContractPipelineBody(includeInPipeline: boolean): {
30
- include_in_pipeline: boolean;
31
- };
32
27
  export declare function deleteContractRun(chain: string, address: string): Promise<unknown>;
@@ -3,20 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.contracts = void 0;
4
4
  exports.listContractsRun = listContractsRun;
5
5
  exports.getContractRun = getContractRun;
6
- exports.getContractRecommendationsRun = getContractRecommendationsRun;
7
6
  exports.buildCreateContractBody = buildCreateContractBody;
8
7
  exports.createContractRun = createContractRun;
9
8
  exports.buildUpdateContractBody = buildUpdateContractBody;
10
9
  exports.updateContractRun = updateContractRun;
11
- exports.updateContractPipelineRun = updateContractPipelineRun;
12
- exports.buildUpdateContractPipelineBody = buildUpdateContractPipelineBody;
13
10
  exports.deleteContractRun = deleteContractRun;
14
11
  const incur_1 = require("incur");
15
12
  const client_1 = require("../lib/client");
16
13
  const json_1 = require("../lib/json");
17
14
  const pagination_1 = require("../lib/pagination");
18
15
  exports.contracts = incur_1.Cli.create('contracts', {
19
- description: 'Smart contract commands — register, list, recommend, update, toggle pipeline inclusion, and remove tracked contracts',
16
+ description: 'Smart contract commands — register, list, update, and remove tracked contracts',
20
17
  });
21
18
  function parseChain(chain) {
22
19
  const value = typeof chain === 'number' ? chain : Number(chain);
@@ -25,6 +22,12 @@ function parseChain(chain) {
25
22
  }
26
23
  return value;
27
24
  }
25
+ function parseStartBlock(value) {
26
+ if (!Number.isSafeInteger(value) || value < 0) {
27
+ throw new Error('--start-block must be a non-negative safe integer');
28
+ }
29
+ return value;
30
+ }
28
31
  // ── List contracts ──
29
32
  function listContractsRun(options = {}) {
30
33
  (0, client_1.requireApiKey)();
@@ -48,25 +51,6 @@ function getContractRun(chain, address) {
48
51
  const client = (0, client_1.createClient)();
49
52
  return client.get(`/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}`);
50
53
  }
51
- // ── Recommended contracts ──
52
- function getContractRecommendationsRun() {
53
- (0, client_1.requireApiKey)();
54
- const client = (0, client_1.createClient)();
55
- return client.get('/v0/contracts/recommendations');
56
- }
57
- exports.contracts.command('recommendations', {
58
- description: 'List contracts the project already interacts with but has not added yet',
59
- options: incur_1.z.object({}),
60
- examples: [
61
- {
62
- description: 'Show recommended contracts to add for decoding/monitoring',
63
- },
64
- ],
65
- hint: 'Requires contracts:read scope on your API key.',
66
- run() {
67
- return getContractRecommendationsRun();
68
- },
69
- });
70
54
  exports.contracts.command('get', {
71
55
  description: 'Get a tracked contract by chain and address',
72
56
  args: incur_1.z.object({
@@ -98,7 +82,7 @@ function buildCreateContractBody(options) {
98
82
  events: parsedEvents,
99
83
  };
100
84
  if (options.startBlock !== undefined)
101
- body.start_block = options.startBlock;
85
+ body.start_block = parseStartBlock(options.startBlock);
102
86
  if (options.includeInPipeline !== undefined) {
103
87
  body.include_in_pipeline = options.includeInPipeline;
104
88
  }
@@ -151,7 +135,7 @@ function buildUpdateContractBody(chain, address, options) {
151
135
  events: parsedEvents,
152
136
  };
153
137
  if (options.startBlock !== undefined)
154
- body.start_block = options.startBlock;
138
+ body.start_block = parseStartBlock(options.startBlock);
155
139
  if (options.includeInPipeline !== undefined) {
156
140
  body.include_in_pipeline = options.includeInPipeline;
157
141
  }
@@ -197,41 +181,6 @@ exports.contracts.command('update', {
197
181
  return updateContractRun(args.chain, args.address, options);
198
182
  },
199
183
  });
200
- // ── Toggle contract pipeline inclusion ──
201
- function updateContractPipelineRun(chain, address, includeInPipeline) {
202
- (0, client_1.requireApiKey)();
203
- const client = (0, client_1.createClient)();
204
- return client.patch(`/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}/pipeline`, buildUpdateContractPipelineBody(includeInPipeline));
205
- }
206
- function buildUpdateContractPipelineBody(includeInPipeline) {
207
- return { include_in_pipeline: includeInPipeline };
208
- }
209
- exports.contracts.command('pipeline', {
210
- description: 'Toggle whether a tracked contract is included in the project events pipeline',
211
- args: incur_1.z.object({
212
- chain: incur_1.z.string().describe('Chain ID'),
213
- address: incur_1.z.string().describe('Contract address (0x...)'),
214
- }),
215
- options: incur_1.z.object({
216
- includeInPipeline: incur_1.z
217
- .boolean()
218
- .describe('true to include the contract in the pipeline, false to exclude it'),
219
- }),
220
- examples: [
221
- {
222
- args: {
223
- chain: '1',
224
- address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
225
- },
226
- options: { includeInPipeline: false },
227
- description: 'Keep ABI decoding but exclude this contract from pipeline deploys',
228
- },
229
- ],
230
- hint: 'Requires contracts:write scope on your API key.',
231
- run({ args, options }) {
232
- return updateContractPipelineRun(args.chain, args.address, options.includeInPipeline);
233
- },
234
- });
235
184
  // ── Delete a contract ──
236
185
  function deleteContractRun(chain, address) {
237
186
  (0, client_1.requireApiKey)();
@@ -15,6 +15,8 @@ function buildImportBody(options) {
15
15
  }
16
16
  if (options.rows) {
17
17
  const rows = (0, json_1.parseJsonArrayOfObjects)(options.rows, '--rows');
18
+ if (rows.length === 0)
19
+ throw new Error('--rows must contain at least one wallet');
18
20
  const addresses = rows.map((row) => row.address);
19
21
  if (addresses.some((address) => typeof address !== 'string' || !address)) {
20
22
  throw new Error('--rows entries must each include a non-empty string address');
@@ -28,6 +30,8 @@ function buildImportBody(options) {
28
30
  throw new Error('Provide --addresses or --rows');
29
31
  }
30
32
  const addresses = (0, json_1.parseJsonArray)(options.addresses, '--addresses');
33
+ if (addresses.length === 0)
34
+ throw new Error('--addresses must contain at least one wallet');
31
35
  if (addresses.some((address) => typeof address !== 'string' || !address)) {
32
36
  throw new Error('--addresses must be a JSON array of wallet address strings');
33
37
  }
@@ -11,11 +11,13 @@ export interface LifecycleThresholdOptions {
11
11
  }
12
12
  export interface GetProfileOptions extends LifecycleThresholdOptions {
13
13
  expand?: string;
14
+ timestamp?: string;
14
15
  }
15
16
  export declare function getProfileRun(address: string, optionsOrExpand?: GetProfileOptions | string): Promise<unknown>;
16
17
  export interface SearchProfilesOptions extends LifecycleThresholdOptions {
17
18
  address?: string;
18
19
  search?: string;
20
+ timestamp?: string;
19
21
  page?: number;
20
22
  size?: number;
21
23
  orderBy?: string;
@@ -33,7 +35,8 @@ export interface SearchProfilesOptions extends LifecycleThresholdOptions {
33
35
  export declare function parseSearchFilters(raw: string): unknown[];
34
36
  export declare function searchProfilesRun(options: SearchProfilesOptions): Promise<unknown>;
35
37
  export interface UpdateProfileOptions {
36
- properties: string;
38
+ properties?: string;
39
+ unset?: string;
37
40
  }
38
41
  export declare function buildUpdateProfileBody(options: UpdateProfileOptions): Record<string, unknown>;
39
42
  export declare function updateProfileRun(address: string, options: UpdateProfileOptions): Promise<unknown>;
@@ -75,6 +75,10 @@ const lifecycleThresholdOptions = {
75
75
  .optional()
76
76
  .describe('Override lifecycle at-risk prior active days threshold'),
77
77
  };
78
+ const profileTimestampOption = incur_1.z
79
+ .string()
80
+ .datetime({ offset: true })
81
+ .optional();
78
82
  function getProfileRun(address, optionsOrExpand = {}) {
79
83
  (0, client_1.requireApiKey)();
80
84
  const client = (0, client_1.createClient)();
@@ -84,6 +88,8 @@ function getProfileRun(address, optionsOrExpand = {}) {
84
88
  const params = {};
85
89
  if (options.expand)
86
90
  params.expand = options.expand;
91
+ if (options.timestamp)
92
+ params.timestamp = options.timestamp;
87
93
  addLifecycleThresholdParams(params, options);
88
94
  return client.get(`/v0/profiles/${encodeURIComponent(address)}`, { params });
89
95
  }
@@ -97,6 +103,7 @@ exports.profiles.command('get', {
97
103
  .string()
98
104
  .optional()
99
105
  .describe('Comma-separated list of fields to expand: apps,chains,tokens,labels'),
106
+ timestamp: profileTimestampOption.describe('Return the wallet-enrichment snapshot closest to this ISO-8601 timestamp'),
100
107
  ...lifecycleThresholdOptions,
101
108
  }),
102
109
  examples: [
@@ -106,6 +113,11 @@ exports.profiles.command('get', {
106
113
  options: { expand: 'labels,chains' },
107
114
  description: 'Get profile with expanded labels and chains',
108
115
  },
116
+ {
117
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
118
+ options: { timestamp: '2025-06-21T10:03:00Z' },
119
+ description: 'Get the closest stored wallet-enrichment snapshot',
120
+ },
109
121
  ],
110
122
  hint: 'Requires profiles:read scope on your API key.',
111
123
  run({ args, options }) {
@@ -138,72 +150,12 @@ const RESOURCE_FIELD_PREFIXES = new Set([
138
150
  'label',
139
151
  'labels',
140
152
  ]);
141
- const QUALIFIER_KEYS = [
142
- 'chain_id',
143
- 'app_id',
144
- 'token_address',
145
- 'tag_id',
146
- 'scope',
147
- ];
148
153
  const FILTER_ENTRY_KEYS = new Set([
149
154
  'field',
150
155
  'op',
151
156
  'value',
152
- ...QUALIFIER_KEYS,
157
+ ...filters_1.QUALIFIER_KEYS,
153
158
  ]);
154
- /**
155
- * Enforce the per-field qualifier rules, mirroring the API's schema. Sending a
156
- * qualifier the field does not accept — or omitting a required one — is a 400,
157
- * so we fail here with a message that names the offending key.
158
- */
159
- function validateQualifiers(record, field) {
160
- const present = (key) => record[key] !== undefined;
161
- const required = (key) => {
162
- if (!present(key)) {
163
- throw new Error(`--filters: "${key}" is required for "${field}"`);
164
- }
165
- };
166
- const forbidden = (keys) => {
167
- for (const key of keys) {
168
- if (present(key)) {
169
- throw new Error(`--filters: "${key}" is not valid for "${field}"`);
170
- }
171
- }
172
- };
173
- switch (field) {
174
- case 'chains.balance':
175
- // chain_id optional — omit it to match any chain.
176
- forbidden(['app_id', 'token_address', 'tag_id', 'scope']);
177
- break;
178
- case 'apps.balance':
179
- required('app_id');
180
- forbidden(['token_address', 'tag_id', 'scope']);
181
- break;
182
- case 'tokens.balance':
183
- required('token_address');
184
- required('scope');
185
- if (record.scope !== 'any' && record.scope !== 'protocol') {
186
- throw new Error(`--filters: "scope" must be "any" or "protocol"`);
187
- }
188
- // app_id identifies the protocol, so it is required by (and only by)
189
- // scope: "protocol".
190
- if (record.scope === 'protocol') {
191
- required('app_id');
192
- }
193
- else {
194
- forbidden(['app_id']);
195
- }
196
- forbidden(['tag_id']);
197
- break;
198
- case 'labels.value':
199
- required('tag_id');
200
- forbidden(['app_id', 'token_address', 'scope']);
201
- break;
202
- default:
203
- // users.* — a user attribute carries no resource identity.
204
- forbidden(QUALIFIER_KEYS);
205
- }
206
- }
207
159
  /**
208
160
  * Parse and validate the --filters JSON. Ensures it is an array of
209
161
  * `{ field, op, value }` objects carrying a canonical `field` — either
@@ -251,7 +203,7 @@ function parseSearchFilters(raw) {
251
203
  '(a bare name is silently ignored by the API and returns the entire unfiltered dataset)');
252
204
  }
253
205
  }
254
- validateQualifiers(record, field);
206
+ (0, filters_1.validateQualifiers)(record, field);
255
207
  // The balance fields compare numerically; a stringified number is a 400.
256
208
  if (field !== 'labels.value' &&
257
209
  RESOURCE_FILTER_FIELDS.has(field) &&
@@ -275,6 +227,9 @@ function parseSearchFilters(raw) {
275
227
  return parsed;
276
228
  }
277
229
  function searchProfilesRun(options) {
230
+ if (options.timestamp && !options.address) {
231
+ throw new Error('--timestamp requires --address');
232
+ }
278
233
  (0, client_1.requireApiKey)();
279
234
  const client = (0, client_1.createClient)();
280
235
  const params = {};
@@ -282,6 +237,8 @@ function searchProfilesRun(options) {
282
237
  params.address = options.address;
283
238
  if (options.search)
284
239
  params.search = options.search;
240
+ if (options.timestamp)
241
+ params.timestamp = options.timestamp;
285
242
  if (options.page !== undefined)
286
243
  params.page = options.page;
287
244
  if (options.size !== undefined)
@@ -313,6 +270,7 @@ exports.profiles.command('search', {
313
270
  options: incur_1.z.object({
314
271
  address: incur_1.z.string().optional().describe('Filter by wallet address'),
315
272
  search: incur_1.z.string().optional().describe('Free-text search across address and identity fields'),
273
+ timestamp: profileTimestampOption.describe('Return the closest wallet-enrichment snapshot; requires --address'),
316
274
  page: incur_1.z.coerce
317
275
  .number()
318
276
  .int()
@@ -373,6 +331,13 @@ exports.profiles.command('search', {
373
331
  }),
374
332
  examples: [
375
333
  { options: { size: 10 }, description: 'List first 10 profiles' },
334
+ {
335
+ options: {
336
+ address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
337
+ timestamp: '2025-06-21T10:03:00Z',
338
+ },
339
+ description: 'Search the closest stored wallet-enrichment snapshot',
340
+ },
376
341
  {
377
342
  options: { orderBy: 'net_worth_usd', orderDir: 'desc', size: 5 },
378
343
  description: 'Top 5 profiles by net worth',
@@ -417,9 +382,26 @@ exports.profiles.command('search', {
417
382
  },
418
383
  });
419
384
  function buildUpdateProfileBody(options) {
420
- const body = (0, json_1.parseJsonObject)(options.properties, '--properties');
385
+ const body = options.properties
386
+ ? (0, json_1.parseJsonObject)(options.properties, '--properties')
387
+ : {};
388
+ if (options.unset) {
389
+ const keys = options.unset
390
+ .split(',')
391
+ .map((key) => key.trim())
392
+ .filter((key) => key.length > 0);
393
+ if (keys.length === 0) {
394
+ throw new Error('--unset must list at least one property key');
395
+ }
396
+ for (const key of keys) {
397
+ if (key === 'user_id') {
398
+ throw new Error('user_id cannot be unset (it participates in identity stitching)');
399
+ }
400
+ body[key] = null;
401
+ }
402
+ }
421
403
  if (Object.keys(body).length === 0) {
422
- throw new Error('--properties must contain at least one key');
404
+ throw new Error('provide --properties and/or --unset with at least one key');
423
405
  }
424
406
  return body;
425
407
  }
@@ -436,7 +418,12 @@ exports.profiles.command('update', {
436
418
  options: incur_1.z.object({
437
419
  properties: incur_1.z
438
420
  .string()
439
- .describe('JSON object of properties to merge. Allowed keys: user_id, display_name, email, farcaster, discord, twitter, telegram, instagram, website, github, linkedin, facebook, tiktok, youtube, reddit, avatar, description, location, ens, lens, basenames, linea'),
421
+ .optional()
422
+ .describe('JSON object of properties to merge; a null value unsets (deletes) that property. Allowed keys: user_id, display_name, email, farcaster, discord, twitter, telegram, instagram, website, github, linkedin, facebook, tiktok, youtube, reddit, avatar, description, location, ens, lens, basenames, linea'),
423
+ unset: incur_1.z
424
+ .string()
425
+ .optional()
426
+ .describe('Comma-separated property keys to unset (delete), e.g. "email,twitter". Shorthand for null values in --properties. user_id cannot be unset.'),
440
427
  }),
441
428
  examples: [
442
429
  {
@@ -451,8 +438,18 @@ exports.profiles.command('update', {
451
438
  options: { properties: '{"email":"alice@example.com"}' },
452
439
  description: 'Set just the email',
453
440
  },
441
+ {
442
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
443
+ options: { unset: 'email,twitter' },
444
+ description: 'Delete the email and Twitter properties',
445
+ },
446
+ {
447
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
448
+ options: { properties: '{"display_name":"alice.eth"}', unset: 'email' },
449
+ description: 'Set a new display name and delete the email in one call',
450
+ },
454
451
  ],
455
- hint: 'Requires profiles:write scope on your API key. Only the listed keys are accepted; unknown keys are rejected.',
452
+ hint: 'Requires profiles:write scope on your API key. Only the listed keys are accepted; unknown keys are rejected. Deleting a property hides any globally-enriched fallback value too; user_id cannot be unset.',
456
453
  run({ args, options }) {
457
454
  return updateProfileRun(args.address, options);
458
455
  },
@@ -482,7 +479,7 @@ exports.profilesProperties.command('batch', {
482
479
  options: incur_1.z.object({
483
480
  rows: incur_1.z
484
481
  .string()
485
- .describe('JSON array of flat {address,...properties} objects. ENS names are not resolved in batch requests.'),
482
+ .describe('JSON array of flat {address,...properties} objects; a null value unsets (deletes) that property (user_id cannot be unset). ENS names are not resolved in batch requests.'),
486
483
  }),
487
484
  examples: [
488
485
  {
@@ -491,6 +488,12 @@ exports.profilesProperties.command('batch', {
491
488
  },
492
489
  description: 'Batch set display names and emails',
493
490
  },
491
+ {
492
+ options: {
493
+ rows: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","email":null}]',
494
+ },
495
+ description: 'Batch delete emails (null unsets a property)',
496
+ },
494
497
  ],
495
498
  hint: 'Requires profiles:write scope on your API key. Unknown keys are ignored by the API; invalid rows are quarantined.',
496
499
  run({ options }) {
@@ -4,7 +4,7 @@ export declare const DEFAULT_EVENTS_BASE_URL = "https://events.formo.so";
4
4
  export declare function getApiBaseUrl(): string;
5
5
  export declare function getEventsBaseUrl(): string;
6
6
  export interface ApiErrorBody {
7
- error?: {
7
+ error?: string | {
8
8
  code?: string;
9
9
  message?: string;
10
10
  doc_url?: string;
@@ -30,8 +30,14 @@ function getEventsBaseUrl() {
30
30
  function parseApiError(error) {
31
31
  const status = error.response?.status;
32
32
  const body = error.response?.data;
33
- const apiError = body?.error;
34
- const baseMessage = apiError?.message ?? error.message;
33
+ const rawError = body?.error;
34
+ const apiError = rawError && typeof rawError === 'object' ? rawError : undefined;
35
+ const plainMessage = typeof rawError === 'string'
36
+ ? rawError
37
+ : typeof error.response?.data === 'string'
38
+ ? error.response.data
39
+ : undefined;
40
+ const baseMessage = apiError?.message ?? plainMessage ?? error.message;
35
41
  const parts = [];
36
42
  parts.push(apiError?.code ? `[${apiError.code}] ${baseMessage}` : baseMessage);
37
43
  if (apiError?.param)
@@ -4,3 +4,10 @@ export declare function isValuelessFilterOperator(op: unknown): boolean;
4
4
  export declare function isCanonicalFilterValue(value: unknown): boolean;
5
5
  export declare function isEmptyMembershipArray(value: unknown): boolean;
6
6
  export declare function hasTinybirdMembershipDelimiter(value: unknown): boolean;
7
+ export declare const QUALIFIER_KEYS: readonly ["chain_id", "app_id", "token_address", "tag_id", "scope"];
8
+ /**
9
+ * Enforce the per-field qualifier rules, mirroring the API's schema. Sending a
10
+ * qualifier the field does not accept — or omitting a required one — is a 400,
11
+ * so we fail here with a message that names the offending key.
12
+ */
13
+ export declare function validateQualifiers(record: Record<string, unknown>, field: string, path?: string): void;
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CANONICAL_FILTER_OPERATORS = void 0;
3
+ exports.QUALIFIER_KEYS = exports.CANONICAL_FILTER_OPERATORS = void 0;
4
4
  exports.isCanonicalFilterOperator = isCanonicalFilterOperator;
5
5
  exports.isValuelessFilterOperator = isValuelessFilterOperator;
6
6
  exports.isCanonicalFilterValue = isCanonicalFilterValue;
7
7
  exports.isEmptyMembershipArray = isEmptyMembershipArray;
8
8
  exports.hasTinybirdMembershipDelimiter = hasTinybirdMembershipDelimiter;
9
+ exports.validateQualifiers = validateQualifiers;
9
10
  exports.CANONICAL_FILTER_OPERATORS = [
10
11
  'eq',
11
12
  'neq',
@@ -43,3 +44,71 @@ function hasTinybirdMembershipDelimiter(value) {
43
44
  return (Array.isArray(value) &&
44
45
  value.some((item) => typeof item === 'string' && item.includes('|')));
45
46
  }
47
+ exports.QUALIFIER_KEYS = [
48
+ 'chain_id',
49
+ 'app_id',
50
+ 'token_address',
51
+ 'tag_id',
52
+ 'scope',
53
+ ];
54
+ /**
55
+ * Enforce the per-field qualifier rules, mirroring the API's schema. Sending a
56
+ * qualifier the field does not accept — or omitting a required one — is a 400,
57
+ * so we fail here with a message that names the offending key.
58
+ */
59
+ function validateQualifiers(record, field, path = '--filters') {
60
+ for (const key of exports.QUALIFIER_KEYS) {
61
+ if (record[key] !== undefined && (typeof record[key] !== 'string' || record[key] === '')) {
62
+ throw new Error(`${path}.${key} must be a non-empty string`);
63
+ }
64
+ }
65
+ if (record.scope !== undefined && record.scope !== 'any' && record.scope !== 'protocol') {
66
+ throw new Error(`${path}.scope must be any or protocol`);
67
+ }
68
+ const present = (key) => record[key] !== undefined;
69
+ const required = (key) => {
70
+ if (!present(key)) {
71
+ throw new Error(`${path}: "${key}" is required for "${field}"`);
72
+ }
73
+ };
74
+ const forbidden = (keys) => {
75
+ for (const key of keys) {
76
+ if (present(key)) {
77
+ throw new Error(`${path}: "${key}" is not valid for "${field}"`);
78
+ }
79
+ }
80
+ };
81
+ switch (field) {
82
+ case 'chains.balance':
83
+ // chain_id optional — omit it to match any chain.
84
+ forbidden(['app_id', 'token_address', 'tag_id', 'scope']);
85
+ break;
86
+ case 'apps.balance':
87
+ required('app_id');
88
+ forbidden(['token_address', 'tag_id', 'scope']);
89
+ break;
90
+ case 'tokens.balance':
91
+ required('token_address');
92
+ required('scope');
93
+ if (record.scope !== 'any' && record.scope !== 'protocol') {
94
+ throw new Error(`${path}: "scope" must be "any" or "protocol"`);
95
+ }
96
+ // app_id identifies the protocol, so it is required by (and only by)
97
+ // scope: "protocol".
98
+ if (record.scope === 'protocol') {
99
+ required('app_id');
100
+ }
101
+ else {
102
+ forbidden(['app_id']);
103
+ }
104
+ forbidden(['tag_id']);
105
+ break;
106
+ case 'labels.value':
107
+ required('tag_id');
108
+ forbidden(['app_id', 'token_address', 'scope']);
109
+ break;
110
+ default:
111
+ // users.* — a user attribute carries no resource identity.
112
+ forbidden(exports.QUALIFIER_KEYS);
113
+ }
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formo/cli",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "packageManager": "pnpm@11.1.2",
5
5
  "engines": {
6
6
  "node": ">=22.12"
@@ -23,6 +23,10 @@
23
23
  "dist",
24
24
  "README.md"
25
25
  ],
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "provenance": true
29
+ },
26
30
  "scripts": {
27
31
  "build": "tsc",
28
32
  "prepublishOnly": "pnpm build",