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 +15 -0
- package/bin/dev.js +6 -0
- package/bin/run.js +6 -0
- package/dist/cli/commands/feedback.d.ts +16 -0
- package/dist/cli/commands/feedback.js +36 -0
- package/dist/commands/feedback.d.ts +1 -0
- package/dist/commands/feedback.js +1 -0
- package/dist/commands/placements/create.js +4 -0
- package/dist/commands/placements/update.js +4 -0
- package/dist/lib/api-schemas.d.ts +24 -1
- package/dist/lib/placement-audiences.d.ts +3 -1
- package/dist/lib/placement-audiences.js +16 -9
- package/dist/sdk/adapty/feedback/index.d.ts +4 -0
- package/dist/sdk/adapty/feedback/index.js +2 -0
- package/dist/sdk/adapty/feedback/resource.d.ts +6 -0
- package/dist/sdk/adapty/feedback/resource.js +8 -0
- package/dist/sdk/adapty/feedback/send.d.ts +18 -0
- package/dist/sdk/adapty/feedback/send.js +36 -0
- package/dist/sdk/adapty/index.d.ts +4 -0
- package/dist/sdk/adapty/index.js +3 -0
- package/oclif.manifest.json +437 -371
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
16
|
-
return 'content_type is required
|
|
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'
|
|
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'
|
|
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
|
-
|
|
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,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. */
|
package/dist/sdk/adapty/index.js
CHANGED
|
@@ -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),
|