@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
@@ -0,0 +1,287 @@
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 { words } from "./text.js";
7
+ import { SPEC_DIR, SpecError } from "./files.js";
8
+ export function reviewsDir(root) {
9
+ return join(root, SPEC_DIR, "reviews");
10
+ }
11
+ const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== null && v !== undefined && v !== ""));
12
+ export function fromAsc(r) {
13
+ return clean({
14
+ id: String(r.id),
15
+ store: "app-store",
16
+ rating: r.rating,
17
+ title: r.title ?? undefined,
18
+ body: r.body ?? "",
19
+ author: r.reviewer_nickname ?? undefined,
20
+ where: r.territory ?? undefined,
21
+ version: r.app_version ?? undefined,
22
+ date: r.review_created_at,
23
+ reply: r.response_body ? clean({ body: r.response_body, date: r.response_last_modified ?? undefined }) : undefined,
24
+ });
25
+ }
26
+ export function fromPlay(r) {
27
+ return clean({
28
+ id: String(r.review_id),
29
+ store: "google-play",
30
+ rating: r.star_rating,
31
+ body: r.review_text ?? "",
32
+ author: r.author_name ?? undefined,
33
+ where: r.reviewer_language ?? undefined,
34
+ version: r.app_version_name ?? undefined,
35
+ date: r.review_last_modified_at,
36
+ reply: r.developer_reply_text ? clean({ body: r.developer_reply_text, date: r.developer_reply_last_modified_at ?? undefined }) : undefined,
37
+ });
38
+ }
39
+ /** Newest reviews first, paging back until `since` (or `max` reviews). */
40
+ export async function fetchReviews(api, store, appId, options = {}) {
41
+ const max = options.max ?? 2000;
42
+ const out = [];
43
+ let before;
44
+ while (out.length < max) {
45
+ const query = { limit: "200", ...(before ? { before } : {}) };
46
+ const page = store === "app-store"
47
+ ? ((await api.get("/connect/reviews", { ...query, appId }))?.reviews ?? []).map(fromAsc)
48
+ : ((await api.get("/connect/google-play/reviews", { ...query, packageName: appId }))?.reviews ?? []).map(fromPlay);
49
+ const fresh = options.since ? page.filter((r) => r.date >= options.since) : page;
50
+ out.push(...fresh);
51
+ if (page.length < 200 || fresh.length < page.length)
52
+ break;
53
+ before = page[page.length - 1].date;
54
+ }
55
+ return out.slice(0, max);
56
+ }
57
+ const monthOf = (iso) => iso.slice(0, 7);
58
+ export async function readReviews(root, store) {
59
+ const out = [];
60
+ for (const s of store ? [store] : ["app-store", "google-play"]) {
61
+ const dir = join(reviewsDir(root), s);
62
+ if (!existsSync(dir))
63
+ continue;
64
+ for (const f of (await readdir(dir)).filter((f) => f.endsWith(".jsonl")).sort()) {
65
+ for (const line of (await readFile(join(dir, f), "utf8")).split("\n")) {
66
+ if (line.trim())
67
+ out.push(JSON.parse(line));
68
+ }
69
+ }
70
+ }
71
+ return out.sort((a, b) => b.date.localeCompare(a.date));
72
+ }
73
+ /**
74
+ * Merge fetched reviews into the monthly files: a review that changed (edited,
75
+ * answered) replaces the old line. Returns how many were new and how many changed.
76
+ */
77
+ export async function saveReviews(root, fetched) {
78
+ const existing = new Map((await readReviews(root)).map((r) => [`${r.store}:${r.id}`, r]));
79
+ let added = 0;
80
+ let updated = 0;
81
+ for (const r of fetched) {
82
+ const key = `${r.store}:${r.id}`;
83
+ const old = existing.get(key);
84
+ if (!old)
85
+ added++;
86
+ else if (JSON.stringify(old) !== JSON.stringify(r))
87
+ updated++;
88
+ existing.set(key, r);
89
+ }
90
+ const files = new Map();
91
+ for (const r of existing.values()) {
92
+ const file = join(reviewsDir(root), r.store, `${monthOf(r.date)}.jsonl`);
93
+ files.set(file, [...(files.get(file) ?? []), r]);
94
+ }
95
+ for (const [file, rows] of files) {
96
+ await mkdir(join(file, ".."), { recursive: true });
97
+ rows.sort((a, b) => b.date.localeCompare(a.date));
98
+ await writeFile(file, rows.map((r) => JSON.stringify(r)).join("\n") + "\n");
99
+ }
100
+ return { added, updated };
101
+ }
102
+ export function reviewStats(reviews) {
103
+ const distribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
104
+ for (const r of reviews)
105
+ if (r.rating >= 1 && r.rating <= 5)
106
+ distribution[r.rating]++;
107
+ const count = reviews.length;
108
+ return {
109
+ count,
110
+ average: count ? reviews.reduce((n, r) => n + r.rating, 0) / count : null,
111
+ low: count ? (distribution[1] + distribution[2]) / count : null,
112
+ unanswered: reviews.filter((r) => !r.reply).length,
113
+ distribution,
114
+ };
115
+ }
116
+ const fmt = (n, digits = 2) => (n === null ? "—" : n.toFixed(digits));
117
+ const pct = (n) => (n === null ? "—" : `${Math.round(n * 100)}%`);
118
+ /** `appeeky/reviews/summary.md`: what users say, in numbers, for people and agents. */
119
+ // Words that say nothing about what users want.
120
+ const STOP = new Set("a an and are as at be but by can do does for from get got has have i if in is it its it's just me my no not of on or so that the this to too was we were what when with would you your app apps very really love like great good nice best please thanks thank use using used one all also only much more can't don't doesn't didn't i'm ive i've it's".split(" "));
121
+ /**
122
+ * What users ask for in their own words: words and two-word phrases that come up in three or
123
+ * more reviews and appear nowhere in your listing. Candidates for targets, or for the product.
124
+ * English stop words only, so other languages show more noise.
125
+ */
126
+ export function reviewPhrases(reviews, listingText, limit = 10) {
127
+ const listing = ` ${words(listingText).join(" ")} `;
128
+ const counts = new Map();
129
+ for (const r of reviews) {
130
+ const ws = words(`${r.title ?? ""} ${r.body}`).filter((w) => w.length > 2);
131
+ const seen = new Set();
132
+ for (let i = 0; i < ws.length; i++) {
133
+ if (!STOP.has(ws[i]))
134
+ seen.add(ws[i]);
135
+ if (i + 1 < ws.length && !STOP.has(ws[i]) && !STOP.has(ws[i + 1]))
136
+ seen.add(`${ws[i]} ${ws[i + 1]}`);
137
+ }
138
+ for (const p of seen) {
139
+ const c = counts.get(p) ?? { reviews: 0, low: 0 };
140
+ c.reviews++;
141
+ if (r.rating <= 3)
142
+ c.low++;
143
+ counts.set(p, c);
144
+ }
145
+ }
146
+ const ranked = [...counts.entries()]
147
+ .filter(([p, c]) => c.reviews >= 3 && !listing.includes(` ${p} `))
148
+ // A phrase beats its own words when it is nearly as common.
149
+ .sort((a, b) => b[1].reviews + (b[0].includes(" ") ? 1 : 0) - (a[1].reviews + (a[0].includes(" ") ? 1 : 0)));
150
+ const out = [];
151
+ for (const [phrase, c] of ranked) {
152
+ if (out.some((o) => o.phrase.split(" ").includes(phrase) && o.reviews >= c.reviews * 0.7))
153
+ continue;
154
+ out.push({ phrase, ...c });
155
+ if (out.length >= limit)
156
+ break;
157
+ }
158
+ return out;
159
+ }
160
+ export function summaryMarkdown(reviews, now = new Date(), listingText = "") {
161
+ const day = 86_400_000;
162
+ const recentFrom = new Date(now.getTime() - 30 * day).toISOString();
163
+ const prevFrom = new Date(now.getTime() - 60 * day).toISOString();
164
+ const lines = ["# Reviews", "", `Pulled ${now.toISOString().slice(0, 10)}. Written by \`appeeky-spec reviews pull\`; do not edit by hand.`, ""];
165
+ for (const store of ["app-store", "google-play"]) {
166
+ const all = reviews.filter((r) => r.store === store);
167
+ if (all.length === 0)
168
+ continue;
169
+ const recent = reviewStats(all.filter((r) => r.date >= recentFrom));
170
+ const prev = reviewStats(all.filter((r) => r.date >= prevFrom && r.date < recentFrom));
171
+ const total = reviewStats(all);
172
+ lines.push(`## ${store === "app-store" ? "App Store" : "Google Play"}`, "");
173
+ lines.push("| | reviews | average | 1–2 stars | unanswered |", "| --- | --- | --- | --- | --- |");
174
+ lines.push(`| last 30 days | ${recent.count} | ${fmt(recent.average)} | ${pct(recent.low)} | ${recent.unanswered} |`);
175
+ lines.push(`| 30 days before | ${prev.count} | ${fmt(prev.average)} | ${pct(prev.low)} | ${prev.unanswered} |`);
176
+ lines.push(`| all pulled | ${total.count} | ${fmt(total.average)} | ${pct(total.low)} | ${total.unanswered} |`, "");
177
+ lines.push(`Stars: ${[5, 4, 3, 2, 1].map((s) => `${s}★ ${total.distribution[s]}`).join(" · ")}`, "");
178
+ const versions = new Map();
179
+ for (const r of all)
180
+ if (r.version)
181
+ versions.set(r.version, [...(versions.get(r.version) ?? []), r]);
182
+ const byVersion = [...versions.entries()].sort((a, b) => (b[1][0].date > a[1][0].date ? 1 : -1)).slice(0, 5);
183
+ if (byVersion.length) {
184
+ lines.push("| version | reviews | average | 1–2 stars |", "| --- | --- | --- | --- |");
185
+ for (const [v, rs] of byVersion) {
186
+ const s = reviewStats(rs);
187
+ lines.push(`| ${v} | ${s.count} | ${fmt(s.average)} | ${pct(s.low)} |`);
188
+ }
189
+ lines.push("");
190
+ }
191
+ const waiting = all.filter((r) => !r.reply && r.rating <= 2).slice(0, 5);
192
+ if (waiting.length) {
193
+ lines.push("Low ratings without a reply:", "");
194
+ for (const r of waiting)
195
+ lines.push(`- \`${r.id}\` ${r.rating}★ ${r.date.slice(0, 10)}${r.where ? ` ${r.where}` : ""}: ${(r.title ? `${r.title}. ` : "") + r.body.replace(/\s+/g, " ").slice(0, 140)}`);
196
+ lines.push("");
197
+ }
198
+ }
199
+ const phrases = listingText ? reviewPhrases(reviews, listingText) : [];
200
+ if (phrases.length) {
201
+ lines.push("## Words users use that your listing does not", "");
202
+ lines.push("Candidates for targets (check their popularity with `suggest`), or things to build. From reviews in both stores.", "");
203
+ lines.push("| words | reviews | of them 1–3 stars |", "| --- | --- | --- |");
204
+ for (const p of phrases)
205
+ lines.push(`| ${p.phrase} | ${p.reviews} | ${p.low} |`);
206
+ lines.push("");
207
+ }
208
+ return lines.join("\n");
209
+ }
210
+ export async function writeSummary(root, now = new Date(), listingText = "") {
211
+ const file = join(reviewsDir(root), "summary.md");
212
+ await mkdir(reviewsDir(root), { recursive: true });
213
+ await writeFile(file, summaryMarkdown(await readReviews(root), now, listingText));
214
+ return file;
215
+ }
216
+ // ── Replies ─────────────────────────────────────────────────────────────────
217
+ export const replySchema = z
218
+ .object({
219
+ id: z.string().min(1),
220
+ store: z.enum(["app-store", "google-play"]),
221
+ /** The review being answered, for whoever reviews the pull request. */
222
+ review: z.string().optional(),
223
+ text: z.string().min(1),
224
+ status: z.enum(["draft", "sent"]).default("draft"),
225
+ sentAt: z.string().optional(),
226
+ })
227
+ .strict();
228
+ export const repliesDir = (root) => join(reviewsDir(root), "replies");
229
+ export async function writeReply(root, reply) {
230
+ const parsed = replySchema.parse(reply);
231
+ await mkdir(repliesDir(root), { recursive: true });
232
+ const file = join(repliesDir(root), `${parsed.store}-${parsed.id}.yaml`);
233
+ await writeFile(file, YAML.stringify(parsed, { lineWidth: 0 }));
234
+ return file;
235
+ }
236
+ export async function readReplies(root) {
237
+ const dir = repliesDir(root);
238
+ if (!existsSync(dir))
239
+ return [];
240
+ const out = [];
241
+ for (const f of (await readdir(dir)).filter((f) => f.endsWith(".yaml")).sort()) {
242
+ const parsed = replySchema.safeParse(YAML.parse(await readFile(join(dir, f), "utf8")));
243
+ if (!parsed.success)
244
+ throw new SpecError(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), join(dir, f));
245
+ out.push({ ...parsed.data, file: join(dir, f) });
246
+ }
247
+ return out;
248
+ }
249
+ /** Store limits for a developer reply. */
250
+ export const REPLY_LIMIT = { "app-store": 5970, "google-play": 350 };
251
+ export function replyIssues(reply) {
252
+ const issues = [];
253
+ if (reply.text.length > REPLY_LIMIT[reply.store])
254
+ issues.push(`${reply.text.length}/${REPLY_LIMIT[reply.store]} characters`);
255
+ return issues;
256
+ }
257
+ /** Send one draft reply to the store and mark it sent. */
258
+ export async function sendReply(api, reply, packageName, now = new Date()) {
259
+ if (reply.store === "app-store") {
260
+ await api.post(`/connect/reviews/${encodeURIComponent(reply.id)}/response`, { responseBody: reply.text });
261
+ }
262
+ else {
263
+ if (!packageName)
264
+ throw new Error("googlePlay.packageName is not set in appeeky.spec.yaml");
265
+ await api.post(`/connect/google-play/apps/${encodeURIComponent(packageName)}/reviews/${encodeURIComponent(reply.id)}/reply`, { replyText: reply.text });
266
+ }
267
+ const { file, ...rest } = reply;
268
+ await writeFile(file, YAML.stringify({ ...rest, status: "sent", sentAt: now.toISOString() }, { lineWidth: 0 }));
269
+ }
270
+ // ── Measuring ───────────────────────────────────────────────────────────────
271
+ /** Ratings around a push, from the pulled review files: no API call. */
272
+ export function reviewMetricSource(root) {
273
+ return {
274
+ label: "Reviews in the repo",
275
+ async window(store, _appId, from, to) {
276
+ const rs = (await readReviews(root, store)).filter((r) => r.date >= from && r.date < to);
277
+ // A handful of reviews says nothing about a change.
278
+ if (rs.length < 5)
279
+ return null;
280
+ const s = reviewStats(rs);
281
+ const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
282
+ const values = { rating: s.average, low_ratings: s.low };
283
+ return { from, to, days, values };
284
+ },
285
+ };
286
+ }
287
+ //# sourceMappingURL=reviews.js.map