adapty 0.9.5 → 0.11.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
@@ -293,6 +293,21 @@ When several agents are found, the command asks which to install into; with no o
293
293
  `npx skills add adaptyteam/adapty-skills --all --global`, which covers the other agents. Restart an agent to
294
294
  load the skills. Exit **1** means an install failed.
295
295
 
296
+ ### Feedback
297
+
298
+ A short report to Adapty on how a task with the CLI or an Adapty skill went. Leave out secrets and personal
299
+ data. There is no `--app`: put app, flow or other ids into the message when they matter:
300
+
301
+ ```sh
302
+ adapty feedback --sentiment positive --message "Imported the paywall from Canva in one pass"
303
+ adapty feedback --sentiment negative --message "flows config update rejected a valid button twice" \
304
+ --feature flow-generator --context "editing the paywall screen"
305
+ ```
306
+
307
+ `--sentiment` is `positive`, `neutral` or `negative`. `--feature` takes letters, digits, `.`, `_` and `-`;
308
+ Adapty records `unknown` when it is left out. The CLI won't send a report containing something that looks like
309
+ an API key, a bearer token, a JWT or an email address; it exits 2 and names the flag to fix.
310
+
296
311
  ### Apple Search Ads
297
312
 
298
313
  Apple Search Ads commands live under `adapty asa` and talk to the ASA service rather than the Developer
package/bin/dev.js CHANGED
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env -S node --loader ts-node/esm --disable-warning=ExperimentalWarning
2
2
 
3
+ import net from 'node:net';
4
+
3
5
  import { execute } from '@oclif/core';
4
6
 
7
+ // Node abandons every address but the last after 250ms. On a slow link the working address is
8
+ // dropped and the last one, e.g. IPv6 with no route, fails. Never lower a user-set value.
9
+ net.setDefaultAutoSelectFamilyAttemptTimeout(Math.max(net.getDefaultAutoSelectFamilyAttemptTimeout(), 1000));
10
+
5
11
  await execute({ development: true, dir: import.meta.url });
package/bin/run.js CHANGED
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import net from 'node:net';
4
+
3
5
  import { execute } from '@oclif/core';
4
6
 
7
+ // Node abandons every address but the last after 250ms. On a slow link the working address is
8
+ // dropped and the last one, e.g. IPv6 with no route, fails. Never lower a user-set value.
9
+ net.setDefaultAutoSelectFamilyAttemptTimeout(Math.max(net.getDefaultAutoSelectFamilyAttemptTimeout(), 1000));
10
+
5
11
  await execute({ dir: import.meta.url });
