@appeeky/spec 0.0.0-stage → 0.1.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.
Files changed (86) hide show
  1. package/.github/assets/cover.gif +0 -0
  2. package/LICENSE +110 -0
  3. package/README.md +483 -2
  4. package/dist/ads.d.ts +39 -0
  5. package/dist/ads.js +67 -0
  6. package/dist/appeeky-api.d.ts +62 -0
  7. package/dist/appeeky-api.js +240 -0
  8. package/dist/attribution-setup.d.ts +28 -0
  9. package/dist/attribution-setup.js +65 -0
  10. package/dist/attribution.d.ts +121 -0
  11. package/dist/attribution.js +170 -0
  12. package/dist/bin.d.ts +2 -0
  13. package/dist/bin.js +4 -0
  14. package/dist/category.d.ts +71 -0
  15. package/dist/category.js +198 -0
  16. package/dist/cli.d.ts +3 -0
  17. package/dist/cli.js +1387 -0
  18. package/dist/competitors.d.ts +74 -0
  19. package/dist/competitors.js +143 -0
  20. package/dist/control.d.ts +21 -0
  21. package/dist/control.js +53 -0
  22. package/dist/core.d.ts +15 -0
  23. package/dist/core.js +15 -0
  24. package/dist/coverage.d.ts +46 -0
  25. package/dist/coverage.js +115 -0
  26. package/dist/data.d.ts +48 -0
  27. package/dist/data.js +298 -0
  28. package/dist/diff.d.ts +11 -0
  29. package/dist/diff.js +33 -0
  30. package/dist/experiments.d.ts +115 -0
  31. package/dist/experiments.js +85 -0
  32. package/dist/files.d.ts +27 -0
  33. package/dist/files.js +129 -0
  34. package/dist/history.d.ts +43 -0
  35. package/dist/history.js +55 -0
  36. package/dist/index.d.ts +24 -0
  37. package/dist/index.js +24 -0
  38. package/dist/limits.d.ts +15 -0
  39. package/dist/limits.js +30 -0
  40. package/dist/lint.d.ts +16 -0
  41. package/dist/lint.js +159 -0
  42. package/dist/loops.d.ts +37 -0
  43. package/dist/loops.js +197 -0
  44. package/dist/measure.d.ts +40 -0
  45. package/dist/measure.js +159 -0
  46. package/dist/metrics.d.ts +270 -0
  47. package/dist/metrics.js +250 -0
  48. package/dist/output.d.ts +21 -0
  49. package/dist/output.js +79 -0
  50. package/dist/project.d.ts +19 -0
  51. package/dist/project.js +35 -0
  52. package/dist/report.d.ts +5 -0
  53. package/dist/report.js +71 -0
  54. package/dist/reviews.d.ts +130 -0
  55. package/dist/reviews.js +287 -0
  56. package/dist/schema.d.ts +519 -0
  57. package/dist/schema.js +218 -0
  58. package/dist/screenshots.d.ts +23 -0
  59. package/dist/screenshots.js +116 -0
  60. package/dist/setup.d.ts +45 -0
  61. package/dist/setup.js +226 -0
  62. package/dist/storefronts.d.ts +15 -0
  63. package/dist/storefronts.js +89 -0
  64. package/dist/stores/app-store.d.ts +33 -0
  65. package/dist/stores/app-store.js +159 -0
  66. package/dist/stores/google-play.d.ts +13 -0
  67. package/dist/stores/google-play.js +61 -0
  68. package/dist/stores/types.d.ts +34 -0
  69. package/dist/stores/types.js +2 -0
  70. package/dist/suggest.d.ts +73 -0
  71. package/dist/suggest.js +135 -0
  72. package/dist/text.d.ts +18 -0
  73. package/dist/text.js +55 -0
  74. package/dist/transport.d.ts +98 -0
  75. package/dist/transport.js +170 -0
  76. package/dist/verify.d.ts +70 -0
  77. package/dist/verify.js +134 -0
  78. package/dist/visibility.d.ts +149 -0
  79. package/dist/visibility.js +239 -0
  80. package/package.json +76 -4
  81. package/skills/appeeky-spec/SKILL.md +146 -0
  82. package/skills/appeeky-spec-experiments/SKILL.md +83 -0
  83. package/skills/appeeky-spec-localize/SKILL.md +52 -0
  84. package/skills/appeeky-spec-review/SKILL.md +75 -0
  85. package/skills/appeeky-spec-targets/SKILL.md +66 -0
  86. package/skills/appeeky-spec-visibility/SKILL.md +79 -0
