@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.
- package/.github/assets/cover.gif +0 -0
- package/LICENSE +110 -0
- package/README.md +483 -2
- package/dist/ads.d.ts +39 -0
- package/dist/ads.js +67 -0
- package/dist/appeeky-api.d.ts +62 -0
- package/dist/appeeky-api.js +240 -0
- package/dist/attribution-setup.d.ts +28 -0
- package/dist/attribution-setup.js +65 -0
- package/dist/attribution.d.ts +121 -0
- package/dist/attribution.js +170 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/category.d.ts +71 -0
- package/dist/category.js +198 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +1387 -0
- package/dist/competitors.d.ts +74 -0
- package/dist/competitors.js +143 -0
- package/dist/control.d.ts +21 -0
- package/dist/control.js +53 -0
- package/dist/core.d.ts +15 -0
- package/dist/core.js +15 -0
- package/dist/coverage.d.ts +46 -0
- package/dist/coverage.js +115 -0
- package/dist/data.d.ts +48 -0
- package/dist/data.js +298 -0
- package/dist/diff.d.ts +11 -0
- package/dist/diff.js +33 -0
- package/dist/experiments.d.ts +115 -0
- package/dist/experiments.js +85 -0
- package/dist/files.d.ts +27 -0
- package/dist/files.js +129 -0
- package/dist/history.d.ts +43 -0
- package/dist/history.js +55 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/limits.d.ts +15 -0
- package/dist/limits.js +30 -0
- package/dist/lint.d.ts +16 -0
- package/dist/lint.js +159 -0
- package/dist/loops.d.ts +37 -0
- package/dist/loops.js +197 -0
- package/dist/measure.d.ts +40 -0
- package/dist/measure.js +159 -0
- package/dist/metrics.d.ts +270 -0
- package/dist/metrics.js +250 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +79 -0
- package/dist/project.d.ts +19 -0
- package/dist/project.js +35 -0
- package/dist/report.d.ts +5 -0
- package/dist/report.js +71 -0
- package/dist/reviews.d.ts +130 -0
- package/dist/reviews.js +287 -0
- package/dist/schema.d.ts +519 -0
- package/dist/schema.js +218 -0
- package/dist/screenshots.d.ts +23 -0
- package/dist/screenshots.js +116 -0
- package/dist/setup.d.ts +45 -0
- package/dist/setup.js +226 -0
- package/dist/storefronts.d.ts +15 -0
- package/dist/storefronts.js +89 -0
- package/dist/stores/app-store.d.ts +33 -0
- package/dist/stores/app-store.js +159 -0
- package/dist/stores/google-play.d.ts +13 -0
- package/dist/stores/google-play.js +61 -0
- package/dist/stores/types.d.ts +34 -0
- package/dist/stores/types.js +2 -0
- package/dist/suggest.d.ts +73 -0
- package/dist/suggest.js +135 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.js +55 -0
- package/dist/transport.d.ts +98 -0
- package/dist/transport.js +170 -0
- package/dist/verify.d.ts +70 -0
- package/dist/verify.js +134 -0
- package/dist/visibility.d.ts +149 -0
- package/dist/visibility.js +239 -0
- package/package.json +76 -4
- package/skills/appeeky-spec/SKILL.md +146 -0
- package/skills/appeeky-spec-experiments/SKILL.md +83 -0
- package/skills/appeeky-spec-localize/SKILL.md +52 -0
- package/skills/appeeky-spec-review/SKILL.md +75 -0
- package/skills/appeeky-spec-targets/SKILL.md +66 -0
- package/skills/appeeky-spec-visibility/SKILL.md +79 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { Command, Option } from "commander";
|
|
7
|
+
import { adsSnapshot, CHANNEL_LABELS, saveAdsSnapshot } from "./ads.js";
|
|
8
|
+
import { attributionClient, attributionClients, attributionMetricSource, createExperimentLink, hasClients, IOS_LINK_NOTE, iosOnlyLink, isClient, linkResult, linkStore } from "./attribution.js";
|
|
9
|
+
import { pullCompetitors, similarApps } from "./competitors.js";
|
|
10
|
+
import { categoryFile, categoryMetricSource, categoryPlans, chartSources, previousRank, readCategoryHistory, readCategoryRanks, saveCategoryRanks } from "./category.js";
|
|
11
|
+
import { applySync, bootstrapIntents, languageFor, planSync, pullVisibility, readRemoteIntents, scanVisibility, visibilityMetricSource, visibilityTarget } from "./visibility.js";
|
|
12
|
+
import { attributionBlock, initHosted, initSelfHosted, nextSteps, specStores } from "./attribution-setup.js";
|
|
13
|
+
import { pullData } from "./data.js";
|
|
14
|
+
import { combineSources } from "./metrics.js";
|
|
15
|
+
import { fetchReviews, readReplies, readReviews, replyIssues, reviewMetricSource, saveReviews, sendReply, writeReply, writeSummary } from "./reviews.js";
|
|
16
|
+
import { AppeekyApi, appeekyRankSource, loadPopularity, metricSourceFor } from "./appeeky-api.js";
|
|
17
|
+
import { closeExperiment, dueExperiments, experimentId, readExperiments, writeExperiment } from "./experiments.js";
|
|
18
|
+
import { appendPlaybook, experimentApp, measureExperiments, playbookLine, verifyReleases } from "./measure.js";
|
|
19
|
+
import { formatChange, isMetricName, METRICS, metricAfter, metricBefore } from "./metrics.js";
|
|
20
|
+
import { loadProjectAtRef, specReport } from "./report.js";
|
|
21
|
+
import { checkScreenshots, pullScreenshots } from "./screenshots.js";
|
|
22
|
+
import { appendKeyword, crossLocalizationSuggestions, keywordSuggestions } from "./suggest.js";
|
|
23
|
+
import { firstRankSource, itunesRankSource, rankAppIds, snapshotRanks } from "./verify.js";
|
|
24
|
+
import { projectCoverage } from "./coverage.js";
|
|
25
|
+
import { invertChanges } from "./diff.js";
|
|
26
|
+
import { loadProject, recordPull, SPEC_DIR, SPEC_FILE, writeListing, writeSpec } from "./files.js";
|
|
27
|
+
import { readReleases, releaseId, releaseMarkdown, writeRelease } from "./history.js";
|
|
28
|
+
import { freeStores, writeLoops } from "./loops.js";
|
|
29
|
+
import { diagnose, findApps, playPackageExists, suggestTargets, targetCandidates, WORKFLOWS } from "./setup.js";
|
|
30
|
+
import { hasErrors, lintProject } from "./lint.js";
|
|
31
|
+
import { c, formatChanges, formatCoverage, formatIssues, printJson, storeLabel } from "./output.js";
|
|
32
|
+
import { configuredStores, planStore, storeHandle } from "./project.js";
|
|
33
|
+
import { STORES } from "./schema.js";
|
|
34
|
+
const round = (v) => (v === undefined ? "?" : Math.abs(v) < 1 ? v.toFixed(3) : v.toFixed(2));
|
|
35
|
+
/** One playbook line for a closed experiment. */
|
|
36
|
+
function experimentPlaybookLine(e) {
|
|
37
|
+
const value = e.result?.value !== undefined ? ` (${e.metric} ${round(e.baseline)} → ${round(e.result.value)})` : "";
|
|
38
|
+
return `- ${e.startedAt.slice(0, 10)}, ${e.type}${e.persona ? `, ${e.persona}` : ""}: ${e.hypothesis} → ${e.status}${value}${e.result?.note ? `. ${e.result.note}` : ""}`;
|
|
39
|
+
}
|
|
40
|
+
import { STORE_CLIS } from "./transport.js";
|
|
41
|
+
const PLAYBOOK = `# Playbook
|
|
42
|
+
|
|
43
|
+
What worked and what did not for this app. Appeeky Spec and your agent read this
|
|
44
|
+
before proposing a change, so keep each entry short and specific:
|
|
45
|
+
|
|
46
|
+
- YYYY-MM-DD, store, locale: what changed → what happened (and how sure we are)
|
|
47
|
+
`;
|
|
48
|
+
/** An account name from the app name: "Sleep Sounds" → "sleep-sounds". */
|
|
49
|
+
const slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "app";
|
|
50
|
+
/** One line of input; Enter gives `fallback`. */
|
|
51
|
+
async function ask(question, fallback = "") {
|
|
52
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
53
|
+
const answer = await rl.question(`${question}${fallback ? c.dim(` (${fallback})`) : ""} `);
|
|
54
|
+
rl.close();
|
|
55
|
+
return answer.trim() || fallback;
|
|
56
|
+
}
|
|
57
|
+
/** A yes/no question that defaults to yes. */
|
|
58
|
+
async function agree(question) {
|
|
59
|
+
return !/^n(o)?$/i.test(await ask(`${question} [Y/n]`));
|
|
60
|
+
}
|
|
61
|
+
/** Keep the private data and cache out of git. */
|
|
62
|
+
async function ignoreLocalData(root) {
|
|
63
|
+
const file = join(root, ".gitignore");
|
|
64
|
+
const current = existsSync(file) ? await readFile(file, "utf8") : "";
|
|
65
|
+
if (/^\.appeeky\/?\s*$/m.test(current))
|
|
66
|
+
return false;
|
|
67
|
+
await writeFile(file, `${current}${current && !current.endsWith("\n") ? "\n" : ""}\n# Appeeky Spec: your analytics, cache and history stay local\n.appeeky/\n`);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
/** Write the GitHub workflows that are not there yet. */
|
|
71
|
+
async function writeWorkflows(root) {
|
|
72
|
+
const dir = join(root, ".github", "workflows");
|
|
73
|
+
await mkdir(dir, { recursive: true });
|
|
74
|
+
const written = [];
|
|
75
|
+
for (const [file, body] of Object.entries(WORKFLOWS)) {
|
|
76
|
+
if (existsSync(join(dir, file)))
|
|
77
|
+
continue;
|
|
78
|
+
await writeFile(join(dir, file), body);
|
|
79
|
+
written.push(`.github/workflows/${file}`);
|
|
80
|
+
}
|
|
81
|
+
return written;
|
|
82
|
+
}
|
|
83
|
+
async function confirm(question) {
|
|
84
|
+
if (!process.stdin.isTTY)
|
|
85
|
+
return false;
|
|
86
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
87
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
88
|
+
rl.close();
|
|
89
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
90
|
+
}
|
|
91
|
+
function storeOption() {
|
|
92
|
+
return new Option("-s, --store <store>", "Only this store").choices([...STORES]);
|
|
93
|
+
}
|
|
94
|
+
export function buildProgram() {
|
|
95
|
+
const program = new Command()
|
|
96
|
+
.name("appeeky-spec")
|
|
97
|
+
.description("Your app's store listing as a spec: check it, measure keyword coverage, diff it with the live store and push it.")
|
|
98
|
+
.version("0.1.0")
|
|
99
|
+
.option("-C, --cwd <dir>", "Run as if started in this folder", process.cwd())
|
|
100
|
+
.option("--json", "Machine-readable output", false)
|
|
101
|
+
.addOption(new Option("--via <mode>", "How to reach the stores (overrides the spec)").choices(["auto", "local", "hosted"]));
|
|
102
|
+
const g = () => program.opts();
|
|
103
|
+
const transportOverrides = () => (g().via ? { via: g().via } : {});
|
|
104
|
+
const load = () => loadProject(g().cwd);
|
|
105
|
+
const api = () => (process.env.APPEEKY_API_KEY ? new AppeekyApi(process.env.APPEEKY_API_KEY, { baseUrl: process.env.APPEEKY_API_URL }) : undefined);
|
|
106
|
+
/** Appeeky API ranks when a key is set (both stores), Apple's public search otherwise (App Store only). */
|
|
107
|
+
const rankSource = () => {
|
|
108
|
+
const a = api();
|
|
109
|
+
return a ? firstRankSource(appeekyRankSource(a), itunesRankSource()) : itunesRankSource();
|
|
110
|
+
};
|
|
111
|
+
program
|
|
112
|
+
.command("init")
|
|
113
|
+
.description(`Create ${SPEC_FILE} and the appeeky/ folder. With no options in a terminal, a guided setup`)
|
|
114
|
+
.option("--name <name>", "App name")
|
|
115
|
+
.option("--app-store <appId>", "App Store app id (numeric) or bundle id")
|
|
116
|
+
.option("--google-play <package>", "Google Play package name")
|
|
117
|
+
.option("--locale <locale>", "Primary locale", "en-US")
|
|
118
|
+
.option("--country <cc>", "First storefront to add targets for", "us")
|
|
119
|
+
.option("--pull", "Pull the live listings right away", false)
|
|
120
|
+
.option("--ci", "Write the GitHub workflows (pull request check, push on merge, daily verify); works on an existing spec too", false)
|
|
121
|
+
.action(async (opts) => {
|
|
122
|
+
const root = resolve(g().cwd);
|
|
123
|
+
const exists = existsSync(join(root, SPEC_FILE));
|
|
124
|
+
if (exists && opts.ci) {
|
|
125
|
+
const written = await writeWorkflows(root);
|
|
126
|
+
if (g().json)
|
|
127
|
+
printJson({ workflows: written });
|
|
128
|
+
else
|
|
129
|
+
console.log(written.length ? written.map((f) => `${c.green("Wrote")} ${f}`).join("\n") : c.dim("The workflows are already there."));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (exists)
|
|
133
|
+
throw new Error(`${SPEC_FILE} already exists in ${root}. Run \`appeeky-spec doctor\` to see what is set up.`);
|
|
134
|
+
const guided = !opts.name && !opts.appStore && !opts.googlePlay && process.stdin.isTTY && !g().json;
|
|
135
|
+
if (guided)
|
|
136
|
+
return wizard(root, opts.locale, opts.country);
|
|
137
|
+
const spec = {
|
|
138
|
+
version: 1,
|
|
139
|
+
app: { name: opts.name ?? "My App", primaryLocale: opts.locale },
|
|
140
|
+
stores: {
|
|
141
|
+
...(opts.appStore ? { appStore: { appId: opts.appStore, via: "auto" } } : {}),
|
|
142
|
+
...(opts.googlePlay ? { googlePlay: { packageName: opts.googlePlay, via: "auto" } } : {}),
|
|
143
|
+
},
|
|
144
|
+
targets: { [opts.country.toLowerCase()]: [] },
|
|
145
|
+
};
|
|
146
|
+
await writeSpec(root, spec);
|
|
147
|
+
await mkdir(join(root, SPEC_DIR, "store"), { recursive: true });
|
|
148
|
+
if (!existsSync(join(root, SPEC_DIR, "playbook.md")))
|
|
149
|
+
await writeFile(join(root, SPEC_DIR, "playbook.md"), PLAYBOOK);
|
|
150
|
+
await ignoreLocalData(root);
|
|
151
|
+
const workflows = opts.ci ? await writeWorkflows(root) : [];
|
|
152
|
+
if (!g().json) {
|
|
153
|
+
console.log(`${c.green("Created")} ${SPEC_FILE} and ${SPEC_DIR}/`);
|
|
154
|
+
for (const f of workflows)
|
|
155
|
+
console.log(`${c.green("Wrote")} ${f}`);
|
|
156
|
+
if (!opts.appStore && !opts.googlePlay)
|
|
157
|
+
console.log(c.dim("Add stores.appStore.appId or stores.googlePlay.packageName, then run `appeeky-spec pull`."));
|
|
158
|
+
}
|
|
159
|
+
if (opts.pull && (opts.appStore || opts.googlePlay))
|
|
160
|
+
await program.parseAsync(["pull"], { from: "user" });
|
|
161
|
+
else if (g().json)
|
|
162
|
+
printJson({ root, created: [SPEC_FILE, `${SPEC_DIR}/`, ...workflows] });
|
|
163
|
+
});
|
|
164
|
+
/**
|
|
165
|
+
* The guided setup: find the app, write the spec, check that the stores can be reached and
|
|
166
|
+
* pull, suggest targets from what it already ranks for, add CI and the agent skills, and
|
|
167
|
+
* end on the first coverage table.
|
|
168
|
+
*/
|
|
169
|
+
async function wizard(root, locale, country) {
|
|
170
|
+
const step = (n, title) => console.log(`\n${c.bold(`${n}/6`)} ${title}`);
|
|
171
|
+
console.log(c.bold("Appeeky Spec setup") + c.dim(" Enter keeps the suggestion in brackets."));
|
|
172
|
+
step(1, "Your app");
|
|
173
|
+
const term = await ask("What is it called on the App Store?");
|
|
174
|
+
const found = term ? await findApps(term, country).catch(() => []) : [];
|
|
175
|
+
let appStore;
|
|
176
|
+
let name = term || "My App";
|
|
177
|
+
let storeName = term;
|
|
178
|
+
let bundleId = "";
|
|
179
|
+
if (found.length) {
|
|
180
|
+
found.forEach((app, i) => console.log(` ${c.cyan(String(i + 1))} ${app.name} ${c.dim(`${app.developer}${app.rating ? ` · ${app.rating}★` : ""} · ${app.id}`)}`));
|
|
181
|
+
console.log(` ${c.cyan("0")} ${c.dim("None of these / not on the App Store")}`);
|
|
182
|
+
const pick = Number(await ask("Which one?", "1"));
|
|
183
|
+
const app = found[pick - 1];
|
|
184
|
+
if (app) {
|
|
185
|
+
appStore = app.id;
|
|
186
|
+
name = app.name.split(/[:\-–—|]/)[0].trim() || app.name;
|
|
187
|
+
storeName = app.name;
|
|
188
|
+
bundleId = app.bundleId;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (term)
|
|
192
|
+
console.log(c.dim(" Not found on the App Store in this country."));
|
|
193
|
+
if (!appStore) {
|
|
194
|
+
const id = await ask("App Store id (digits), or Enter to skip:");
|
|
195
|
+
if (/^\d{5,}$/.test(id))
|
|
196
|
+
appStore = id;
|
|
197
|
+
}
|
|
198
|
+
let googlePlay;
|
|
199
|
+
const pkg = await ask("Google Play package, or - for none:", bundleId || "-");
|
|
200
|
+
if (pkg && pkg !== "-") {
|
|
201
|
+
const live = await playPackageExists(pkg);
|
|
202
|
+
console.log(live ? ` ${c.green("✓")} found on Google Play` : ` ${c.yellow("!")} no public Google Play page for ${pkg}: kept, check the name`);
|
|
203
|
+
googlePlay = pkg;
|
|
204
|
+
}
|
|
205
|
+
if (!appStore && !googlePlay)
|
|
206
|
+
throw new Error("Give an App Store id or a Google Play package to set up the spec.");
|
|
207
|
+
step(2, "The spec");
|
|
208
|
+
await writeSpec(root, {
|
|
209
|
+
version: 1,
|
|
210
|
+
app: { name, primaryLocale: locale },
|
|
211
|
+
stores: {
|
|
212
|
+
...(appStore ? { appStore: { appId: appStore, via: "auto" } } : {}),
|
|
213
|
+
...(googlePlay ? { googlePlay: { packageName: googlePlay, via: "auto" } } : {}),
|
|
214
|
+
},
|
|
215
|
+
targets: { [country]: [] },
|
|
216
|
+
});
|
|
217
|
+
await mkdir(join(root, SPEC_DIR, "store"), { recursive: true });
|
|
218
|
+
if (!existsSync(join(root, SPEC_DIR, "playbook.md")))
|
|
219
|
+
await writeFile(join(root, SPEC_DIR, "playbook.md"), PLAYBOOK);
|
|
220
|
+
const ignored = await ignoreLocalData(root);
|
|
221
|
+
console.log(` ${c.green("✓")} ${SPEC_FILE}, ${SPEC_DIR}/playbook.md${ignored ? ", .appeeky/ in .gitignore" : ""}`);
|
|
222
|
+
step(3, "Store access and your live listing");
|
|
223
|
+
let project = await load();
|
|
224
|
+
let reach = diagnose(root, project).filter((x) => x.area === "App Store" || x.area === "Google Play");
|
|
225
|
+
printChecks(reach);
|
|
226
|
+
// A store that cannot be reached: offer to save the user's own key with the store CLI, once per machine.
|
|
227
|
+
let saved = false;
|
|
228
|
+
for (const x of reach.filter((r) => r.status === "fail")) {
|
|
229
|
+
const store = x.area === "App Store" ? "app-store" : "google-play";
|
|
230
|
+
if (!(await agree(`Save your own ${x.area} key on this machine now?`)))
|
|
231
|
+
continue;
|
|
232
|
+
saved = (await saveStoreKey(store, slug(name), store === "app-store" ? appStore : googlePlay)) || saved;
|
|
233
|
+
}
|
|
234
|
+
if (saved) {
|
|
235
|
+
reach = diagnose(root, project).filter((x) => x.area === "App Store" || x.area === "Google Play");
|
|
236
|
+
printChecks(reach);
|
|
237
|
+
}
|
|
238
|
+
let pulled = false;
|
|
239
|
+
// Pull each store that can be reached, one at a time: one store's problem does not stop the other.
|
|
240
|
+
const reachable = reach.filter((x) => x.status === "ok").map((x) => (x.area === "App Store" ? "app-store" : "google-play"));
|
|
241
|
+
if (reachable.length && (await agree(`Pull your live listing from ${reachable.map((s) => storeLabel(s)).join(" and ")} now?`))) {
|
|
242
|
+
for (const store of reachable) {
|
|
243
|
+
try {
|
|
244
|
+
await program.parseAsync(["pull", "--store", store], { from: "user" });
|
|
245
|
+
pulled = true;
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
console.log(` ${c.red("✗")} ${e.message}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (reachable.length < reach.length)
|
|
253
|
+
console.log(c.dim(" Set up access for the rest as above, then run `appeeky-spec pull`."));
|
|
254
|
+
step(4, `The searches to win in ${country.toUpperCase()}`);
|
|
255
|
+
project = await load();
|
|
256
|
+
// Without a pulled listing, the App Store name still says what the app is for.
|
|
257
|
+
const candidates = targetCandidates(project, 14, storeName);
|
|
258
|
+
const ranked = await suggestTargets(candidates, appStore, country, itunesRankSource());
|
|
259
|
+
let targets = [];
|
|
260
|
+
if (ranked.length) {
|
|
261
|
+
for (const r of ranked)
|
|
262
|
+
console.log(` ${r.keyword.padEnd(24)} ${r.rank ? c.green(`#${r.rank}`) : c.dim("not in the top 200")}`);
|
|
263
|
+
const answer = await ask("Keep these? Enter for yes, or type your own, comma separated:", "yes");
|
|
264
|
+
targets = answer === "yes" ? ranked.map((r) => r.keyword) : answer.split(",").map((x) => x.trim().toLowerCase()).filter(Boolean);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
const answer = await ask("Type 3 to 5 searches people use to find apps like yours, comma separated:");
|
|
268
|
+
targets = answer.split(",").map((x) => x.trim().toLowerCase()).filter((x) => x && !/^(y|yes)$/.test(x));
|
|
269
|
+
}
|
|
270
|
+
if (targets.length) {
|
|
271
|
+
project.spec.targets[country] = targets;
|
|
272
|
+
await writeSpec(root, project.spec);
|
|
273
|
+
console.log(` ${c.green("✓")} ${targets.length} ${targets.length === 1 ? "target" : "targets"} in ${SPEC_FILE}`);
|
|
274
|
+
}
|
|
275
|
+
step(5, "Automation");
|
|
276
|
+
if (existsSync(join(root, ".git")) && (await agree("Add GitHub Actions (pull request check, push on merge, daily verify)?"))) {
|
|
277
|
+
for (const f of await writeWorkflows(root))
|
|
278
|
+
console.log(` ${c.green("✓")} ${f}`);
|
|
279
|
+
console.log(c.dim(" The push and the daily run need APPEEKY_API_KEY (or your store keys) as repository secrets."));
|
|
280
|
+
}
|
|
281
|
+
if (await agree("Install the agent skills (npx skills add appeeky/appeeky-spec)?")) {
|
|
282
|
+
const result = spawnSync("npx", ["-y", "skills", "add", "appeeky/appeeky-spec"], { cwd: root, stdio: "inherit", shell: process.platform === "win32" });
|
|
283
|
+
if (result.status !== 0)
|
|
284
|
+
console.log(c.yellow(" The skills did not install. Run `npx skills add appeeky/appeeky-spec` later."));
|
|
285
|
+
}
|
|
286
|
+
step(6, "Where you stand");
|
|
287
|
+
if (pulled && targets.length)
|
|
288
|
+
await program.parseAsync(["coverage", "--country", country], { from: "user" });
|
|
289
|
+
else
|
|
290
|
+
console.log(c.dim(" Pull the listing and add targets, then `appeeky-spec coverage` shows which searches it covers."));
|
|
291
|
+
console.log(`\n${c.bold("Next")}: change a listing file (or ask your agent), then ${c.cyan("appeeky-spec diff")} and ${c.cyan('appeeky-spec push --note "why"')}.`);
|
|
292
|
+
console.log(c.dim("Run `appeeky-spec doctor` any time to see what is set up and what is missing."));
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Save a store key with the store's own CLI (`appstore accounts add`, `gps accounts add`), run
|
|
296
|
+
* through npx: the key stays in the CLI's config on this machine, never in the repo.
|
|
297
|
+
*/
|
|
298
|
+
async function saveStoreKey(store, account, appId) {
|
|
299
|
+
const cli = STORE_CLIS[store];
|
|
300
|
+
let args;
|
|
301
|
+
if (store === "app-store") {
|
|
302
|
+
console.log(c.dim(" App Store Connect → Users and Access → Integrations → App Store Connect API. Role App Manager to push."));
|
|
303
|
+
const keyId = await ask(" Key ID:");
|
|
304
|
+
const issuer = await ask(" Issuer ID (Enter for an individual key):");
|
|
305
|
+
const path = await ask(" Path to the AuthKey_….p8 file:");
|
|
306
|
+
if (!keyId || !path)
|
|
307
|
+
return false;
|
|
308
|
+
args = ["accounts", "add", account, "--key-id", keyId, ...(issuer ? ["--issuer-id", issuer] : []), "--private-key-path", path, ...(appId ? ["--default-app", appId] : []), "--default"];
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
console.log(c.dim(" A Google Cloud service account with the Android Publisher API, invited in Play Console → Users and permissions."));
|
|
312
|
+
const path = await ask(" Path to its JSON key:");
|
|
313
|
+
if (!path)
|
|
314
|
+
return false;
|
|
315
|
+
args = ["accounts", "add", account, "--credentials", path, ...(appId ? ["--default-package", appId] : []), "--default"];
|
|
316
|
+
}
|
|
317
|
+
const result = spawnSync("npx", ["-y", "-p", cli.npmPackage, cli.bin, ...args], { stdio: "inherit", shell: process.platform === "win32" });
|
|
318
|
+
if (result.status === 0) {
|
|
319
|
+
console.log(` ${c.green("✓")} saved as "${account}" in the ${cli.bin} CLI's config (not in this repo)`);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
console.log(` ${c.red("✗")} ${cli.bin} accounts add failed: run it again later with the command above.`);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
function printChecks(checks) {
|
|
326
|
+
const mark = { ok: c.green("✓"), warn: c.yellow("!"), fail: c.red("✗") };
|
|
327
|
+
for (const x of checks) {
|
|
328
|
+
console.log(` ${mark[x.status]} ${x.area.padEnd(20)} ${x.detail}`);
|
|
329
|
+
if (x.fix)
|
|
330
|
+
console.log(` ${c.dim(x.fix)}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
program
|
|
334
|
+
.command("doctor")
|
|
335
|
+
.description("What is set up for this repo, what is missing, and how to fix it")
|
|
336
|
+
.action(async () => {
|
|
337
|
+
const root = resolve(g().cwd);
|
|
338
|
+
const project = await load().catch(() => null);
|
|
339
|
+
const checks = diagnose(project?.root ?? root, project);
|
|
340
|
+
if (g().json)
|
|
341
|
+
printJson({ checks });
|
|
342
|
+
else {
|
|
343
|
+
printChecks(checks);
|
|
344
|
+
const fails = checks.filter((x) => x.status === "fail").length;
|
|
345
|
+
const warns = checks.filter((x) => x.status === "warn").length;
|
|
346
|
+
console.log(`\n${fails ? c.red(`${fails} to fix`) : c.green("Ready")}${warns ? c.dim(` · ${warns} optional`) : ""}`);
|
|
347
|
+
}
|
|
348
|
+
if (checks.some((x) => x.status === "fail"))
|
|
349
|
+
process.exitCode = 1;
|
|
350
|
+
});
|
|
351
|
+
program
|
|
352
|
+
.command("pull")
|
|
353
|
+
.description("Write the live store listings into appeeky/store/ (overwrites local files)")
|
|
354
|
+
.addOption(storeOption())
|
|
355
|
+
.option("--locale <locales...>", "Only these locales")
|
|
356
|
+
.action(async (opts) => {
|
|
357
|
+
const project = await load();
|
|
358
|
+
const written = [];
|
|
359
|
+
for (const store of configuredStores(project, opts.store)) {
|
|
360
|
+
const handle = storeHandle(project, store, transportOverrides());
|
|
361
|
+
if (!g().json)
|
|
362
|
+
console.log(c.dim(`${storeLabel(store)}: pulling via ${handle.transport.label}…`));
|
|
363
|
+
const state = await handle.adapter.pull();
|
|
364
|
+
for (const [locale, listing] of Object.entries(state.listings)) {
|
|
365
|
+
if (opts.locale && !opts.locale.includes(locale))
|
|
366
|
+
continue;
|
|
367
|
+
written.push({ store, locale, file: await writeListing(project.root, store, locale, listing) });
|
|
368
|
+
}
|
|
369
|
+
await recordPull(project.root, store, state.target);
|
|
370
|
+
if (!g().json)
|
|
371
|
+
console.log(`${storeLabel(store)}: ${Object.keys(state.listings).length} locales from ${state.target}`);
|
|
372
|
+
}
|
|
373
|
+
if (g().json)
|
|
374
|
+
printJson({ written });
|
|
375
|
+
});
|
|
376
|
+
program
|
|
377
|
+
.command("check")
|
|
378
|
+
.alias("lint")
|
|
379
|
+
.description("Check the listings against store limits and ASO rules")
|
|
380
|
+
.option("--strict", "Fail on warnings too", false)
|
|
381
|
+
.action(async (opts) => {
|
|
382
|
+
const project = await load();
|
|
383
|
+
const issues = [...lintProject(project), ...(await checkScreenshots(project.root))];
|
|
384
|
+
if (g().json)
|
|
385
|
+
printJson({ issues });
|
|
386
|
+
else
|
|
387
|
+
console.log(formatIssues(issues));
|
|
388
|
+
if (hasErrors(issues) || (opts.strict && issues.some((i) => i.severity === "warning")))
|
|
389
|
+
process.exitCode = 1;
|
|
390
|
+
});
|
|
391
|
+
program
|
|
392
|
+
.command("coverage")
|
|
393
|
+
.description("Which target keywords the listings index, per storefront")
|
|
394
|
+
.option("--country <cc...>", "Only these storefronts")
|
|
395
|
+
.option("--no-metrics", "Skip popularity from the Appeeky API")
|
|
396
|
+
.action(async (opts) => {
|
|
397
|
+
const project = await load();
|
|
398
|
+
const key = process.env.APPEEKY_API_KEY;
|
|
399
|
+
const targets = Object.fromEntries(Object.entries(project.spec.targets).filter(([cc]) => !opts.country || opts.country.map((x) => x.toLowerCase()).includes(cc)));
|
|
400
|
+
const scoped = { ...project, spec: { ...project.spec, targets } };
|
|
401
|
+
const stores = configuredStores(project);
|
|
402
|
+
let lookup;
|
|
403
|
+
let note;
|
|
404
|
+
if (opts.metrics) {
|
|
405
|
+
const result = await loadPopularity(project.root, key ? new AppeekyApi(key, { baseUrl: process.env.APPEEKY_API_URL }) : undefined, targets, stores, (done, total) => {
|
|
406
|
+
if (!g().json && process.stderr.isTTY)
|
|
407
|
+
process.stderr.write(`\r${c.dim(`popularity ${done}/${total}`)}`);
|
|
408
|
+
});
|
|
409
|
+
if (!g().json && process.stderr.isTTY && result.fetched > 0)
|
|
410
|
+
process.stderr.write("\r\u001b[K");
|
|
411
|
+
lookup = result.lookup;
|
|
412
|
+
if (!key)
|
|
413
|
+
note = "Set APPEEKY_API_KEY to rank targets by search popularity (appeeky.com/dashboard/settings).";
|
|
414
|
+
else if (result.failed.length > 0)
|
|
415
|
+
note = `Popularity missing for ${result.failed.length} keywords: ${result.failed[0]}`;
|
|
416
|
+
}
|
|
417
|
+
const reports = projectCoverage(scoped, lookup);
|
|
418
|
+
const crossLocalization = crossLocalizationSuggestions(scoped);
|
|
419
|
+
if (g().json)
|
|
420
|
+
printJson({ coverage: reports, crossLocalization, note });
|
|
421
|
+
else {
|
|
422
|
+
console.log(formatCoverage(reports));
|
|
423
|
+
for (const x of crossLocalization) {
|
|
424
|
+
const split = x.splitPhrase ? "separate its keyword field with commas and " : "";
|
|
425
|
+
console.log(`\n${c.cyan("tip")} ${x.country.toUpperCase()} also indexes ${x.locale}: ${split}add ${x.add.map((w) => `"${w}"`).join(", ")} to its keyword field → ${c.dim(x.keywords)}`);
|
|
426
|
+
}
|
|
427
|
+
if (note)
|
|
428
|
+
console.log(c.dim(`\n${note}`));
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
const plans = async (project, only) => {
|
|
432
|
+
const out = [];
|
|
433
|
+
for (const store of configuredStores(project, only))
|
|
434
|
+
out.push(await planStore(project, storeHandle(project, store, transportOverrides())));
|
|
435
|
+
return out;
|
|
436
|
+
};
|
|
437
|
+
program
|
|
438
|
+
.command("diff")
|
|
439
|
+
.description("Compare the spec with the live store")
|
|
440
|
+
.addOption(storeOption())
|
|
441
|
+
.action(async (opts) => {
|
|
442
|
+
const project = await load();
|
|
443
|
+
const result = await plans(project, opts.store);
|
|
444
|
+
if (g().json) {
|
|
445
|
+
printJson({ stores: result.map((p) => ({ store: p.store, via: p.via, target: p.state.target, changes: p.changes })) });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
for (const p of result) {
|
|
449
|
+
console.log(c.dim(`${storeLabel(p.store)}: ${p.state.target} via ${p.via}`));
|
|
450
|
+
console.log(formatChanges(p.changes));
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
program
|
|
454
|
+
.command("push")
|
|
455
|
+
.description("Push the spec to the stores and record a release in appeeky/releases/")
|
|
456
|
+
.addOption(storeOption())
|
|
457
|
+
.option("-y, --yes", "Do not ask for confirmation", false)
|
|
458
|
+
.option("--dry-run", "Show what would be pushed", false)
|
|
459
|
+
.option("-m, --note <text>", "Why this change: stored with the release")
|
|
460
|
+
.option("--force", "Push even when `check` finds errors", false)
|
|
461
|
+
.option("--create-version <version>", "Create this App Store version first (name, subtitle and keywords need one)")
|
|
462
|
+
.option("--no-baseline", "Do not record search ranks before pushing")
|
|
463
|
+
.action(async (opts) => {
|
|
464
|
+
const project = await load();
|
|
465
|
+
if (opts.createVersion && !opts.dryRun && project.spec.stores.appStore && (!opts.store || opts.store === "app-store")) {
|
|
466
|
+
const handle = storeHandle(project, "app-store", transportOverrides());
|
|
467
|
+
const created = await handle.adapter.createVersion(opts.createVersion);
|
|
468
|
+
if (!g().json)
|
|
469
|
+
console.log(`${c.green("Created")} App Store version ${created.versionString}`);
|
|
470
|
+
}
|
|
471
|
+
const issues = lintProject(project).filter((i) => i.severity === "error");
|
|
472
|
+
if (issues.length > 0 && !opts.force) {
|
|
473
|
+
if (g().json)
|
|
474
|
+
printJson({ error: "check_failed", issues });
|
|
475
|
+
else
|
|
476
|
+
console.error(`${formatIssues(issues)}\n\n${c.red("Fix these first, or pass --force.")}`);
|
|
477
|
+
process.exitCode = 1;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const result = await plans(project, opts.store);
|
|
481
|
+
const pushable = result.flatMap((p) => p.changes.filter((ch) => !ch.blocked));
|
|
482
|
+
if (!g().json) {
|
|
483
|
+
for (const p of result) {
|
|
484
|
+
console.log(c.dim(`${storeLabel(p.store)}: ${p.state.target} via ${p.via}`));
|
|
485
|
+
console.log(formatChanges(p.changes));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (pushable.length === 0 || opts.dryRun) {
|
|
489
|
+
if (g().json)
|
|
490
|
+
printJson({ dryRun: opts.dryRun, stores: result.map((p) => ({ store: p.store, target: p.state.target, changes: p.changes })) });
|
|
491
|
+
else if (pushable.length === 0)
|
|
492
|
+
console.log(c.dim("\nNothing to push."));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (!opts.yes && !(await confirm(`\nPush ${pushable.length} change${pushable.length === 1 ? "" : "s"}?`))) {
|
|
496
|
+
if (!process.stdin.isTTY && !g().json)
|
|
497
|
+
console.error(c.yellow("Not a terminal: pass --yes to push."));
|
|
498
|
+
process.exitCode = 1;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const releases = [];
|
|
502
|
+
for (const p of result) {
|
|
503
|
+
const ready = p.changes.filter((ch) => !ch.blocked);
|
|
504
|
+
if (ready.length === 0)
|
|
505
|
+
continue;
|
|
506
|
+
const handle = storeHandle(project, p.store, transportOverrides());
|
|
507
|
+
// Ranks right before the push are what `verify` compares against later.
|
|
508
|
+
const baseline = opts.baseline && rankAppIds(project)[p.store] ? await snapshotRanks(project, rankSource(), [p.store]).catch(() => null) : null;
|
|
509
|
+
const pushed = await handle.adapter.push(ready, p.state, project.listings[p.store]);
|
|
510
|
+
const now = new Date();
|
|
511
|
+
const record = {
|
|
512
|
+
id: releaseId(now, p.store),
|
|
513
|
+
pushedAt: now.toISOString(),
|
|
514
|
+
store: p.store,
|
|
515
|
+
target: p.state.target,
|
|
516
|
+
via: p.via,
|
|
517
|
+
...(opts.note ? { note: opts.note } : {}),
|
|
518
|
+
changes: pushed.applied.map(({ locale, field, before, after }) => ({ locale, field, before, after })),
|
|
519
|
+
...(baseline?.ranks[p.store] ? { baseline: { at: baseline.at, source: baseline.source, ranks: baseline.ranks[p.store] } } : {}),
|
|
520
|
+
};
|
|
521
|
+
const file = await writeRelease(project.root, record);
|
|
522
|
+
releases.push({ ...record, file, skipped: pushed.skipped });
|
|
523
|
+
if (!g().json)
|
|
524
|
+
console.log(`${c.green("Pushed")} ${pushed.applied.length} to ${storeLabel(p.store)} · ${c.dim(file)}`);
|
|
525
|
+
}
|
|
526
|
+
if (releases.length)
|
|
527
|
+
await writeLoops(project.root);
|
|
528
|
+
if (g().json)
|
|
529
|
+
printJson({ releases });
|
|
530
|
+
});
|
|
531
|
+
program
|
|
532
|
+
.command("status")
|
|
533
|
+
.description("Every change being measured: what, the metric that decides it, the next check and the result so far")
|
|
534
|
+
.option("--all", "Show closed loops too", false)
|
|
535
|
+
.action(async (opts) => {
|
|
536
|
+
const project = await load();
|
|
537
|
+
const loops = await writeLoops(project.root);
|
|
538
|
+
const stores = [...(project.spec.stores.appStore ? ["app-store"] : []), ...(project.spec.stores.googlePlay ? ["google-play"] : [])];
|
|
539
|
+
const free = freeStores(loops, stores);
|
|
540
|
+
if (g().json) {
|
|
541
|
+
printJson({ loops, free });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const shown = opts.all ? loops : loops.filter((l) => l.state !== "closed");
|
|
545
|
+
if (shown.length === 0)
|
|
546
|
+
console.log(c.dim(opts.all ? "No releases or experiments yet." : "Nothing is being measured. See closed loops with --all."));
|
|
547
|
+
for (const l of shown)
|
|
548
|
+
printLoop(l);
|
|
549
|
+
const closed = loops.length - loops.filter((l) => l.state !== "closed").length;
|
|
550
|
+
if (!opts.all && closed)
|
|
551
|
+
console.log(c.dim(`\n${closed} closed. --all to see them.`));
|
|
552
|
+
console.log(free.length ? `\n${c.green("Free to change:")} ${free.map(storeLabel).join(", ")}` : c.dim("\nEvery store has a release being measured: wait before the next change."));
|
|
553
|
+
console.log(c.dim("Written to appeeky/loops.md."));
|
|
554
|
+
});
|
|
555
|
+
program
|
|
556
|
+
.command("log")
|
|
557
|
+
.description("Releases pushed from this spec, newest first")
|
|
558
|
+
.option("-n, --limit <n>", "How many", "20")
|
|
559
|
+
.option("--markdown", "Print each release as a Markdown summary", false)
|
|
560
|
+
.action(async (opts) => {
|
|
561
|
+
const project = await load();
|
|
562
|
+
const releases = (await readReleases(project.root)).slice(0, Number(opts.limit) || 20);
|
|
563
|
+
if (opts.markdown) {
|
|
564
|
+
console.log(releases.map(releaseMarkdown).join("\n\n"));
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (g().json) {
|
|
568
|
+
printJson({ releases });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (releases.length === 0)
|
|
572
|
+
console.log(c.dim("No releases yet."));
|
|
573
|
+
for (const r of releases) {
|
|
574
|
+
const locales = [...new Set(r.changes.map((ch) => ch.locale))];
|
|
575
|
+
console.log(`${c.bold(r.id)} ${storeLabel(r.store)} · ${r.target} · ${r.changes.length} changes in ${locales.join(", ")}`);
|
|
576
|
+
if (r.note)
|
|
577
|
+
console.log(` ${c.dim(r.note)}`);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
program
|
|
581
|
+
.command("ads")
|
|
582
|
+
.description("Spend and installs of your paid channels: Apple Search Ads, plus the Meta and TikTok campaigns under ads: (needs APPEEKY_API_KEY)")
|
|
583
|
+
.option("--days <n>", "How many days, up to yesterday", "30")
|
|
584
|
+
.option("--save", "Write the numbers to appeeky/ads/<date>.yaml", false)
|
|
585
|
+
.action(async (opts) => {
|
|
586
|
+
const project = await load();
|
|
587
|
+
const a = api();
|
|
588
|
+
if (!a)
|
|
589
|
+
throw new Error("Set APPEEKY_API_KEY: ad data comes from the channels you connected in Appeeky.");
|
|
590
|
+
const snapshot = await adsSnapshot(a, project.spec, experimentApp(project, {}), Number(opts.days) || 30);
|
|
591
|
+
const saved = opts.save ? await saveAdsSnapshot(project.root, snapshot) : undefined;
|
|
592
|
+
if (g().json) {
|
|
593
|
+
printJson({ ...snapshot, ...(saved ? { saved } : {}) });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const money = (n) => (n === undefined ? "—" : `${snapshot.currency && snapshot.currency !== "USD" ? `${snapshot.currency} ` : "$"}${n.toFixed(2)}`);
|
|
597
|
+
console.log(c.dim(`${snapshot.from} → ${snapshot.to} (${snapshot.days} days, per day)`));
|
|
598
|
+
if (snapshot.channels.length === 0 && snapshot.errors.length === 0) {
|
|
599
|
+
console.log(c.dim("No ad data. Connect Apple Search Ads, Meta or TikTok in Appeeky, and list Meta and TikTok campaign ids under ads: in appeeky.spec.yaml."));
|
|
600
|
+
}
|
|
601
|
+
for (const ch of snapshot.channels) {
|
|
602
|
+
console.log(`${c.bold(CHANNEL_LABELS[ch.channel].padEnd(18))} spend ${money(ch.spendPerDay).padEnd(10)} ${ch.result} ${String(ch.resultsPerDay ?? "—").padEnd(8)} cost per ${ch.result === "installs" ? "install" : "conversion"} ${money(ch.costPerResult)}`);
|
|
603
|
+
}
|
|
604
|
+
for (const e of snapshot.errors)
|
|
605
|
+
console.log(`${c.red(CHANNEL_LABELS[e.channel].padEnd(18))} ${c.dim(e.message)}`);
|
|
606
|
+
if (saved)
|
|
607
|
+
console.log(c.dim(`\nSaved ${saved.replace(project.root + "/", "")}`));
|
|
608
|
+
});
|
|
609
|
+
const reviews = program.command("reviews").description("Customer reviews in the repo, and replies sent through a pull request (needs APPEEKY_API_KEY)");
|
|
610
|
+
reviews
|
|
611
|
+
.command("pull")
|
|
612
|
+
.description("Write App Store and Google Play reviews to appeeky/reviews/ and update summary.md")
|
|
613
|
+
.option("--since <date>", "Only reviews from this date on (default: everything the connection has)")
|
|
614
|
+
.option("--max <n>", "At most this many reviews per store", "2000")
|
|
615
|
+
.action(async (opts) => {
|
|
616
|
+
const project = await load();
|
|
617
|
+
const a = api();
|
|
618
|
+
if (!a)
|
|
619
|
+
throw new Error("Set APPEEKY_API_KEY: reviews come from the store connections in Appeeky.");
|
|
620
|
+
const ids = rankAppIds(project);
|
|
621
|
+
const results = {};
|
|
622
|
+
for (const store of Object.keys(ids)) {
|
|
623
|
+
try {
|
|
624
|
+
const got = await fetchReviews(a, store, ids[store], { since: opts.since, max: Number(opts.max) || 2000 });
|
|
625
|
+
results[store] = { fetched: got.length, ...(await saveReviews(project.root, got)) };
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
results[store] = { error: e.message };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Every listing's text, so the summary can show what users say that it does not.
|
|
632
|
+
const listingText = Object.values(project.listings)
|
|
633
|
+
.flatMap((byLocale) => Object.values(byLocale))
|
|
634
|
+
.flatMap((l) => Object.values(l).filter((v) => typeof v === "string"))
|
|
635
|
+
.join(" ");
|
|
636
|
+
const summary = await writeSummary(project.root, new Date(), listingText);
|
|
637
|
+
if (g().json) {
|
|
638
|
+
printJson({ results, summary });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
for (const [store, r] of Object.entries(results)) {
|
|
642
|
+
if ("error" in r)
|
|
643
|
+
console.log(`${c.red(storeLabel(store).padEnd(12))} ${c.dim(r.error)}`);
|
|
644
|
+
else
|
|
645
|
+
console.log(`${c.bold(storeLabel(store).padEnd(12))} ${r.fetched} reviews · ${c.green(`${r.added} new`)} · ${r.updated} changed`);
|
|
646
|
+
}
|
|
647
|
+
console.log(c.dim(`\nWrote ${summary.replace(project.root + "/", "")}`));
|
|
648
|
+
});
|
|
649
|
+
reviews
|
|
650
|
+
.command("list")
|
|
651
|
+
.description("Reviews from the repo, newest first")
|
|
652
|
+
.option("--store <store>", "app-store or google-play")
|
|
653
|
+
.option("--rating <n>", "Only this many stars, or 1-2 for a range")
|
|
654
|
+
.option("--unanswered", "Only reviews without a reply", false)
|
|
655
|
+
.option("-n, --limit <n>", "How many", "20")
|
|
656
|
+
.action(async (opts) => {
|
|
657
|
+
const project = await load();
|
|
658
|
+
const [lo, hi] = (opts.rating ?? "1-5").split("-").map(Number);
|
|
659
|
+
const rows = (await readReviews(project.root, opts.store))
|
|
660
|
+
.filter((r) => r.rating >= (lo ?? 1) && r.rating <= (hi ?? lo ?? 5))
|
|
661
|
+
.filter((r) => !opts.unanswered || !r.reply)
|
|
662
|
+
.slice(0, Number(opts.limit) || 20);
|
|
663
|
+
if (g().json) {
|
|
664
|
+
printJson({ reviews: rows });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (rows.length === 0)
|
|
668
|
+
console.log(c.dim("No reviews. Run appeeky-spec reviews pull."));
|
|
669
|
+
for (const r of rows) {
|
|
670
|
+
const stars = r.rating <= 2 ? c.red(`${r.rating}★`) : r.rating === 3 ? c.yellow(`${r.rating}★`) : c.green(`${r.rating}★`);
|
|
671
|
+
console.log(`${stars} ${c.dim(`${r.date.slice(0, 10)} ${r.store} ${r.where ?? ""} ${r.version ?? ""} ${r.id}`)}${r.reply ? c.dim(" · replied") : ""}`);
|
|
672
|
+
console.log(` ${(r.title ? `${c.bold(r.title)} ` : "") + r.body.replace(/\s+/g, " ").slice(0, 200)}`);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
reviews
|
|
676
|
+
.command("reply")
|
|
677
|
+
.description("Draft a reply in appeeky/reviews/replies/ for review in a pull request; reviews push sends it")
|
|
678
|
+
.argument("<review>", "Review id from reviews list")
|
|
679
|
+
.requiredOption("--text <text>", "The reply")
|
|
680
|
+
.action(async (id, opts) => {
|
|
681
|
+
const project = await load();
|
|
682
|
+
const review = (await readReviews(project.root)).find((r) => r.id === id);
|
|
683
|
+
if (!review)
|
|
684
|
+
throw new Error(`No review ${id} in appeeky/reviews/. Run appeeky-spec reviews pull first.`);
|
|
685
|
+
const file = await writeReply(project.root, {
|
|
686
|
+
id,
|
|
687
|
+
store: review.store,
|
|
688
|
+
review: `${review.rating}★ ${review.date.slice(0, 10)}: ${(review.title ? `${review.title}. ` : "") + review.body.replace(/\s+/g, " ").slice(0, 200)}`,
|
|
689
|
+
text: opts.text,
|
|
690
|
+
});
|
|
691
|
+
const issues = replyIssues({ id, store: review.store, text: opts.text, status: "draft" });
|
|
692
|
+
if (g().json) {
|
|
693
|
+
printJson({ file, issues });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
console.log(`${c.green("Drafted")} ${file.replace(project.root + "/", "")}`);
|
|
697
|
+
for (const i of issues)
|
|
698
|
+
console.log(c.red(` ${i}`));
|
|
699
|
+
});
|
|
700
|
+
reviews
|
|
701
|
+
.command("push")
|
|
702
|
+
.description("Send the draft replies in appeeky/reviews/replies/ to the stores")
|
|
703
|
+
.option("--yes", "Send without asking", false)
|
|
704
|
+
.action(async (opts) => {
|
|
705
|
+
const project = await load();
|
|
706
|
+
const a = api();
|
|
707
|
+
if (!a)
|
|
708
|
+
throw new Error("Set APPEEKY_API_KEY: replies are sent through the store connections in Appeeky.");
|
|
709
|
+
const drafts = (await readReplies(project.root)).filter((r) => r.status === "draft");
|
|
710
|
+
const blocked = drafts.filter((r) => replyIssues(r).length);
|
|
711
|
+
if (blocked.length)
|
|
712
|
+
throw new Error(`Too long: ${blocked.map((r) => `${r.id} (${replyIssues(r).join(", ")})`).join("; ")}`);
|
|
713
|
+
if (drafts.length === 0) {
|
|
714
|
+
if (g().json)
|
|
715
|
+
printJson({ sent: [] });
|
|
716
|
+
else
|
|
717
|
+
console.log(c.dim("No draft replies."));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (!g().json)
|
|
721
|
+
for (const r of drafts)
|
|
722
|
+
console.log(`${c.bold(r.id)} ${c.dim(r.store)}\n ${r.text}`);
|
|
723
|
+
if (!opts.yes && !(await confirm(`Send ${drafts.length} repl${drafts.length === 1 ? "y" : "ies"}?`)))
|
|
724
|
+
return;
|
|
725
|
+
const sent = [];
|
|
726
|
+
const failed = [];
|
|
727
|
+
for (const r of drafts) {
|
|
728
|
+
try {
|
|
729
|
+
await sendReply(a, r, project.spec.stores.googlePlay?.packageName);
|
|
730
|
+
sent.push(r.id);
|
|
731
|
+
}
|
|
732
|
+
catch (e) {
|
|
733
|
+
failed.push({ id: r.id, message: e.message });
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (g().json) {
|
|
737
|
+
printJson({ sent, failed });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
console.log(c.green(`Sent ${sent.length}.`));
|
|
741
|
+
for (const f of failed)
|
|
742
|
+
console.log(`${c.red(f.id)} ${c.dim(f.message)}`);
|
|
743
|
+
});
|
|
744
|
+
const competitors = program.command("competitors").description("Competitors in the repo: their listing, ranks and reviews (needs APPEEKY_API_KEY)");
|
|
745
|
+
competitors
|
|
746
|
+
.command("pull")
|
|
747
|
+
.description("Write each competitor under competitors: to appeeky/competitors/<name>.yaml")
|
|
748
|
+
.action(async () => {
|
|
749
|
+
const project = await load();
|
|
750
|
+
const a = api();
|
|
751
|
+
if (!a)
|
|
752
|
+
throw new Error("Set APPEEKY_API_KEY: competitor data comes from the Appeeky API.");
|
|
753
|
+
if (project.spec.competitors.length === 0)
|
|
754
|
+
throw new Error("No competitors in appeeky.spec.yaml. Find some with appeeky-spec competitors discover.");
|
|
755
|
+
const results = await pullCompetitors(a, project.root, project.spec);
|
|
756
|
+
if (g().json) {
|
|
757
|
+
printJson({ results });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
for (const r of results) {
|
|
761
|
+
console.log(`${r.errors.length ? c.yellow("!") : c.green("✓")} ${r.file.replace(project.root + "/", "")}`);
|
|
762
|
+
for (const e of r.errors)
|
|
763
|
+
console.log(c.dim(` ${e}`));
|
|
764
|
+
for (const ch of r.changes)
|
|
765
|
+
console.log(` ${c.yellow("changed")} ${ch}`);
|
|
766
|
+
}
|
|
767
|
+
console.log(c.dim("\nThe git diff shows what each competitor changed since the last pull."));
|
|
768
|
+
});
|
|
769
|
+
competitors
|
|
770
|
+
.command("discover")
|
|
771
|
+
.description("Apps like yours, to add as competitors")
|
|
772
|
+
.option("--store <store>", "app-store or google-play")
|
|
773
|
+
.option("--add <n>", "Add the first n to competitors: in appeeky.spec.yaml")
|
|
774
|
+
.action(async (opts) => {
|
|
775
|
+
const project = await load();
|
|
776
|
+
const a = api();
|
|
777
|
+
if (!a)
|
|
778
|
+
throw new Error("Set APPEEKY_API_KEY: similar apps come from the Appeeky API.");
|
|
779
|
+
const ids = rankAppIds(project);
|
|
780
|
+
const store = opts.store ?? Object.keys(ids)[0];
|
|
781
|
+
if (!store || !ids[store])
|
|
782
|
+
throw new Error("Set stores.appStore.appId or stores.googlePlay.packageName first.");
|
|
783
|
+
const country = Object.keys(project.spec.targets)[0] ?? "us";
|
|
784
|
+
const apps = await similarApps(a, store, ids[store], country);
|
|
785
|
+
const n = Number(opts.add) || 0;
|
|
786
|
+
if (n > 0) {
|
|
787
|
+
const have = new Set(project.spec.competitors.map((x) => (store === "app-store" ? x.appStore : x.googlePlay)));
|
|
788
|
+
const fresh = apps.filter((x) => !have.has(x.id)).slice(0, n);
|
|
789
|
+
project.spec.competitors.push(...fresh.map((x) => (store === "app-store" ? { name: x.title, appStore: x.id } : { name: x.title, googlePlay: x.id })));
|
|
790
|
+
await writeSpec(project.root, project.spec);
|
|
791
|
+
}
|
|
792
|
+
if (g().json) {
|
|
793
|
+
printJson({ store, country, apps, added: n });
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
for (const x of apps)
|
|
797
|
+
console.log(`${x.id.padEnd(14)} ${x.title}${x.rating ? c.dim(` ${x.rating}★ ${x.ratings ?? ""}`) : ""}`);
|
|
798
|
+
if (n > 0)
|
|
799
|
+
console.log(c.green(`\nAdded ${Math.min(n, apps.length)} to appeeky.spec.yaml. Run appeeky-spec competitors pull.`));
|
|
800
|
+
else
|
|
801
|
+
console.log(c.dim("\nAdd them with --add <n>, or by hand under competitors: in appeeky.spec.yaml."));
|
|
802
|
+
});
|
|
803
|
+
const visibility = program
|
|
804
|
+
.command("visibility")
|
|
805
|
+
.description("Whether ChatGPT, Claude, Gemini and Perplexity name your app, from Appeeky AI Visibility (needs APPEEKY_API_KEY)");
|
|
806
|
+
const needKey = () => {
|
|
807
|
+
const a = api();
|
|
808
|
+
if (!a)
|
|
809
|
+
throw new Error("Set APPEEKY_API_KEY: AI visibility comes from Appeeky AI Visibility.");
|
|
810
|
+
return a;
|
|
811
|
+
};
|
|
812
|
+
const watched = (project) => {
|
|
813
|
+
const t = visibilityTarget(project);
|
|
814
|
+
if (!t)
|
|
815
|
+
throw new Error("Set stores.appStore.appId (numeric) or stores.googlePlay.packageName first.");
|
|
816
|
+
return t;
|
|
817
|
+
};
|
|
818
|
+
visibility
|
|
819
|
+
.command("init")
|
|
820
|
+
.description("Write the intents and prompts Appeeky tracks into appeeky.spec.yaml; with none yet, have Appeeky write them from your listing")
|
|
821
|
+
.option("--country <cc>", "Storefront (default: aiVisibility.countries, or the first under targets)")
|
|
822
|
+
.action(async (opts) => {
|
|
823
|
+
const project = await load();
|
|
824
|
+
const a = needKey();
|
|
825
|
+
const t = watched(project);
|
|
826
|
+
const countries = opts.country ? [opts.country.toLowerCase()] : t.countries;
|
|
827
|
+
const spec = project.spec;
|
|
828
|
+
spec.aiVisibility ??= { intents: {} };
|
|
829
|
+
const report = [];
|
|
830
|
+
for (const country of countries) {
|
|
831
|
+
const remote = await readRemoteIntents(a, t.appId, country);
|
|
832
|
+
if (remote.length > 0) {
|
|
833
|
+
spec.aiVisibility.intents[country] = remote;
|
|
834
|
+
report.push({ country, intents: remote.length });
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
const r = await bootstrapIntents(a, t.appId, country, languageFor(project, country));
|
|
838
|
+
report.push({ country, intents: 0, bootstrapped: r.queued || r.already });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
// A storefront asked for by --country is watched from now on.
|
|
842
|
+
if (opts.country && !t.countries.includes(countries[0]))
|
|
843
|
+
spec.aiVisibility.countries = [...t.countries, countries[0]];
|
|
844
|
+
if (report.some((r) => r.intents > 0))
|
|
845
|
+
await writeSpec(project.root, spec);
|
|
846
|
+
if (g().json) {
|
|
847
|
+
printJson({ store: t.store, appId: t.appId, report });
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
for (const r of report) {
|
|
851
|
+
if (r.intents > 0)
|
|
852
|
+
console.log(`${c.green("✓")} ${r.country.toUpperCase()}: wrote ${r.intents} intents to aiVisibility.intents in appeeky.spec.yaml`);
|
|
853
|
+
else
|
|
854
|
+
console.log(`${c.yellow("…")} ${r.country.toUpperCase()}: Appeeky is writing intents and prompts from your listing. Run appeeky-spec visibility init again in a few minutes.`);
|
|
855
|
+
}
|
|
856
|
+
if (report.some((r) => r.intents > 0))
|
|
857
|
+
console.log(c.dim("\nEdit them in the spec, then send the changes with appeeky-spec visibility sync."));
|
|
858
|
+
});
|
|
859
|
+
visibility
|
|
860
|
+
.command("sync")
|
|
861
|
+
.description("Send the intents and prompts in appeeky.spec.yaml to Appeeky AI Visibility")
|
|
862
|
+
.option("--prune", "Also archive intents and prompts that are not in the spec", false)
|
|
863
|
+
.option("--dry-run", "Only show what would change", false)
|
|
864
|
+
.action(async (opts) => {
|
|
865
|
+
const project = await load();
|
|
866
|
+
const a = needKey();
|
|
867
|
+
const t = watched(project);
|
|
868
|
+
const intents = project.spec.aiVisibility?.intents ?? {};
|
|
869
|
+
if (Object.keys(intents).length === 0)
|
|
870
|
+
throw new Error("No aiVisibility.intents in appeeky.spec.yaml. Start with appeeky-spec visibility init.");
|
|
871
|
+
const out = [];
|
|
872
|
+
for (const [country, wanted] of Object.entries(intents)) {
|
|
873
|
+
const plan = await planSync(a, t.appId, country, wanted);
|
|
874
|
+
const applied = opts.dryRun ? null : await applySync(a, t.appId, plan, languageFor(project, country), opts.prune);
|
|
875
|
+
out.push({ plan, applied });
|
|
876
|
+
}
|
|
877
|
+
if (g().json) {
|
|
878
|
+
printJson(out);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
for (const { plan, applied } of out) {
|
|
882
|
+
const cc = plan.country.toUpperCase();
|
|
883
|
+
for (const i of plan.addIntents)
|
|
884
|
+
console.log(`${c.green("+")} ${cc} intent "${i.label}" (${i.prompts.length} prompts)`);
|
|
885
|
+
for (const p of plan.addPrompts)
|
|
886
|
+
console.log(`${c.green("+")} ${cc} prompt under "${p.label}": ${p.text}`);
|
|
887
|
+
const drop = opts.prune ? c.red("-") : c.dim("·");
|
|
888
|
+
for (const i of plan.extraIntents)
|
|
889
|
+
console.log(`${drop} ${cc} intent "${i.label}"${opts.prune ? "" : c.dim(" (only in Appeeky; --prune archives it)")}`);
|
|
890
|
+
for (const p of plan.extraPrompts)
|
|
891
|
+
console.log(`${drop} ${cc} prompt under "${p.label}": ${p.text}${opts.prune ? "" : c.dim(" (only in Appeeky)")}`);
|
|
892
|
+
const nothing = !plan.addIntents.length && !plan.addPrompts.length && !plan.extraIntents.length && !plan.extraPrompts.length;
|
|
893
|
+
if (nothing)
|
|
894
|
+
console.log(c.dim(`${cc}: in sync.`));
|
|
895
|
+
else if (applied)
|
|
896
|
+
console.log(c.dim(`${cc}: added ${applied.intents} intents and ${applied.prompts} prompts${opts.prune ? `, archived ${applied.archived}` : ""}.`));
|
|
897
|
+
}
|
|
898
|
+
if (opts.dryRun)
|
|
899
|
+
console.log(c.dim("\nDry run: nothing was sent."));
|
|
900
|
+
else
|
|
901
|
+
console.log(c.dim("\nThe next scheduled scan asks the assistants. To ask now: appeeky-spec visibility scan (uses AI Visibility credits)."));
|
|
902
|
+
});
|
|
903
|
+
visibility
|
|
904
|
+
.command("pull")
|
|
905
|
+
.description("Write what the assistants answer to appeeky/visibility/<country>.yaml")
|
|
906
|
+
.option("--country <cc>", "One storefront only")
|
|
907
|
+
.action(async (opts) => {
|
|
908
|
+
const project = await load();
|
|
909
|
+
const a = needKey();
|
|
910
|
+
const results = await pullVisibility(a, project, opts.country ? [opts.country.toLowerCase()] : undefined);
|
|
911
|
+
if (g().json) {
|
|
912
|
+
printJson(results.map(({ data, ...r }) => ({ ...r, summary: data?.summary })));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
for (const r of results) {
|
|
916
|
+
const cc = r.country.toUpperCase();
|
|
917
|
+
if (r.error) {
|
|
918
|
+
console.log(`${c.red("✗")} ${cc}: ${r.error}`);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const d = r.data;
|
|
922
|
+
if (d.intents.length === 0) {
|
|
923
|
+
console.log(`${c.yellow("!")} ${cc}: no intents tracked yet. Start with appeeky-spec visibility init.`);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
const s = d.summary;
|
|
927
|
+
console.log(`${c.green("✓")} ${r.file.replace(project.root + "/", "")} ${c.green(`${s.winning} named`)} · ${c.yellow(`${s.weak} weak`)} · ${c.red(`${s.missing} missing`)}${s.noData ? c.dim(` · ${s.noData} not scanned yet`) : ""}`);
|
|
928
|
+
for (const i of d.intents.filter((x) => x.status === "missing").slice(0, 3))
|
|
929
|
+
console.log(` ${c.red("missing")} "${i.label}"${i.namedInstead ? c.dim(`: they name ${i.namedInstead.slice(0, 3).join(", ")}`) : ""}`);
|
|
930
|
+
for (const ch of r.changes)
|
|
931
|
+
console.log(` ${c.yellow("changed")} ${ch}`);
|
|
932
|
+
if (d.fixes.length)
|
|
933
|
+
console.log(c.dim(` ${d.fixes.length} suggested fixes in the file.`));
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
visibility
|
|
937
|
+
.command("scan")
|
|
938
|
+
.description("Ask the assistants now instead of waiting for the schedule (uses AI Visibility credits)")
|
|
939
|
+
.option("--country <cc>", "One storefront only")
|
|
940
|
+
.action(async (opts) => {
|
|
941
|
+
const project = await load();
|
|
942
|
+
const a = needKey();
|
|
943
|
+
const t = watched(project);
|
|
944
|
+
const countries = opts.country ? [opts.country.toLowerCase()] : t.countries;
|
|
945
|
+
for (const country of countries)
|
|
946
|
+
await scanVisibility(a, t.appId, country);
|
|
947
|
+
if (g().json) {
|
|
948
|
+
printJson({ scanned: countries });
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
console.log(c.green(`Scan started for ${countries.map((x) => x.toUpperCase()).join(", ")}.`) + c.dim(" Pull the results in a few minutes: appeeky-spec visibility pull"));
|
|
952
|
+
});
|
|
953
|
+
const category = program.command("category").description("Your rank in the category chart, one row a day in appeeky/category.csv");
|
|
954
|
+
category
|
|
955
|
+
.command("pull")
|
|
956
|
+
.description("Record today's category rank (App Store: no key needed; Google Play: APPEEKY_API_KEY)")
|
|
957
|
+
.action(async () => {
|
|
958
|
+
const project = await load();
|
|
959
|
+
const { plans, notes } = await categoryPlans(project);
|
|
960
|
+
const a = api();
|
|
961
|
+
if (!a && plans.some((p) => p.store === "google-play"))
|
|
962
|
+
notes.push("Google Play: set APPEEKY_API_KEY to read its chart.");
|
|
963
|
+
const history = await readCategoryHistory(project.root);
|
|
964
|
+
const { ranks, failed } = await readCategoryRanks(plans, chartSources(a));
|
|
965
|
+
const file = ranks.length ? await saveCategoryRanks(project.root, ranks) : undefined;
|
|
966
|
+
if (g().json) {
|
|
967
|
+
printJson({ plans, ranks, failed, notes, file });
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
for (const r of ranks) {
|
|
971
|
+
const prev = previousRank(history, r);
|
|
972
|
+
const now = r.rank === null ? "not in the top 100" : `#${r.rank}`;
|
|
973
|
+
let move = "";
|
|
974
|
+
if (prev) {
|
|
975
|
+
const d = (prev.rank ?? 101) - (r.rank ?? 101);
|
|
976
|
+
move = d > 0 ? c.green(` ▲${d}`) : d < 0 ? c.red(` ▼${-d}`) : c.dim(" =");
|
|
977
|
+
move += c.dim(` since ${prev.date}`);
|
|
978
|
+
}
|
|
979
|
+
console.log(`${storeLabel(r.store).padEnd(12)} ${r.country.toUpperCase()} ${r.chart} · ${r.name ?? r.category} ${c.bold(now)}${move}`);
|
|
980
|
+
}
|
|
981
|
+
for (const f of failed)
|
|
982
|
+
console.log(c.yellow(`! ${f}`));
|
|
983
|
+
for (const n of notes)
|
|
984
|
+
console.log(c.dim(n));
|
|
985
|
+
if (file)
|
|
986
|
+
console.log(c.dim(`\nRecorded in ${categoryFile(project.root).replace(project.root + "/", "")}. verify reads it as category_rank.`));
|
|
987
|
+
});
|
|
988
|
+
const attribution = program.command("attribution").description("Install attribution from Appeeky Analytics");
|
|
989
|
+
attribution
|
|
990
|
+
.command("init")
|
|
991
|
+
.description("Create the app in Appeeky Analytics for each store, write attribution: and print the SDK key")
|
|
992
|
+
.option("--self [url]", "Your own Appeeky Analytics server (with ATTRIBUTION_TOKEN); without it, Appeeky's hosted service (with APPEEKY_API_KEY)")
|
|
993
|
+
.action(async (opts) => {
|
|
994
|
+
const project = await load();
|
|
995
|
+
const via = opts.self ? "self" : "hosted";
|
|
996
|
+
const url = typeof opts.self === "string" ? opts.self : project.spec.attribution?.url;
|
|
997
|
+
if (specStores(project.spec).length === 0)
|
|
998
|
+
throw new Error("Add the App Store app id or the Google Play package under stores: first.");
|
|
999
|
+
let apps;
|
|
1000
|
+
if (via === "self") {
|
|
1001
|
+
if (!url)
|
|
1002
|
+
throw new Error("--self <url>: your Appeeky Analytics server, e.g. https://analytics.example.workers.dev");
|
|
1003
|
+
if (!process.env.ATTRIBUTION_TOKEN)
|
|
1004
|
+
throw new Error("Set ATTRIBUTION_TOKEN to your server's ADMIN_TOKEN.");
|
|
1005
|
+
apps = await initSelfHosted(project.spec, url, process.env.ATTRIBUTION_TOKEN);
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
const a = api();
|
|
1009
|
+
if (!a)
|
|
1010
|
+
throw new Error("Set APPEEKY_API_KEY to turn attribution on in Appeeky.");
|
|
1011
|
+
apps = await initHosted(project.spec, a);
|
|
1012
|
+
}
|
|
1013
|
+
project.spec.attribution = attributionBlock(via, apps, url);
|
|
1014
|
+
await writeSpec(project.root, project.spec);
|
|
1015
|
+
if (g().json) {
|
|
1016
|
+
printJson({ apps, attribution: project.spec.attribution });
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
for (const a of apps)
|
|
1020
|
+
console.log(`${c.green(a.created ? "Created" : "Found")} ${storeLabel(a.store)} app ${c.bold(a.id)} · key ${a.publishableKey}`);
|
|
1021
|
+
console.log(c.dim("Wrote attribution: to appeeky.spec.yaml.\n"));
|
|
1022
|
+
for (const line of nextSteps(via, apps, url))
|
|
1023
|
+
console.log(line);
|
|
1024
|
+
});
|
|
1025
|
+
const data = program.command("data").description("Your app's analytics, ads and revenue as CSV files an agent can read (needs APPEEKY_API_KEY)");
|
|
1026
|
+
data
|
|
1027
|
+
.command("pull")
|
|
1028
|
+
.description("App Store Connect, Google Play, Apple Search Ads, RevenueCat, Meta and TikTok, per day")
|
|
1029
|
+
.option("--days <n>", "How many days, up to yesterday", "90")
|
|
1030
|
+
.action(async (opts) => {
|
|
1031
|
+
const project = await load();
|
|
1032
|
+
const a = api();
|
|
1033
|
+
const attribution = attributionClients(project.spec, a);
|
|
1034
|
+
if (!a && !hasClients(attribution))
|
|
1035
|
+
throw new Error("Set APPEEKY_API_KEY (data from the connections in Appeeky), or attribution: with via: self and ATTRIBUTION_TOKEN.");
|
|
1036
|
+
const result = await pullData(a, project, Number(opts.days) || 90, new Date(), hasClients(attribution) ? attribution : undefined);
|
|
1037
|
+
if (g().json) {
|
|
1038
|
+
printJson(result);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
for (const f of result.files)
|
|
1042
|
+
console.log(`${c.green("✓")} ${f.path.replace(project.root + "/", "").padEnd(52)} ${String(f.rows).padStart(5)} rows ${c.dim(f.from && f.to ? `${f.from} → ${f.to}` : "")}`);
|
|
1043
|
+
for (const e of result.errors)
|
|
1044
|
+
console.log(`${c.red("✗")} ${e.source.padEnd(42)} ${c.dim(e.message)}`);
|
|
1045
|
+
if (result.files.length === 0 && result.errors.length === 0)
|
|
1046
|
+
console.log(c.dim("Nothing to pull. Connect your stores, Search Ads or RevenueCat in Appeeky."));
|
|
1047
|
+
if (!project.spec.data.commit && result.files.length)
|
|
1048
|
+
console.log(c.dim("\nIn .appeeky/data/ (gitignored). Set data: { commit: true } in appeeky.spec.yaml to version it with the spec."));
|
|
1049
|
+
});
|
|
1050
|
+
program
|
|
1051
|
+
.command("revert")
|
|
1052
|
+
.description("Put the values from before a release back into the spec (then run push)")
|
|
1053
|
+
.argument("<release>", "Release id from `appeeky-spec log`")
|
|
1054
|
+
.action(async (id) => {
|
|
1055
|
+
const project = await load();
|
|
1056
|
+
const release = (await readReleases(project.root)).find((r) => r.id === id || r.id.startsWith(id));
|
|
1057
|
+
if (!release)
|
|
1058
|
+
throw new Error(`No release ${id}. See \`appeeky-spec log\`.`);
|
|
1059
|
+
const inverse = invertChanges(release.changes.map((ch) => ({ ...ch, store: release.store, newLocale: false })));
|
|
1060
|
+
const touched = new Set();
|
|
1061
|
+
for (const ch of inverse) {
|
|
1062
|
+
const listings = project.listings[release.store];
|
|
1063
|
+
listings[ch.locale] = { ...listings[ch.locale], [ch.field]: ch.after };
|
|
1064
|
+
touched.add(ch.locale);
|
|
1065
|
+
}
|
|
1066
|
+
for (const locale of touched) {
|
|
1067
|
+
await writeListing(project.root, release.store, locale, project.listings[release.store][locale]);
|
|
1068
|
+
}
|
|
1069
|
+
if (g().json)
|
|
1070
|
+
printJson({ release: release.id, reverted: inverse.length });
|
|
1071
|
+
else {
|
|
1072
|
+
console.log(`${c.green("Restored")} ${inverse.length} fields from before ${release.id} in the spec. Review with \`appeeky-spec diff\`, then \`appeeky-spec push\`.`);
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
program
|
|
1076
|
+
.command("suggest")
|
|
1077
|
+
.description("Words to add to an App Store keyword field, from missing targets and Appeeky keyword opportunities")
|
|
1078
|
+
.option("--locale <locale>", "Listing locale (default: the primary locale)")
|
|
1079
|
+
.option("--country <cc>", "Storefront for opportunities", "us")
|
|
1080
|
+
.option("--apply <words...>", "Add these words to the keyword field in the spec")
|
|
1081
|
+
.action(async (opts) => {
|
|
1082
|
+
const project = await load();
|
|
1083
|
+
const locale = opts.locale ?? project.spec.app.primaryLocale;
|
|
1084
|
+
if (!project.listings["app-store"][locale])
|
|
1085
|
+
throw new Error(`No App Store listing for ${locale}. Run \`appeeky-spec pull\` first.`);
|
|
1086
|
+
if (opts.apply) {
|
|
1087
|
+
let field = project.listings["app-store"][locale].keywords ?? "";
|
|
1088
|
+
const skipped = [];
|
|
1089
|
+
for (const w of opts.apply) {
|
|
1090
|
+
const next = appendKeyword(field, w);
|
|
1091
|
+
if (next === null)
|
|
1092
|
+
skipped.push(w);
|
|
1093
|
+
else
|
|
1094
|
+
field = next;
|
|
1095
|
+
}
|
|
1096
|
+
await writeListing(project.root, "app-store", locale, { ...project.listings["app-store"][locale], keywords: field });
|
|
1097
|
+
if (g().json)
|
|
1098
|
+
printJson({ locale, keywords: field, skipped });
|
|
1099
|
+
else
|
|
1100
|
+
console.log(`${c.green("Updated")} ${locale} keywords → ${field}${skipped.length ? c.yellow(`\nDid not fit: ${skipped.join(", ")}`) : ""}`);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const a = api();
|
|
1104
|
+
const appId = rankAppIds(project)["app-store"];
|
|
1105
|
+
const candidates = a && appId ? await a.opportunities(appId, opts.country.toLowerCase(), "app-store").catch(() => []) : [];
|
|
1106
|
+
const suggestions = keywordSuggestions(project, locale, candidates);
|
|
1107
|
+
if (g().json) {
|
|
1108
|
+
printJson({ locale, suggestions });
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (suggestions.length === 0)
|
|
1112
|
+
console.log(c.dim("Nothing to add: every target word is already indexed."));
|
|
1113
|
+
for (const s of suggestions.slice(0, 25)) {
|
|
1114
|
+
const pop = s.popularity !== undefined ? c.dim(` pop ${s.popularity}`) : "";
|
|
1115
|
+
console.log(`${s.fits ? c.green("+") : c.dim("·")} ${s.word.padEnd(18)}${pop} ${c.dim(`helps: ${s.helps.slice(0, 3).join(", ")}`)}${s.fits ? "" : c.dim(" (does not fit)")}`);
|
|
1116
|
+
}
|
|
1117
|
+
if (!a)
|
|
1118
|
+
console.log(c.dim("\nSet APPEEKY_API_KEY to add Appeeky keyword opportunities."));
|
|
1119
|
+
else
|
|
1120
|
+
console.log(c.dim(`\nAdd with: appeeky-spec suggest --locale ${locale} --apply <words...>`));
|
|
1121
|
+
});
|
|
1122
|
+
program
|
|
1123
|
+
.command("verify")
|
|
1124
|
+
.description("Measure releases against the ranks recorded before each push (day 7 and day 14)")
|
|
1125
|
+
.argument("[releases...]", "Release ids (default: every release that reached a milestone)")
|
|
1126
|
+
.option("--now", "Measure now, even before day 7", false)
|
|
1127
|
+
.option("--playbook", "Add one line per result to appeeky/playbook.md", false)
|
|
1128
|
+
.action(async (ids, opts) => {
|
|
1129
|
+
const project = await load();
|
|
1130
|
+
const a = api();
|
|
1131
|
+
const attribution = attributionClients(project.spec, a);
|
|
1132
|
+
const visibility = a ? visibilityMetricSource(a, project) : null;
|
|
1133
|
+
const metrics = combineSources([
|
|
1134
|
+
...(a ? [metricSourceFor(a, project.spec)] : []),
|
|
1135
|
+
...(visibility ? [visibility] : []),
|
|
1136
|
+
categoryMetricSource(project.root),
|
|
1137
|
+
reviewMetricSource(project.root),
|
|
1138
|
+
...(hasClients(attribution) ? [attributionMetricSource(attribution)] : []),
|
|
1139
|
+
]);
|
|
1140
|
+
const results = await verifyReleases(project, rankSource(), { ids: ids.length ? ids : undefined, force: opts.now, metrics });
|
|
1141
|
+
const measured = metrics ? await measureExperiments(project, metrics) : [];
|
|
1142
|
+
const lines = [...results.map(playbookLine), ...measured.map(experimentPlaybookLine)];
|
|
1143
|
+
const added = opts.playbook ? await appendPlaybook(project.root, lines) : 0;
|
|
1144
|
+
const experiments = dueExperiments(await readExperiments(project.root));
|
|
1145
|
+
await writeLoops(project.root);
|
|
1146
|
+
if (g().json) {
|
|
1147
|
+
printJson({
|
|
1148
|
+
results: results.map((r) => ({ release: r.release.id, ...r.check })),
|
|
1149
|
+
closedExperiments: measured,
|
|
1150
|
+
playbookLines: added,
|
|
1151
|
+
dueExperiments: experiments.map((e) => e.id),
|
|
1152
|
+
});
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (results.length === 0)
|
|
1156
|
+
console.log(c.dim("No release is due. Releases are measured on day 7 and day 14 (or pass --now)."));
|
|
1157
|
+
for (const { release, check } of results) {
|
|
1158
|
+
const tone = check.verdict === "won" ? c.green : check.verdict === "lost" ? c.red : c.yellow;
|
|
1159
|
+
const by = check.decidedBy ? c.dim(` (ranks did not move; decided by ${check.decidedBy.map((m) => METRICS[m]?.label ?? m).join(", ")})`) : "";
|
|
1160
|
+
console.log(`${c.bold(release.id)} day ${check.day}: ${tone(check.verdict)}${by} ${check.improved.length} up · ${check.dropped.length} down · ${check.regressions.length} top-20 lost`);
|
|
1161
|
+
if (check.control) {
|
|
1162
|
+
const k = check.control;
|
|
1163
|
+
const sign = (n) => (n > 0 ? `+${n}` : `${n}`);
|
|
1164
|
+
console.log(` ${c.dim("control")} changed (${k.changed.join(", ")}) ${sign(k.changedMove)} places · unchanged (${k.control.join(", ")}) ${sign(k.controlMove)} · net ${sign(k.net)}`);
|
|
1165
|
+
}
|
|
1166
|
+
if (check.note)
|
|
1167
|
+
console.log(` ${c.yellow(check.note)}`);
|
|
1168
|
+
for (const m of check.regressions)
|
|
1169
|
+
console.log(` ${c.red("lost")} ${m.country.toUpperCase()} "${m.keyword}" ${m.before} → ${m.after ?? "not ranked"}`);
|
|
1170
|
+
for (const m of check.improved.slice(0, 5))
|
|
1171
|
+
console.log(` ${c.green("up")} ${m.country.toUpperCase()} "${m.keyword}" ${m.before ?? "not ranked"} → ${m.after}`);
|
|
1172
|
+
for (const m of check.metrics?.changes ?? []) {
|
|
1173
|
+
const tone = m.outcome === "better" ? c.green : m.outcome === "worse" ? c.red : c.dim;
|
|
1174
|
+
const usual = m.noise !== undefined && m.noise > 0.05 ? c.dim(` (moves ±${Math.round(m.noise * 100)}% on its own)`) : "";
|
|
1175
|
+
console.log(` ${tone(m.outcome.padEnd(6))} ${formatChange(m, check.metrics?.after.currency)}${usual}`);
|
|
1176
|
+
}
|
|
1177
|
+
if (check.verdict === "lost")
|
|
1178
|
+
console.log(c.yellow(` Consider: appeeky-spec revert ${release.id}`));
|
|
1179
|
+
}
|
|
1180
|
+
for (const e of measured) {
|
|
1181
|
+
const tone = e.status === "won" ? c.green : e.status === "lost" ? c.red : c.yellow;
|
|
1182
|
+
console.log(`${c.bold(e.id)}: ${tone(e.status)} ${e.metric} ${e.baseline !== undefined ? `${round(e.baseline)} → ` : ""}${round(e.result?.value)}`);
|
|
1183
|
+
}
|
|
1184
|
+
if (!a && results.length > 0)
|
|
1185
|
+
console.log(c.dim("\nSet APPEEKY_API_KEY to measure conversion, downloads, revenue and ads too."));
|
|
1186
|
+
if (added > 0)
|
|
1187
|
+
console.log(c.dim(`\nAdded ${added} line${added === 1 ? "" : "s"} to appeeky/playbook.md.`));
|
|
1188
|
+
for (const e of experiments)
|
|
1189
|
+
console.log(c.yellow(`Experiment ${e.id} is due: appeeky-spec experiment close ${e.id} --value <${e.metric}>`));
|
|
1190
|
+
});
|
|
1191
|
+
const experiment = program.command("experiment").description("Growth experiments outside the listing: screenshots, UGC, ads, pricing");
|
|
1192
|
+
experiment
|
|
1193
|
+
.command("add")
|
|
1194
|
+
.description("Start an experiment")
|
|
1195
|
+
.requiredOption("--hypothesis <text>", "What you expect and why")
|
|
1196
|
+
.requiredOption("--metric <name>", `The one number that decides it. Measured for you: ${Object.keys(METRICS).join(", ")}`)
|
|
1197
|
+
.option("--type <type>", "screenshots | ugc | ads | pricing | other", "other")
|
|
1198
|
+
.option("--persona <id>", "Persona id from appeeky.spec.yaml")
|
|
1199
|
+
.option("--store <store>", "app-store | google-play")
|
|
1200
|
+
.option("--country <cc>", "Storefront")
|
|
1201
|
+
.option("--baseline <n>", "Metric value before", parseFloat)
|
|
1202
|
+
.option("--target <n>", "Metric value that counts as a win", parseFloat)
|
|
1203
|
+
.option("--days <n>", "Check after this many days", "14")
|
|
1204
|
+
.option("--asset <paths...>", "Files, links or ad ids that ran")
|
|
1205
|
+
.option("--link [channel]", "Create a smart link for the experiment (needs attribution:); optional channel: tiktok, instagram, creator, tiktok_ads, meta_ads…")
|
|
1206
|
+
.action(async (o) => {
|
|
1207
|
+
const project = await load();
|
|
1208
|
+
if (o.persona && !project.spec.personas.some((p) => p.id === o.persona))
|
|
1209
|
+
throw new Error(`No persona "${o.persona}" in ${SPEC_FILE}.`);
|
|
1210
|
+
const now = new Date();
|
|
1211
|
+
const days = Number(o.days) || 14;
|
|
1212
|
+
let baseline = o.baseline;
|
|
1213
|
+
const a = api();
|
|
1214
|
+
if (baseline === undefined && a && isMetricName(o.metric)) {
|
|
1215
|
+
// The baseline is the metric over the same number of days before the start.
|
|
1216
|
+
const app = experimentApp(project, { store: o.store });
|
|
1217
|
+
if (app)
|
|
1218
|
+
baseline = await metricBefore(metricSourceFor(a, project.spec), app.store, app.appId, o.metric, now.toISOString(), days).catch(() => undefined);
|
|
1219
|
+
}
|
|
1220
|
+
const id = experimentId(now, o.hypothesis);
|
|
1221
|
+
let link;
|
|
1222
|
+
if (o.link) {
|
|
1223
|
+
const client = attributionClient(project.spec, a);
|
|
1224
|
+
if (!isClient(client))
|
|
1225
|
+
throw new Error(`--link needs attribution. ${client.missing}`);
|
|
1226
|
+
const made = await createExperimentLink(client, id, { appStoreId: project.spec.stores.appStore?.appId, packageName: project.spec.stores.googlePlay?.packageName }, typeof o.link === "string" ? o.link : undefined);
|
|
1227
|
+
const host = project.spec.attribution?.via === "self" ? project.spec.attribution.url?.replace(/\/$/, "") : "https://ingest.appeeky.com";
|
|
1228
|
+
link = { slug: made.slug, url: `${host}/l/${made.slug}`, store: linkStore(project.spec) };
|
|
1229
|
+
if (o.baseline === undefined && o.metric.startsWith("link_"))
|
|
1230
|
+
baseline = 0;
|
|
1231
|
+
}
|
|
1232
|
+
const e = await writeExperiment(project.root, {
|
|
1233
|
+
...(link ? { link } : {}),
|
|
1234
|
+
id,
|
|
1235
|
+
type: o.type,
|
|
1236
|
+
hypothesis: o.hypothesis,
|
|
1237
|
+
persona: o.persona,
|
|
1238
|
+
store: o.store,
|
|
1239
|
+
country: o.country,
|
|
1240
|
+
metric: o.metric,
|
|
1241
|
+
baseline,
|
|
1242
|
+
target: o.target,
|
|
1243
|
+
startedAt: now.toISOString(),
|
|
1244
|
+
checkAfterDays: days,
|
|
1245
|
+
assets: o.asset ?? [],
|
|
1246
|
+
});
|
|
1247
|
+
await writeLoops(project.root);
|
|
1248
|
+
if (g().json)
|
|
1249
|
+
printJson({ experiment: e });
|
|
1250
|
+
else {
|
|
1251
|
+
console.log(`${c.green("Started")} ${e.id} · check on ${new Date(now.getTime() + e.checkAfterDays * 86_400_000).toISOString().slice(0, 10)}`);
|
|
1252
|
+
if (e.baseline !== undefined)
|
|
1253
|
+
console.log(c.dim(` baseline ${e.metric} ${round(e.baseline)}${o.baseline === undefined ? " (last " + days + " days)" : ""}`));
|
|
1254
|
+
else if (isMetricName(e.metric) && !a)
|
|
1255
|
+
console.log(c.dim(" Set APPEEKY_API_KEY to measure the baseline and the result for you."));
|
|
1256
|
+
if (e.link?.url)
|
|
1257
|
+
console.log(` ${c.bold("link")} ${e.link.url} ${c.dim("· post this link; its installs and revenue are counted to it")}`);
|
|
1258
|
+
if (e.link && iosOnlyLink(project.spec, e.metric))
|
|
1259
|
+
console.log(c.yellow(IOS_LINK_NOTE));
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
experiment
|
|
1263
|
+
.command("list")
|
|
1264
|
+
.description("Experiments, newest first")
|
|
1265
|
+
.action(async () => {
|
|
1266
|
+
const project = await load();
|
|
1267
|
+
const all = await readExperiments(project.root);
|
|
1268
|
+
const due = new Set(dueExperiments(all).map((e) => e.id));
|
|
1269
|
+
if (g().json) {
|
|
1270
|
+
printJson({ experiments: all.map((e) => ({ ...e, due: due.has(e.id) })) });
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (all.length === 0)
|
|
1274
|
+
console.log(c.dim("No experiments yet."));
|
|
1275
|
+
for (const e of all) {
|
|
1276
|
+
const status = due.has(e.id) ? c.yellow("due") : e.status === "won" ? c.green("won") : e.status === "lost" ? c.red("lost") : c.dim(e.status);
|
|
1277
|
+
console.log(`${c.bold(e.id)} ${e.type} · ${status}${e.result?.value !== undefined ? ` · ${e.metric} ${e.result.value}` : ""}\n ${c.dim(e.hypothesis)}`);
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
experiment
|
|
1281
|
+
.command("close")
|
|
1282
|
+
.description("Record the result of an experiment")
|
|
1283
|
+
.argument("<id>")
|
|
1284
|
+
.option("--value <n>", "Measured metric value", parseFloat)
|
|
1285
|
+
.option("--note <text>", "What happened")
|
|
1286
|
+
.option("--playbook", "Add the result to appeeky/playbook.md", false)
|
|
1287
|
+
.action(async (id, o) => {
|
|
1288
|
+
const project = await load();
|
|
1289
|
+
const e = (await readExperiments(project.root)).find((x) => x.id === id || x.id.startsWith(id));
|
|
1290
|
+
if (!e)
|
|
1291
|
+
throw new Error(`No experiment ${id}. See \`appeeky-spec experiment list\`.`);
|
|
1292
|
+
let value = o.value;
|
|
1293
|
+
const a = api();
|
|
1294
|
+
if (value === undefined && e.link && (e.metric === "link_clicks" || e.metric === "link_installs" || e.metric === "link_revenue")) {
|
|
1295
|
+
// The experiment's own smart link: its clicks, or the installs and revenue counted to it.
|
|
1296
|
+
const client = attributionClient(project.spec, a, process.env, e.link.store);
|
|
1297
|
+
const stats = isClient(client) ? await linkResult(client, e.link.slug).catch(() => null) : null;
|
|
1298
|
+
if (stats)
|
|
1299
|
+
value = e.metric === "link_clicks" ? stats.clicks.total : e.metric === "link_installs" ? stats.installs : stats.revenueUsd;
|
|
1300
|
+
}
|
|
1301
|
+
if (value === undefined && a && isMetricName(e.metric)) {
|
|
1302
|
+
const app = experimentApp(project, e);
|
|
1303
|
+
if (app)
|
|
1304
|
+
value = await metricAfter(metricSourceFor(a, project.spec), app.store, app.appId, e.metric, e.startedAt, e.checkAfterDays).catch(() => undefined);
|
|
1305
|
+
}
|
|
1306
|
+
const closed = await writeExperiment(project.root, closeExperiment(e, value, o.note));
|
|
1307
|
+
if (o.playbook)
|
|
1308
|
+
await appendPlaybook(project.root, [experimentPlaybookLine(closed)]);
|
|
1309
|
+
await writeLoops(project.root);
|
|
1310
|
+
if (g().json)
|
|
1311
|
+
printJson({ experiment: closed });
|
|
1312
|
+
else
|
|
1313
|
+
console.log(`${closed.id}: ${closed.status}`);
|
|
1314
|
+
});
|
|
1315
|
+
const screenshots = program.command("screenshots").description("App Store screenshots in appeeky/screenshots/");
|
|
1316
|
+
screenshots
|
|
1317
|
+
.command("pull")
|
|
1318
|
+
.description("Download the live screenshots of every locale")
|
|
1319
|
+
.option("--locale <locales...>", "Only these locales")
|
|
1320
|
+
.action(async (opts) => {
|
|
1321
|
+
const project = await load();
|
|
1322
|
+
const handle = storeHandle(project, "app-store", transportOverrides());
|
|
1323
|
+
const state = await handle.adapter.pull();
|
|
1324
|
+
const count = await pullScreenshots(project.root, handle.transport, state, { locales: opts.locale });
|
|
1325
|
+
if (g().json)
|
|
1326
|
+
printJson({ downloaded: count });
|
|
1327
|
+
else
|
|
1328
|
+
console.log(`${c.green("Downloaded")} ${count} screenshots from ${state.target}`);
|
|
1329
|
+
});
|
|
1330
|
+
screenshots
|
|
1331
|
+
.command("check")
|
|
1332
|
+
.description("Sizes and counts per display type")
|
|
1333
|
+
.action(async () => {
|
|
1334
|
+
const project = await load();
|
|
1335
|
+
const issues = await checkScreenshots(project.root);
|
|
1336
|
+
if (g().json)
|
|
1337
|
+
printJson({ issues });
|
|
1338
|
+
else
|
|
1339
|
+
console.log(formatIssues(issues));
|
|
1340
|
+
if (hasErrors(issues))
|
|
1341
|
+
process.exitCode = 1;
|
|
1342
|
+
});
|
|
1343
|
+
program
|
|
1344
|
+
.command("report")
|
|
1345
|
+
.description("Markdown for a pull request: listing changes, check results and coverage against a base ref")
|
|
1346
|
+
.option("--base <ref>", "Git ref to compare with", "origin/main")
|
|
1347
|
+
.option("-o, --output <file>", "Write the report to a file")
|
|
1348
|
+
.action(async (opts) => {
|
|
1349
|
+
const project = await load();
|
|
1350
|
+
const base = await loadProjectAtRef(project.root, opts.base);
|
|
1351
|
+
const md = specReport(base, project);
|
|
1352
|
+
if (opts.output)
|
|
1353
|
+
await writeFile(opts.output, `${md}\n`);
|
|
1354
|
+
else
|
|
1355
|
+
console.log(md);
|
|
1356
|
+
if (hasErrors(lintProject(project)))
|
|
1357
|
+
process.exitCode = 1;
|
|
1358
|
+
});
|
|
1359
|
+
return program;
|
|
1360
|
+
}
|
|
1361
|
+
export async function run(argv = process.argv) {
|
|
1362
|
+
const program = buildProgram();
|
|
1363
|
+
try {
|
|
1364
|
+
await program.parseAsync(argv);
|
|
1365
|
+
}
|
|
1366
|
+
catch (err) {
|
|
1367
|
+
const message = err.message;
|
|
1368
|
+
if (program.opts().json)
|
|
1369
|
+
printJson({ error: message });
|
|
1370
|
+
else
|
|
1371
|
+
console.error(c.red(message));
|
|
1372
|
+
process.exitCode = 1;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
const STATE_TONE = { late: c.red, due: c.yellow, measuring: c.dim, closed: c.dim };
|
|
1376
|
+
function printLoop(l) {
|
|
1377
|
+
const state = STATE_TONE[l.state](l.state.padEnd(9));
|
|
1378
|
+
const when = l.nextCheck ? `next ${l.nextCheck}` : "";
|
|
1379
|
+
console.log(`${state} ${c.bold(l.id)} ${c.dim(`${l.kind}${l.store ? ` · ${l.store}` : ""}${when ? ` · ${when}` : ""}`)}`);
|
|
1380
|
+
console.log(` ${l.what}`);
|
|
1381
|
+
console.log(` ${c.dim("decided by")} ${l.metric}${l.result ? ` ${c.dim("·")} ${l.result}` : ""}`);
|
|
1382
|
+
for (const d of l.detail ?? [])
|
|
1383
|
+
console.log(` ${c.dim(d)}`);
|
|
1384
|
+
for (const w of l.warnings)
|
|
1385
|
+
console.log(` ${c.yellow("! " + w)}`);
|
|
1386
|
+
}
|
|
1387
|
+
//# sourceMappingURL=cli.js.map
|