@@ -0,0 +1,16 @@
1
+ import { AdaptyCommand } from '../base/adapty/index.js';
2
+ type Result = {
3
+ status: 'sent';
4
+ };
5
+ export default class Feedback extends AdaptyCommand {
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ sentiment: import("@oclif/core/interfaces").OptionFlag<"positive" | "neutral" | "negative", import("@oclif/core/interfaces").CustomOptions>;
10
+ message: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ feature: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ context: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ };
14
+ run(): Promise<Result>;
15
+ }
16
+ export {};
@@ -0,0 +1,36 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { validateSendFeedback } from '../../sdk/adapty/feedback/index.js';
3
+ import { sentiments } from '../../sdk/adapty/index.js';
4
+ import { assertValid } from '../../sdk/core/validation.js';
5
+ import { AdaptyCommand } from '../base/adapty/index.js';
6
+ export default class Feedback extends AdaptyCommand {
7
+ static description = [
8
+ 'Send a short report on how a task with the Adapty CLI or an Adapty skill went.',
9
+ 'Meant for AI agents: say what worked, what failed or confused you. Put app, flow and other ids into the message if they matter.',
10
+ 'Leave out secrets and personal data.',
11
+ ].join('\n');
12
+ static examples = [
13
+ '<%= config.bin %> feedback --sentiment negative --feature flow-generator --message "flows config update rejected a valid button component twice"',
14
+ '<%= config.bin %> feedback --sentiment positive --message "Imported the paywall from Canva in one pass" --context "Building an onboarding flow from a Canva file"',
15
+ ];
16
+ static flags = {
17
+ sentiment: Flags.option({ description: 'How the task went', options: sentiments, required: true })(),
18
+ message: Flags.string({ description: 'The report: a few sentences on what happened', required: true }),
19
+ feature: Flags.string({ description: 'Feature or skill the report comes from; Adapty records "unknown" when omitted' }),
20
+ context: Flags.string({ description: 'What you were doing at that moment' }),
21
+ };
22
+ async run() {
23
+ const { flags } = await this.parse(Feedback);
24
+ const input = {
25
+ context: flags.context,
26
+ feature: flags.feature,
27
+ message: flags.message,
28
+ sentiment: flags.sentiment,
29
+ };
30
+ assertValid(validateSendFeedback(input));
31
+ await this.adapty.feedback.send(input);
32
+ const result = { status: 'sent' };
33
+ this.render(result, () => 'Feedback sent. Thank you!');
34
+ return result;
35
+ }
36
+ }
@@ -0,0 +1 @@
1
+ export { default } from '../cli/commands/feedback.js';
@@ -0,0 +1 @@
1
+ export { default } from '../cli/commands/feedback.js';
@@ -18,6 +18,10 @@ export default class PlacementsCreate extends Command {
18
18
  description: 'JSON array of audience entries. Every entry needs an explicit content_type. '
19
19
  + 'Paywall: {content_type:"paywall", segment_ids, paywall_id, priority}. '
20
20
  + 'Flow: {content_type:"flow", segment_ids, flow_id, priority}. '
21
+ + 'A/B test (as `placements get` shows it): {content_type:"ab_test" | "flow_ab_test", ab_test_id, segment_ids, priority}; '
22
+ + 'crossplacement entries ("cross_ab_test", "flow_cross_ab_test") are read-only, the API keeps the stored ones. '
23
+ + 'Write the entries of `placements get` back unchanged to keep a running test: a write that removes or replaces '
24
+ + 'an A/B test entry stops that test with no winner. '
21
25
  + 'A flow must be published first (flows publish) — attaching a draft flow returns 400.',
22
26
  exactlyOne: ['paywall-id', 'audiences'],
23
27
  }),
@@ -22,6 +22,10 @@ export default class PlacementsUpdate extends Command {
22
22
  description: 'JSON array of audience entries. Every entry needs an explicit content_type. '
23
23
  + 'Paywall: {content_type:"paywall", segment_ids, paywall_id, priority}. '
24
24
  + 'Flow: {content_type:"flow", segment_ids, flow_id, priority}. '
25
+ + 'A/B test (as `placements get` shows it): {content_type:"ab_test" | "flow_ab_test", ab_test_id, segment_ids, priority}; '
26
+ + 'crossplacement entries ("cross_ab_test", "flow_cross_ab_test") are read-only, the API keeps the stored ones. '
27
+ + 'Write the entries of `placements get` back unchanged to keep a running test: a write that removes or replaces '
28
+ + 'an A/B test entry stops that test with no winner. '
25
29
  + 'A flow must be published first (flows publish) — attaching a draft flow returns 400.',
26
30
  exactlyOne: ['paywall-id', 'audiences'],
27
31
  }),
@@ -173,7 +173,30 @@ export type PlacementFlowAudienceEntryDTO = {
173
173
  priority: number;
174
174
  segment_ids?: string[];
175
175
  };