package/dist/data.js ADDED
@@ -0,0 +1,298 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { SPEC_DIR } from "./files.js";
5
+ import { rankAppIds } from "./verify.js";
6
+ /**
7
+ * Your app's numbers as plain CSV files an agent can read: the same sources Appeeky
8
+ * Desktop keeps locally (App Store Connect and Google Play analytics, Apple Search Ads,
9
+ * RevenueCat, Meta and TikTok), pulled through the connections in Appeeky.
10
+ *
11
+ * They are private business data, so they go to .appeeky/data/ (gitignored) unless the
12
+ * spec says `data: { commit: true }`, which writes them to appeeky/data/ instead.
13
+ * Pulling again merges by date, so a scheduled pull keeps one growing file per source.
14
+ */
15
+ export function dataDir(project) {
16
+ return project.spec.data.commit ? join(project.root, SPEC_DIR, "data") : join(project.root, ".appeeky", "data");
17
+ }
18
+ function cell(v) {
19
+ if (v === null || v === undefined)
20
+ return "";
21
+ const s = typeof v === "number" ? String(Math.round(v * 10_000) / 10_000) : v;
22
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
23
+ }
24
+ function parseCsv(text) {
25
+ const [head, ...lines] = text.trim().split("\n");
26
+ if (!head)
27
+ return [];
28
+ const cols = head.split(",");
29
+ return lines.map((line) => {
30
+ const vals = [];
31
+ let cur = "";
32
+ let quoted = false;
33
+ for (let i = 0; i < line.length; i++) {
34
+ const ch = line[i];
35
+ if (quoted) {
36
+ if (ch === '"' && line[i + 1] === '"') {
37
+ cur += '"';
38
+ i++;
39
+ }
40
+ else if (ch === '"')
41
+ quoted = false;
42
+ else
43
+ cur += ch;
44
+ }
45
+ else if (ch === '"')
46
+ quoted = true;
47
+ else if (ch === ",") {
48
+ vals.push(cur);
49
+ cur = "";
50
+ }
51
+ else
52
+ cur += ch;
53
+ }
54
+ vals.push(cur);
55
+ return Object.fromEntries(cols.map((c, i) => [c, vals[i] ?? ""]));
56
+ });
57
+ }
58
+ /** Merge rows into a CSV file by key: new rows replace old ones with the same key. */
59
+ export async function mergeCsv(dir, file) {
60
+ const path = join(dir, `${file.name}.csv`);
61
+ const keyOf = (r) => file.key.map((k) => String(r[k] ?? "")).join("|");
62
+ const merged = new Map();
63
+ if (existsSync(path))
64
+ for (const r of parseCsv(await readFile(path, "utf8")))
65
+ merged.set(keyOf(r), r);
66
+ for (const r of file.rows)
67
+ merged.set(keyOf(r), r);
68
+ const rows = [...merged.values()].sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
69
+ await mkdir(dir, { recursive: true });
70
+ await writeFile(path, [file.columns.join(","), ...rows.map((r) => file.columns.map((c) => cell(r[c])).join(","))].join("\n") + "\n");
71
+ const dates = rows.map((r) => String(r.date ?? "")).filter(Boolean).sort();
72
+ return { path, rows: rows.length, from: dates[0], to: dates[dates.length - 1] };
73
+ }
74
+ const iso = (ms) => new Date(ms).toISOString().slice(0, 10);
75
+ /** [from, to] inclusive, split into chunks the API accepts. */
76
+ export function dateChunks(from, to, maxDays = 180) {
77
+ const out = [];
78
+ let start = Date.parse(from);
79
+ const end = Date.parse(to);
80
+ while (start <= end) {
81
+ const stop = Math.min(end, start + (maxDays - 1) * 86_400_000);
82
+ out.push([iso(start), iso(stop)]);
83
+ start = stop + 86_400_000;
84
+ }
85
+ return out;
86
+ }
87
+ /** Every source this spec can read, over the `days` up to yesterday. */
88
+ export async function pullData(api, project, days, now = new Date(), attributionIn) {
89
+ const to = iso(now.getTime() - 86_400_000);
90
+ const from = iso(Date.parse(to) - (Math.max(1, days) - 1) * 86_400_000);
91
+ const ids = rankAppIds(project);
92
+ const files = [];
93
+ const errors = [];
94
+ const attempt = async (source, run) => {
95
+ try {
96
+ await run();
97
+ }
98
+ catch (e) {
99
+ errors.push({ source, message: e.message });
100
+ }
101
+ };
102
+ for (const store of (api ? Object.keys(ids) : [])) {
103
+ if (!api)
104
+ break;
105
+ const appId = ids[store];
106
+ const label = store === "app-store" ? "App Store Connect" : "Google Play";
107
+ await attempt(`${label} analytics`, async () => {
108
+ const storeRows = [];
109
+ const adRows = [];
110
+ const rcRows = [];
111
+ let sources = null;
112
+ let rcScope;
113
+ for (const [a, b] of dateChunks(from, to)) {
114
+ const m = await api.get(`/user/apps/${encodeURIComponent(appId)}/metrics`, { from: a, to: b });
115
+ for (const d of m?.store?.days ?? [])
116
+ storeRows.push({ ...d, date: String(d.date) });
117
+ for (const d of m?.ads?.days ?? [])
118
+ adRows.push({ ...d, currency: m?.ads?.currency });
119
+ for (const d of m?.revenuecat?.days ?? [])
120
+ rcRows.push({ ...d, date: String(d.date) });
121
+ if (m?.sources)
122
+ sources = m.sources;
123
+ rcScope = m?.revenuecat?.scope ?? rcScope;
124
+ }
125
+ if (storeRows.length)
126
+ files.push({
127
+ name: `${store}-analytics`,
128
+ about: `${label}: impressions, product page views, downloads, conversion, proceeds, trials and subscriptions per day`,
129
+ key: ["date"],
130
+ columns: ["date", "impressions", "pageViews", "downloads", "conversionRate", "proceeds", "trials", "subscriptions"],
131
+ rows: storeRows,
132
+ });
133
+ if (sources?.length)
134
+ files.push({
135
+ name: `${store}-traffic-sources`,
136
+ about: `${label}: where product page views came from, ${from} to ${to} (replaced on every pull)`,
137
+ key: ["source"],
138
+ columns: ["source", "impressions", "pageViews", "share"],
139
+ rows: sources,
140
+ });
141
+ if (adRows.length && store === "app-store")
142
+ files.push({
143
+ name: "apple-search-ads",
144
+ about: "Apple Search Ads: spend, impressions, taps and installs per day",
145
+ key: ["date"],
146
+ columns: ["date", "spend", "impressions", "taps", "installs", "currency"],
147
+ rows: adRows,
148
+ });
149
+ if (rcRows.length && !files.some((f) => f.name === "revenuecat"))
150
+ files.push({
151
+ name: "revenuecat",
152
+ about: `RevenueCat: revenue, MRR, trials, new customers, trial conversion, churn and actives per day${rcScope === "project" ? " (the RevenueCat project holds other apps too, so these include them)" : ""}`,
153
+ key: ["date"],
154
+ columns: ["date", "revenue", "mrr", "trials", "newCustomers", "trialConversion", "churn", "actives"],
155
+ rows: rcRows,
156
+ });
157
+ });
158
+ }
159
+ if (api && ids["app-store"]) {
160
+ await attempt("App Store Connect sales", async () => {
161
+ const m = await api.get(`/connect/metrics/apps/${encodeURIComponent(ids["app-store"])}`, { from, to });
162
+ if (m?.daily?.length)
163
+ files.push({
164
+ name: "app-store-sales",
165
+ about: "App Store Connect sales reports: units, revenue, subscriptions, trials and in-app purchases per day",
166
+ key: ["date"],
167
+ columns: ["date", "downloads", "revenue", "subscriptions", "trials", "iap_count"],
168
+ rows: m.daily.map((d) => ({ ...d, date: String(d.metric_date) })),
169
+ });
170
+ if (m?.countries?.length)
171
+ files.push({
172
+ name: "app-store-countries",
173
+ about: `App Store Connect: downloads and revenue by country, ${from} to ${to} (replaced on every pull)`,
174
+ key: ["country"],
175
+ columns: ["country", "downloads", "revenue"],
176
+ rows: m.countries,
177
+ });
178
+ });
179
+ }
180
+ // One set of files per Appeeky Analytics app; with an app per store, the store is in the name.
181
+ const attributionByStore = !attributionIn
182
+ ? []
183
+ : "get" in attributionIn
184
+ ? [["", attributionIn]]
185
+ : [...new Map(Object.entries(attributionIn).map(([store, client]) => [client, store])).entries()].map(([client, store]) => [store, client]);
186
+ for (const [store, attribution] of attributionByStore) {
187
+ const suffix = attributionByStore.length > 1 ? `-${store}` : "";
188
+ await attempt(attribution.label, async () => {
189
+ const summary = await attribution.get("summary", { days: String(Math.min(180, Math.max(1, days))) });
190
+ if (summary?.bySource?.length)
191
+ files.push({
192
+ name: `attribution-sources${suffix}`,
193
+ about: `Installs, trials, payers and revenue by source and campaign, for installs in the last ${summary.days} days (replaced on every pull)`,
194
+ key: ["source", "campaign"],
195
+ columns: ["source", "campaign", "installs", "trials", "payers", "revenueUsd"],
196
+ rows: summary.bySource.map((r) => ({ ...r })),
197
+ });
198
+ for (const dim of ["keyword", "campaign", "country"]) {
199
+ const rows = await attribution.get("breakdown", { dim, range: "30d" });
200
+ const list = Array.isArray(rows) ? rows : (rows?.rows ?? []);
201
+ // Apple Search Ads reports keyword ids, not the words; the words are in your Search Ads account.
202
+ const column = dim === "keyword" ? "keywordId" : dim;
203
+ if (list.length)
204
+ files.push({
205
+ name: `attribution-${dim === "country" ? "countries" : `${dim}s`}${suffix}`,
206
+ about: `Installs, payers and revenue by ${dim === "keyword" ? "Apple Search Ads keyword id" : dim === "campaign" ? "campaign ((none) is organic and other installs without one)" : dim}, last 30 days (replaced on every pull)`,
207
+ key: [column],
208
+ columns: [column, "installs", "payers", "revenueUsd"],
209
+ rows: list.map((r) => ({ [column]: r.key ?? "(none)", installs: r.count, payers: r.payers, revenueUsd: r.revenueUsd })),
210
+ });
211
+ }
212
+ const links = await attribution.get("links", { range: "30d" });
213
+ if (links?.links?.length)
214
+ files.push({
215
+ name: `smart-links${suffix}`,
216
+ about: "Smart links: clicks, installs and revenue per link, last 30 days (replaced on every pull)",
217
+ key: ["slug"],
218
+ columns: ["slug", "name", "campaign", "channel", "clicks", "installs", "revenueUsd"],
219
+ rows: links.links.map((l) => ({ slug: l.link.slug, name: l.link.name, campaign: l.link.campaign, channel: l.link.channel, clicks: l.clicks.total, installs: l.installs, revenueUsd: l.revenueUsd })),
220
+ });
221
+ });
222
+ }
223
+ const meta = project.spec.ads.meta;
224
+ if (api && meta) {
225
+ await attempt("Meta Ads", async () => {
226
+ const rows = [];
227
+ for (const campaign of meta.campaigns) {
228
+ const data = await api.get("/connect/meta-ads/insights", {
229
+ targetType: "campaign",
230
+ targetId: campaign,
231
+ since: from,
232
+ until: to,
233
+ timeIncrement: "1",
234
+ fields: "spend,impressions,clicks,actions,date_start",
235
+ ...(meta.adAccount ? { adAccountId: meta.adAccount } : {}),
236
+ });
237
+ for (const r of data?.data ?? []) {
238
+ const installs = (r.actions ?? []).find((a) => ["mobile_app_install", "app_install", "omni_app_install"].includes(a.action_type));
239
+ rows.push({ date: r.date_start, campaign, spend: Number(r.spend ?? 0), impressions: Number(r.impressions ?? 0), clicks: Number(r.clicks ?? 0), installs: Number(installs?.value ?? 0) });
240
+ }
241
+ }
242
+ if (rows.length)
243
+ files.push({ name: "meta-ads", about: "Meta Ads: spend, impressions, clicks and installs per campaign per day", key: ["date", "campaign"], columns: ["date", "campaign", "spend", "impressions", "clicks", "installs"], rows });
244
+ });
245
+ }
246
+ const tiktok = project.spec.ads.tiktok;
247
+ if (api && tiktok) {
248
+ await attempt("TikTok Ads", async () => {
249
+ const data = await api.get("/connect/tiktok-ads/report", {
250
+ dataLevel: "AUCTION_CAMPAIGN",
251
+ dimensions: "campaign_id,stat_time_day",
252
+ metrics: "spend,impressions,clicks,conversion",
253
+ startDate: from,
254
+ endDate: to,
255
+ pageSize: "1000",
256
+ ...(tiktok.advertiser ? { advertiserId: tiktok.advertiser } : {}),
257
+ });
258
+ const rows = (data?.list ?? [])
259
+ .filter((r) => r.dimensions?.campaign_id && tiktok.campaigns.includes(String(r.dimensions.campaign_id)))
260
+ .map((r) => ({
261
+ date: String(r.dimensions?.stat_time_day ?? "").slice(0, 10),
262
+ campaign: String(r.dimensions?.campaign_id),
263
+ spend: Number(r.metrics?.spend ?? 0),
264
+ impressions: Number(r.metrics?.impressions ?? 0),
265
+ clicks: Number(r.metrics?.clicks ?? 0),
266
+ conversions: Number(r.metrics?.conversion ?? 0),
267
+ }));
268
+ if (rows.length)
269
+ files.push({ name: "tiktok-ads", about: "TikTok Ads: spend, impressions, clicks and conversions per campaign per day", key: ["date", "campaign"], columns: ["date", "campaign", "spend", "impressions", "clicks", "conversions"], rows });
270
+ });
271
+ }
272
+ const dir = dataDir(project);
273
+ const written = [];
274
+ for (const f of files) {
275
+ // Range snapshots are replaced, daily series are merged.
276
+ if (!f.key.includes("date") && existsSync(join(dir, `${f.name}.csv`)))
277
+ await writeFile(join(dir, `${f.name}.csv`), "");
278
+ written.push({ name: f.name, about: f.about, ...(await mergeCsv(dir, f)) });
279
+ }
280
+ if (written.length)
281
+ await writeIndex(dir, written, now);
282
+ return { files: written, errors };
283
+ }
284
+ /** README.md next to the files: what each one holds, so an agent knows where to look. */
285
+ async function writeIndex(dir, files, now) {
286
+ const lines = [
287
+ "# App data",
288
+ "",
289
+ `Pulled ${now.toISOString().slice(0, 10)} by \`appeeky-spec data pull\`. Daily files are merged by date on every pull. Do not edit by hand.`,
290
+ "",
291
+ "| file | rows | dates | what it holds |",
292
+ "| --- | --- | --- | --- |",
293
+ ...files.map((f) => `| ${f.name}.csv | ${f.rows} | ${f.from && f.to ? `${f.from} → ${f.to}` : "—"} | ${f.about} |`),
294
+ "",
295
+ ];
296
+ await writeFile(join(dir, "README.md"), lines.join("\n"));
297
+ }
298
+ //# sourceMappingURL=data.js.map
package/dist/diff.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { type Listing, type StoreId, type StoreListings } from "./schema.js";
2
+ import type { FieldChange } from "./stores/types.js";
3
+ /** Stores trim trailing whitespace and normalize line endings, so compare the same way. */
4
+ export declare function normalizeValue(value: string | undefined | null): string | undefined;
5
+ /**
6
+ * Field-level differences for the fields the spec manages. A locale or field
7
+ * that is only in the store is left alone: the spec owns what it lists.
8
+ */
9
+ export declare function diffListings<L extends Listing>(store: StoreId, local: StoreListings<L>, remote: StoreListings<L>): FieldChange[];
10
+ /** The inverse of a set of applied changes: what to push to go back. A field that was empty goes back to "" (cleared). */
11
+ export declare function invertChanges(changes: FieldChange[]): FieldChange[];
package/dist/diff.js ADDED
@@ -0,0 +1,33 @@
1
+ import { fieldsFor } from "./schema.js";
2
+ /** Stores trim trailing whitespace and normalize line endings, so compare the same way. */
3
+ export function normalizeValue(value) {
4
+ if (value === undefined || value === null)
5
+ return undefined;
6
+ return value.replace(/\r\n?/g, "\n").replace(/[ \t]+$/gm, "").trimEnd();
7
+ }
8
+ /**
9
+ * Field-level differences for the fields the spec manages. A locale or field
10
+ * that is only in the store is left alone: the spec owns what it lists.
11
+ */
12
+ export function diffListings(store, local, remote) {
13
+ const changes = [];
14
+ for (const [locale, listing] of Object.entries(local)) {
15
+ const live = remote[locale];
16
+ for (const field of fieldsFor(store)) {
17
+ const after = normalizeValue(listing[field]);
18
+ if (after === undefined)
19
+ continue;
20
+ const before = normalizeValue(live?.[field]);
21
+ // "" in the spec clears the field; empty and missing are the same.
22
+ if ((before ?? "") === after)
23
+ continue;
24
+ changes.push({ store, locale, field, before, after, newLocale: live === undefined });
25
+ }
26
+ }
27
+ return changes;
28
+ }
29
+ /** The inverse of a set of applied changes: what to push to go back. A field that was empty goes back to "" (cleared). */
30
+ export function invertChanges(changes) {
31
+ return changes.map((c) => ({ ...c, before: c.after, after: c.before ?? "", newLocale: false, blocked: undefined }));
32
+ }
33
+ //# sourceMappingURL=diff.js.map
@@ -0,0 +1,115 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Anything you try to grow the app that is not a listing push: screenshots,
4
+ * a UGC hook, an ad creative, a price. Same shape as a release: a hypothesis,
5
+ * one metric, a baseline, a date to check, and a result.
6
+ * Listing pushes are experiments already (their release file holds the result).
7
+ */
8
+ export declare const experimentSchema: z.ZodObject<{
9
+ id: z.ZodString;
10
+ type: z.ZodEnum<["screenshots", "ugc", "ads", "pricing", "other"]>;
11
+ hypothesis: z.ZodString;
12
+ /** Persona id from `personas:` in appeeky.spec.yaml. */
13
+ persona: z.ZodOptional<z.ZodString>;
14
+ store: z.ZodOptional<z.ZodEnum<["app-store", "google-play"]>>;
15
+ country: z.ZodOptional<z.ZodString>;
16
+ metric: z.ZodString;
17
+ baseline: z.ZodOptional<z.ZodNumber>;
18
+ /** Value that counts as a win, e.g. 2.5 for "install_per_1k_views > 2.5". */
19
+ target: z.ZodOptional<z.ZodNumber>;
20
+ startedAt: z.ZodString;
21
+ checkAfterDays: z.ZodDefault<z.ZodNumber>;
22
+ status: z.ZodDefault<z.ZodEnum<["running", "won", "lost", "inconclusive"]>>;
23
+ result: z.ZodOptional<z.ZodObject<{
24
+ value: z.ZodOptional<z.ZodNumber>;
25
+ note: z.ZodOptional<z.ZodString>;
26
+ closedAt: z.ZodString;
27
+ }, "strip", z.ZodTypeAny, {
28
+ closedAt: string;
29
+ value?: number | undefined;
30
+ note?: string | undefined;
31
+ }, {
32
+ closedAt: string;
33
+ value?: number | undefined;
34
+ note?: string | undefined;
35
+ }>>;
36
+ /** The smart link that carries the experiment's traffic, so its installs and revenue are counted exactly. */
37
+ link: z.ZodOptional<z.ZodObject<{
38
+ slug: z.ZodString;
39
+ url: z.ZodOptional<z.ZodString>;
40
+ store: z.ZodOptional<z.ZodEnum<["app-store", "google-play"]>>;
41
+ }, "strict", z.ZodTypeAny, {
42
+ slug: string;
43
+ url?: string | undefined;
44
+ store?: "app-store" | "google-play" | undefined;
45
+ }, {
46
+ slug: string;
47
+ url?: string | undefined;
48
+ store?: "app-store" | "google-play" | undefined;
49
+ }>>;
50
+ /** Links, file paths, ad ids: whatever ran. */
51
+ assets: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
52
+ }, "strict", z.ZodTypeAny, {
53
+ type: "ads" | "screenshots" | "ugc" | "pricing" | "other";
54
+ status: "won" | "lost" | "running" | "inconclusive";
55
+ id: string;
56
+ hypothesis: string;
57
+ metric: string;
58
+ startedAt: string;
59
+ checkAfterDays: number;
60
+ assets: string[];
61
+ store?: "app-store" | "google-play" | undefined;
62
+ link?: {
63
+ slug: string;
64
+ url?: string | undefined;
65
+ store?: "app-store" | "google-play" | undefined;
66
+ } | undefined;
67
+ country?: string | undefined;
68
+ result?: {
69
+ closedAt: string;
70
+ value?: number | undefined;
71
+ note?: string | undefined;
72
+ } | undefined;
73
+ persona?: string | undefined;
74
+ baseline?: number | undefined;
75
+ target?: number | undefined;
76
+ }, {
77
+ type: "ads" | "screenshots" | "ugc" | "pricing" | "other";
78
+ id: string;
79
+ hypothesis: string;
80
+ metric: string;
81
+ startedAt: string;
82
+ status?: "won" | "lost" | "running" | "inconclusive" | undefined;
83
+ store?: "app-store" | "google-play" | undefined;
84
+ link?: {
85
+ slug: string;
86
+ url?: string | undefined;
87
+ store?: "app-store" | "google-play" | undefined;
88
+ } | undefined;
89
+ country?: string | undefined;
90
+ result?: {
91
+ closedAt: string;
92
+ value?: number | undefined;
93
+ note?: string | undefined;
94
+ } | undefined;
95
+ persona?: string | undefined;
96
+ baseline?: number | undefined;
97
+ target?: number | undefined;
98
+ checkAfterDays?: number | undefined;
99
+ assets?: string[] | undefined;
100
+ }>;
101
+ export type Experiment = z.infer<typeof experimentSchema>;
102
+ export type ExperimentInput = z.input<typeof experimentSchema>;
103
+ export declare function experimentsDir(root: string): string;
104
+ export declare function readExperiments(root: string): Promise<Experiment[]>;
105
+ export declare function writeExperiment(root: string, input: ExperimentInput): Promise<Experiment>;
106
+ export declare function experimentId(date: Date, hypothesis: string): string;
107
+ /** Experiments whose check date has passed and that have no result yet. */
108
+ export declare function dueExperiments(experiments: Experiment[], now?: Date): Experiment[];
109
+ /**
110
+ * Close with a measured value. With a `target`, reaching it wins. Without
111
+ * one, a known metric (conversion, revenue, cpa, ...) is judged against the
112
+ * baseline: a 5% move the right way wins, the wrong way loses. Anything else
113
+ * is inconclusive.
114
+ */
115
+ export declare function closeExperiment(e: Experiment, value: number | undefined, note: string | undefined, now?: Date): Experiment;
@@ -0,0 +1,85 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import YAML from "yaml";
5
+ import { z } from "zod";
6
+ import { SPEC_DIR, SpecError } from "./files.js";
7
+ import { isMetricName, outcomeOf } from "./metrics.js";
8
+ /**
9
+ * Anything you try to grow the app that is not a listing push: screenshots,
10
+ * a UGC hook, an ad creative, a price. Same shape as a release: a hypothesis,
11
+ * one metric, a baseline, a date to check, and a result.
12
+ * Listing pushes are experiments already (their release file holds the result).
13
+ */
14
+ export const experimentSchema = z
15
+ .object({
16
+ id: z.string().regex(/^[a-z0-9-]+$/),
17
+ type: z.enum(["screenshots", "ugc", "ads", "pricing", "other"]),
18
+ hypothesis: z.string().min(1),
19
+ /** Persona id from `personas:` in appeeky.spec.yaml. */
20
+ persona: z.string().optional(),
21
+ store: z.enum(["app-store", "google-play"]).optional(),
22
+ country: z.string().optional(),
23
+ metric: z.string().min(1),
24
+ baseline: z.number().optional(),
25
+ /** Value that counts as a win, e.g. 2.5 for "install_per_1k_views > 2.5". */
26
+ target: z.number().optional(),
27
+ startedAt: z.string(),
28
+ checkAfterDays: z.number().int().positive().default(14),
29
+ status: z.enum(["running", "won", "lost", "inconclusive"]).default("running"),
30
+ result: z.object({ value: z.number().optional(), note: z.string().optional(), closedAt: z.string() }).optional(),
31
+ /** The smart link that carries the experiment's traffic, so its installs and revenue are counted exactly. */
32
+ link: z.object({ slug: z.string(), url: z.string().optional(), store: z.enum(["app-store", "google-play"]).optional() }).strict().optional(),
33
+ /** Links, file paths, ad ids: whatever ran. */
34
+ assets: z.array(z.string()).default([]),
35
+ })
36
+ .strict();
37
+ export function experimentsDir(root) {
38
+ return join(root, SPEC_DIR, "experiments");
39
+ }
40
+ export async function readExperiments(root) {
41
+ const dir = experimentsDir(root);
42
+ if (!existsSync(dir))
43
+ return [];
44
+ const out = [];
45
+ for (const f of (await readdir(dir)).filter((f) => f.endsWith(".yaml")).sort().reverse()) {
46
+ const parsed = experimentSchema.safeParse(YAML.parse(await readFile(join(dir, f), "utf8")));
47
+ if (!parsed.success)
48
+ throw new SpecError(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), join(dir, f));
49
+ out.push(parsed.data);
50
+ }
51
+ return out;
52
+ }
53
+ export async function writeExperiment(root, input) {
54
+ const experiment = experimentSchema.parse(input);
55
+ await mkdir(experimentsDir(root), { recursive: true });
56
+ await writeFile(join(experimentsDir(root), `${experiment.id}.yaml`), YAML.stringify(experiment, { lineWidth: 0 }));
57
+ return experiment;
58
+ }
59
+ export function experimentId(date, hypothesis) {
60
+ const slug = hypothesis.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "experiment";
61
+ return `${date.toISOString().slice(0, 10)}-${slug}`;
62
+ }
63
+ /** Experiments whose check date has passed and that have no result yet. */
64
+ export function dueExperiments(experiments, now = new Date()) {
65
+ return experiments.filter((e) => e.status === "running" && Date.parse(e.startedAt) + e.checkAfterDays * 86_400_000 <= now.getTime());
66
+ }
67
+ /**
68
+ * Close with a measured value. With a `target`, reaching it wins. Without
69
+ * one, a known metric (conversion, revenue, cpa, ...) is judged against the
70
+ * baseline: a 5% move the right way wins, the wrong way loses. Anything else
71
+ * is inconclusive.
72
+ */
73
+ export function closeExperiment(e, value, note, now = new Date()) {
74
+ let status = "inconclusive";
75
+ if (value !== undefined && e.target !== undefined) {
76
+ const lowerIsBetter = isMetricName(e.metric) && outcomeOf(e.metric, -1) === "better";
77
+ status = (lowerIsBetter ? value <= e.target : value >= e.target) ? "won" : "lost";
78
+ }
79
+ else if (value !== undefined && e.baseline !== undefined && e.baseline > 0 && isMetricName(e.metric)) {
80
+ const outcome = outcomeOf(e.metric, value / e.baseline - 1);
81
+ status = outcome === "better" ? "won" : outcome === "worse" ? "lost" : "inconclusive";
82
+ }
83
+ return { ...e, status, result: { value, note, closedAt: now.toISOString() } };
84
+ }
85
+ //# sourceMappingURL=experiments.js.map
@@ -0,0 +1,27 @@
1
+ import { type Listing, type Project, type SpecFile, type SpecFileInput, type StoreId } from "./schema.js";
2
+ export declare const SPEC_FILE = "appeeky.spec.yaml";
3
+ export declare const SPEC_DIR = "appeeky";
4
+ export declare class SpecError extends Error {
5
+ readonly file?: string | undefined;
6
+ constructor(message: string, file?: string | undefined);
7
+ }
8
+ export declare function listingsDir(root: string, store: StoreId): string;
9
+ export declare function listingPath(root: string, store: StoreId, locale: string): string;
10
+ /** Walk up from `start` to the folder that holds `appeeky.spec.yaml`. */
11
+ export declare function findRoot(start?: string): string | undefined;
12
+ export declare function parseSpec(raw: unknown, file?: string): SpecFile;
13
+ export declare function parseListing(store: StoreId, raw: unknown, file: string): Listing;
14
+ /** Load the spec and every listing file under `appeeky/store/`. */
15
+ export declare function loadProject(start?: string): Promise<Project>;
16
+ /** Keep the store's field order and use block scalars for multi-line text so diffs stay readable. */
17
+ export declare function stringifyListing(store: StoreId, listing: Listing): string;
18
+ export declare function writeListing(root: string, store: StoreId, locale: string, listing: Listing): Promise<string>;
19
+ export declare function stringifySpec(spec: SpecFileInput): string;
20
+ export declare function writeSpec(root: string, spec: SpecFileInput): Promise<string>;
21
+ /** Where each store's listing files were last pulled from, e.g. "version 3.0.1 (prepare for submission)". */
22
+ export type PullState = Partial<Record<StoreId, {
23
+ target: string;
24
+ at: string;
25
+ }>>;
26
+ export declare function readPullState(root: string): Promise<PullState>;
27
+ export declare function recordPull(root: string, store: StoreId, target: string): Promise<void>;