176
- export type PlacementAudienceEntryDTO = PlacementFlowAudienceEntryDTO | PlacementPaywallAudienceEntryDTO;
176
+ /**
177
+ * An audience that runs a regular A/B test: the running content of the audience is the test.
178
+ * `ab_test_title` is read-only: the API ignores it on a write. Writing the same `ab_test_id` back
179
+ * on the same segments keeps the test running; any other id or segment set starts or stops a test.
180
+ */
181
+ export type PlacementABTestAudienceEntryDTO = {
182
+ ab_test_id: string;
183
+ ab_test_title?: string;
184
+ content_type: 'ab_test' | 'flow_ab_test';
185
+ priority: number;
186
+ segment_ids?: string[];
187
+ };
188
+ /**
189
+ * An audience that shows a crossplacement A/B test (`ab_test_id` is the parent test). Read-only:
190
+ * the API ignores these entries on a write and keeps the stored ones.
191
+ */
192
+ export type PlacementCrossABTestAudienceEntryDTO = {
193
+ ab_test_id: string;
194
+ ab_test_title?: string;
195
+ content_type: 'cross_ab_test' | 'flow_cross_ab_test';
196
+ priority: number;
197
+ segment_ids?: string[];
198
+ };
199
+ export type PlacementAudienceEntryDTO = PlacementABTestAudienceEntryDTO | PlacementCrossABTestAudienceEntryDTO | PlacementFlowAudienceEntryDTO | PlacementPaywallAudienceEntryDTO;
177
200
  export type PlacementSummaryDTO = {
178
201
  developer_id: string;
179
202
  id: string;
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Returns a human-readable problem string when `entry` is not a valid placement
3
3
  * audience entry, or `null` when it is well-formed. Every entry must carry an
4
- * explicit `content_type` ('paywall' | 'flow'); there is no implicit paywall.
4
+ * explicit `content_type`; there is no implicit paywall. A kind that this CLI does
5
+ * not know is refused with an "update the CLI" text, never with a text that invites
6
+ * deleting the entry: a write without a running A/B test entry stops that test.
5
7
  */
6
8
  export declare function audienceEntryProblem(entry: unknown): null | string;
@@ -2,24 +2,31 @@
2
2
  // The DTO union in api-schemas.ts requires content_type, but a JSON.parse gives
3
3
  // back `unknown`, so the type alone cannot enforce it — this does, the same way
4
4
  // for humans and agents (no prompting).
5
+ /** The A/B test audience kinds: regular and crossplacement, paywall and flow. Each needs `ab_test_id`. */
6
+ const A_B_TEST_KINDS = new Set(['ab_test', 'cross_ab_test', 'flow_ab_test', 'flow_cross_ab_test']);
5
7
  /**
6
8
  * Returns a human-readable problem string when `entry` is not a valid placement
7
9
  * audience entry, or `null` when it is well-formed. Every entry must carry an
8
- * explicit `content_type` ('paywall' | 'flow'); there is no implicit paywall.
10
+ * explicit `content_type`; there is no implicit paywall. A kind that this CLI does
11
+ * not know is refused with an "update the CLI" text, never with a text that invites
12
+ * deleting the entry: a write without a running A/B test entry stops that test.
9
13
  */
10
14
  export function audienceEntryProblem(entry) {
11
15
  if (typeof entry !== 'object' || entry === null) {
12
16
  return 'each entry must be a JSON object';
13
17
  }
14
- const { content_type: contentType, flow_id: flowId, paywall_id: paywallId } = entry;
15
- if (contentType !== 'flow' && contentType !== 'paywall') {
16
- return 'content_type is required and must be "paywall" or "flow"';
18
+ const { ab_test_id: abTestId, content_type: contentType, flow_id: flowId, paywall_id: paywallId, } = entry;
19
+ if (typeof contentType !== 'string') {
20
+ return 'content_type is required: "paywall", "flow", "ab_test", "flow_ab_test", "cross_ab_test" or "flow_cross_ab_test"';
17
21
  }
18
- if (contentType === 'paywall' && typeof paywallId !== 'string') {
19
- return 'a paywall entry requires paywall_id';
22
+ if (contentType === 'paywall') {
23
+ return typeof paywallId === 'string' ? null : 'a paywall entry requires paywall_id';
20
24
  }
21
- if (contentType === 'flow' && typeof flowId !== 'string') {
22
- return 'a flow entry requires flow_id';
25
+ if (contentType === 'flow') {
26
+ return typeof flowId === 'string' ? null : 'a flow entry requires flow_id';
23
27
  }
24
- return null;
28
+ if (!A_B_TEST_KINDS.has(contentType)) {
29
+ return 'this CLI does not know this audience kind; update the CLI';
30
+ }
31
+ return typeof abTestId === 'string' ? null : `an entry with content_type "${contentType}" requires ab_test_id`;
25
32
  }
@@ -0,0 +1,4 @@
1
+ export { feedback } from './resource.js';
2
+ export { sentiments, validateSendFeedback } from './send.js';
3
+ export type { FeedbackApi } from './resource.js';
4
+ export type { SendFeedbackInput, Sentiment } from './send.js';
@@ -0,0 +1,2 @@
1
+ export { feedback } from './resource.js';
2
+ export { sentiments, validateSendFeedback } from './send.js';
@@ -0,0 +1,6 @@
1
+ import type { SendFeedbackInput } from './send.js';
2
+ import type { Http } from '../../core/http/index.js';
3
+ export declare const feedback: (http: Http) => {
4
+ send: (input: SendFeedbackInput) => Promise<void>;
5
+ };
6
+ export type FeedbackApi = ReturnType<typeof feedback>;
@@ -0,0 +1,8 @@
1
+ import { assertValid } from '../../core/validation.js';
2
+ import { toSendRequest, validateSendFeedback } from './send.js';
3
+ export const feedback = (http) => ({
4
+ send: async (input) => {
5
+ assertValid(validateSendFeedback(input));
6
+ await http.post('/feedback', toSendRequest(input));
7
+ },
8
+ });
@@ -0,0 +1,18 @@
1
+ import type { Issue } from '../../core/errors.js';
2
+ export declare const sentiments: readonly ["positive", "neutral", "negative"];
3
+ export type Sentiment = (typeof sentiments)[number];
4
+ export type SendFeedbackInput = {
5
+ context?: string | undefined;
6
+ feature?: string | undefined;
7
+ message: string;
8
+ sentiment: Sentiment;
9
+ };
10
+ type SendFeedbackRequest = {
11
+ context?: string;
12
+ feature?: string;
13
+ message: string;
14
+ sentiment: Sentiment;
15
+ };
16
+ export declare const validateSendFeedback: (input: SendFeedbackInput) => Issue[];
17
+ export declare const toSendRequest: (input: SendFeedbackInput) => SendFeedbackRequest;
18
+ export {};
@@ -0,0 +1,36 @@
1
+ export const sentiments = ['positive', 'neutral', 'negative'];
2
+ // A coarse first pass: the server scrubs reports more thoroughly.
3
+ const secrets = [
4
+ { kind: 'an API key or token', pattern: /(?<![a-z\d])(?:dev|secret|sk|pk|rk)_(?:live|test)_(?=[a-z\d]{0,64}\d)[a-z\d]{6,}/i },
5
+ { kind: 'a bearer token', pattern: /\bbearer\s+(?=[\w.~+-]{0,64}\d)[\w.~+-]{16,}/i },
6
+ { kind: 'a JWT', pattern: /(?<![\w-])eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]+/ },
7
+ // Lookbehinds and bounded repeats keep matching linear on a long pasted log.
8
+ { kind: 'an email address', pattern: /(?<![\w.%+-])(?!git@)[\w.%+-]{1,64}@[a-z][a-z\d-]*(?:\.[a-z\d-]+)*\.[a-z]{2,}\b/i },
9
+ ];
10
+ const checkSecrets = (issues, path, value) => {
11
+ if (value === undefined) {
12
+ return;
13
+ }
14
+ for (const { kind, pattern } of secrets) {
15
+ if (pattern.test(value)) {
16
+ issues.push({ message: `looks like it contains ${kind}; remove it and send again`, path });
17
+ }
18
+ }
19
+ };
20
+ export const validateSendFeedback = (input) => {
21
+ const issues = [];
22
+ checkSecrets(issues, 'message', input.message);
23
+ checkSecrets(issues, 'context', input.context);
24
+ checkSecrets(issues, 'feature', input.feature);
25
+ return issues;
26
+ };
27
+ export const toSendRequest = (input) => {
28
+ const body = { message: input.message, sentiment: input.sentiment };
29
+ if (input.feature !== undefined) {
30
+ body.feature = input.feature;
31
+ }
32
+ if (input.context !== undefined) {
33
+ body.context = input.context;
34
+ }
35
+ return body;
36
+ };
@@ -1,14 +1,17 @@
1
1
  import type { AccessLevelsApi } from './access-levels.js';
2
2
  import type { AppsApi } from './apps/index.js';
3
3
  import type { AuthApi } from './auth/index.js';
4
+ import type { FeedbackApi } from './feedback/index.js';
4
5
  import type { MigrationApi } from './migrations/index.js';
5
6
  import type { Clock } from '../core/clock.js';
6
7
  import type { RetryAttempt } from '../core/http/index.js';
7
8
  export { platforms } from './apps/index.js';
8
9
  export { developerErrorParser } from './errors.js';
10
+ export { sentiments } from './feedback/index.js';
9
11
  export type { AccessLevel, AccessLevelList, AccessLevelsApi } from './access-levels.js';
10
12
  export type { AppDetail, AppsApi, AppSummary, CreateAppInput, UpdateAppInput } from './apps/index.js';
11
13
  export type { AuthApi, AuthUser, IssuedToken } from './auth/index.js';
14
+ export type { FeedbackApi, SendFeedbackInput, Sentiment } from './feedback/index.js';
12
15
  export type { Action, ActionKind, AvailableFlow, CreateMigrationInput, Envelope, Issue, JsonSchema, Migration, MigrationApi, MigrationList, MigrationState, Progress, ResourceRef, Step, StepStatus, WaitOptions, WizardError, } from './migrations/index.js';
13
16
  export type { PageParams, Paginated, Pagination } from './pagination.js';
14
17
  /** Exported because the adapter compares the resolved URL with it and warns when they differ. */
@@ -35,6 +38,7 @@ export type Adapty = {
35
38
  accessLevels: AccessLevelsApi;
36
39
  apps: AppsApi;
37
40
  auth: AuthApi;
41
+ feedback: FeedbackApi;
38
42
  migrations: MigrationApi;
39
43
  };
40
44
  /** The assembly point of the developer API: one transport, resources on top of it. */
@@ -4,9 +4,11 @@ import { accessLevels } from './access-levels.js';
4
4
  import { apps } from './apps/index.js';
5
5
  import { auth } from './auth/index.js';
6
6
  import { developerErrorParser } from './errors.js';
7
+ import { feedback } from './feedback/index.js';
7
8
  import { migrations } from './migrations/index.js';
8
9
  export { platforms } from './apps/index.js';
9
10
  export { developerErrorParser } from './errors.js';
11
+ export { sentiments } from './feedback/index.js';
10
12
  /** Exported because the adapter compares the resolved URL with it and warns when they differ. */
11
13
  export const DEFAULT_ADAPTY_API_URL = 'https://api-admin.adapty.io/api/v1/developer';
12
14
  /**
@@ -46,6 +48,7 @@ export const createAdapty = (options = {}) => {
46
48
  accessLevels: accessLevels(http),
47
49
  apps: apps(http),
48
50
  auth: auth(http),
51
+ feedback: feedback(http),
49
52
  // The clock is a dependency here too, not only in the transport: `waitFor` sleeps
50
53
  // between polls, and a test must be able to do that instantly.
51
54
  migrations: migrations(wizard, options.clock ?? systemClock),