@appeeky/spec 0.0.0-stage → 0.2.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 +636 -2
- package/agents/appeeky-ads.md +50 -0
- package/agents/appeeky-aso.md +58 -0
- package/agents/appeeky-releases.md +47 -0
- package/agents/appeeky-reviews.md +53 -0
- package/agents/appeeky-social.md +54 -0
- package/agents/appeeky-ugc.md +56 -0
- package/agents/cofounder.md +36 -0
- package/dist/ads.d.ts +42 -0
- package/dist/ads.js +71 -0
- package/dist/agents.d.ts +47 -0
- package/dist/agents.js +88 -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 +1960 -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/dataforseo.d.ts +139 -0
- package/dist/dataforseo.js +354 -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 +290 -0
- package/dist/metrics.js +254 -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 +10 -0
- package/dist/report.js +113 -0
- package/dist/reviews.d.ts +130 -0
- package/dist/reviews.js +287 -0
- package/dist/schema.d.ts +612 -0
- package/dist/schema.js +253 -0
- package/dist/screenshots.d.ts +23 -0
- package/dist/screenshots.js +116 -0
- package/dist/search-ads.d.ts +137 -0
- package/dist/search-ads.js +354 -0
- package/dist/seo.d.ts +87 -0
- package/dist/seo.js +391 -0
- package/dist/setup.d.ts +45 -0
- package/dist/setup.js +259 -0
- package/dist/social.d.ts +129 -0
- package/dist/social.js +189 -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 +99 -0
- package/dist/transport.js +170 -0
- package/dist/ugc-core.js +1 -0
- package/dist/ugc-local.d.ts +116 -0
- package/dist/ugc-local.js +174 -0
- package/dist/ugc.d.ts +149 -0
- package/dist/ugc.js +111 -0
- package/dist/verify.d.ts +70 -0
- package/dist/verify.js +134 -0
- package/dist/visibility-core.js +96 -0
- package/dist/visibility-local.d.ts +159 -0
- package/dist/visibility-local.js +246 -0
- package/dist/visibility.d.ts +152 -0
- package/dist/visibility.js +239 -0
- package/package.json +80 -4
- package/skills/appeeky-spec/SKILL.md +142 -0
- package/skills/appeeky-spec-experiments/SKILL.md +93 -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 +106 -0
package/dist/setup.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { installedTargets } from "./agents.js";
|
|
4
|
+
import { SPEC_DIR, SPEC_FILE } from "./files.js";
|
|
5
|
+
import { normalize, words } from "./text.js";
|
|
6
|
+
import { STORE_CLIS, onPath } from "./transport.js";
|
|
7
|
+
/** App Store apps matching a name, from Apple's public search. */
|
|
8
|
+
export async function findApps(term, country = "us", fetcher = fetch) {
|
|
9
|
+
const url = `https://itunes.apple.com/search?media=software&entity=software&limit=6&country=${encodeURIComponent(country)}&term=${encodeURIComponent(term)}`;
|
|
10
|
+
const res = await fetcher(url, { headers: { "User-Agent": "appeeky-spec" } });
|
|
11
|
+
if (!res.ok)
|
|
12
|
+
return [];
|
|
13
|
+
const json = (await res.json());
|
|
14
|
+
return (json.results ?? [])
|
|
15
|
+
.filter((r) => r.trackId && r.trackName)
|
|
16
|
+
.map((r) => ({
|
|
17
|
+
id: String(r.trackId),
|
|
18
|
+
name: r.trackName,
|
|
19
|
+
developer: r.sellerName ?? r.artistName ?? "",
|
|
20
|
+
bundleId: r.bundleId ?? "",
|
|
21
|
+
...(r.averageUserRating ? { rating: Math.round(r.averageUserRating * 10) / 10 } : {}),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
/** Whether a Google Play package has a public store page. */
|
|
25
|
+
export async function playPackageExists(pkg, fetcher = fetch) {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetcher(`https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en`, { method: "HEAD", headers: { "User-Agent": "Mozilla/5.0 appeeky-spec" } });
|
|
28
|
+
return res.ok;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// ── Targets ─────────────────────────────────────────────────────────────────
|
|
35
|
+
/** Words nobody searches with, at the start or the end of a phrase ("AI-powered", "the best"). */
|
|
36
|
+
const FILLER = new Set("a an the and or for with your my our to of in on by best top new free app apps powered based simple easy fast smart ultimate official".split(" "));
|
|
37
|
+
/**
|
|
38
|
+
* Searches the listing already speaks to: the parts of the name and subtitle between
|
|
39
|
+
* punctuation ("Sleep Sounds: Rain & Ocean" → "sleep sounds", "rain", "ocean"), their two-word
|
|
40
|
+
* runs, and keyword-field entries. The brand (the name's first part, alone) is left out.
|
|
41
|
+
*/
|
|
42
|
+
export function targetCandidates(project, limit = 14, storeName) {
|
|
43
|
+
const locale = project.spec.app.primaryLocale;
|
|
44
|
+
const asc = project.listings["app-store"][locale];
|
|
45
|
+
const play = project.listings["google-play"][locale] ?? Object.values(project.listings["google-play"])[0];
|
|
46
|
+
const name = asc?.name ?? play?.title ?? storeName ?? project.spec.app.name;
|
|
47
|
+
const subtitle = asc?.subtitle ?? play?.shortDescription ?? "";
|
|
48
|
+
const brand = normalize(project.spec.app.name).trim();
|
|
49
|
+
const out = [];
|
|
50
|
+
const add = (phrase) => {
|
|
51
|
+
const ws = words(phrase);
|
|
52
|
+
const p = ws.join(" ");
|
|
53
|
+
if (!p || p.length < 3 || p === brand || out.includes(p) || ws.length > 3)
|
|
54
|
+
return;
|
|
55
|
+
if (FILLER.has(ws[0]) || FILLER.has(ws[ws.length - 1]))
|
|
56
|
+
return;
|
|
57
|
+
out.push(p);
|
|
58
|
+
};
|
|
59
|
+
for (const text of [name, subtitle]) {
|
|
60
|
+
for (const part of text.split(/[:\-–—|,&+/()]| and | for | with /i)) {
|
|
61
|
+
add(part);
|
|
62
|
+
const ws = words(part);
|
|
63
|
+
for (let i = 0; i + 1 < ws.length; i++)
|
|
64
|
+
add(`${ws[i]} ${ws[i + 1]}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const k of (asc?.keywords ?? "").split(","))
|
|
68
|
+
add(k);
|
|
69
|
+
return out.slice(0, limit);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The searches to start with: the ones the app already ranks for (a target you are near is
|
|
73
|
+
* the cheapest win), best first, then two-word phrases from the listing.
|
|
74
|
+
*/
|
|
75
|
+
export async function suggestTargets(candidates, appId, country, source, count = 5) {
|
|
76
|
+
const ranks = appId ? await source.ranks("app-store", appId, country, candidates).catch(() => ({})) : {};
|
|
77
|
+
const ranked = candidates.map((keyword) => ({ keyword, rank: ranks[keyword] ?? null }));
|
|
78
|
+
const found = ranked.filter((r) => r.rank !== null).sort((a, b) => a.rank - b.rank);
|
|
79
|
+
const rest = ranked.filter((r) => r.rank === null).sort((a, b) => b.keyword.split(" ").length - a.keyword.split(" ").length);
|
|
80
|
+
return [...found, ...rest].slice(0, count);
|
|
81
|
+
}
|
|
82
|
+
// ── GitHub workflows ────────────────────────────────────────────────────────
|
|
83
|
+
export const WORKFLOWS = {
|
|
84
|
+
"listing-check.yml": `# Appeeky Spec: report listing changes on every pull request.
|
|
85
|
+
on: pull_request
|
|
86
|
+
permissions:
|
|
87
|
+
contents: read
|
|
88
|
+
pull-requests: write # to comment
|
|
89
|
+
jobs:
|
|
90
|
+
check:
|
|
91
|
+
runs-on: ubuntu-latest
|
|
92
|
+
steps:
|
|
93
|
+
- uses: actions/checkout@v4
|
|
94
|
+
with:
|
|
95
|
+
fetch-depth: 0 # the report compares with main
|
|
96
|
+
- uses: appeeky/appeeky-spec@v0
|
|
97
|
+
`,
|
|
98
|
+
"listing-push.yml": `# Appeeky Spec: push the listing to the stores when a change is merged, and send the
|
|
99
|
+
# social posts and make the UGC clips a merged pull request approved.
|
|
100
|
+
on:
|
|
101
|
+
push:
|
|
102
|
+
branches: [main]
|
|
103
|
+
paths: ["appeeky/store/**", "appeeky.spec.yaml", "appeeky/social/drafts/**", "appeeky/ugc/briefs/**"]
|
|
104
|
+
permissions:
|
|
105
|
+
contents: write # to commit the release file
|
|
106
|
+
jobs:
|
|
107
|
+
push:
|
|
108
|
+
runs-on: ubuntu-latest
|
|
109
|
+
environment: stores # optional: a required approval before it runs
|
|
110
|
+
steps:
|
|
111
|
+
- uses: actions/checkout@v4
|
|
112
|
+
- run: |
|
|
113
|
+
npx @appeeky/spec push --yes --note "\${{ github.event.head_commit.message }}"
|
|
114
|
+
npx @appeeky/spec social push --yes
|
|
115
|
+
npx @appeeky/spec ugc generate --yes \${FAL_KEY:+--local}
|
|
116
|
+
env:
|
|
117
|
+
APPEEKY_API_KEY: \${{ secrets.APPEEKY_API_KEY }}
|
|
118
|
+
FAL_KEY: \${{ secrets.FAL_KEY }} # optional: render clips on your own fal account
|
|
119
|
+
- uses: actions/upload-artifact@v4 # clips made with FAL_KEY, kept with the run
|
|
120
|
+
if: hashFiles('.appeeky/media/*.mp4') != ''
|
|
121
|
+
with: { name: ugc-clips, path: .appeeky/media/*.mp4 }
|
|
122
|
+
- run: |
|
|
123
|
+
git config user.name "appeeky-spec"
|
|
124
|
+
git config user.email "spec@users.noreply.github.com"
|
|
125
|
+
git add appeeky && git commit -m "Record release, posts and clips" && git push || true
|
|
126
|
+
`,
|
|
127
|
+
"listing-daily.yml": `# Appeeky Spec: record the category rank, AI visibility and Google search, measure releases on day 7 and 14, refresh reviews and competitors.
|
|
128
|
+
on:
|
|
129
|
+
schedule: [{ cron: "0 6 * * *" }] # 06:00 UTC
|
|
130
|
+
workflow_dispatch:
|
|
131
|
+
permissions:
|
|
132
|
+
contents: write
|
|
133
|
+
jobs:
|
|
134
|
+
refresh:
|
|
135
|
+
runs-on: ubuntu-latest
|
|
136
|
+
steps:
|
|
137
|
+
- uses: actions/checkout@v4
|
|
138
|
+
- run: |
|
|
139
|
+
npx @appeeky/spec category pull || true
|
|
140
|
+
npx @appeeky/spec visibility pull || true
|
|
141
|
+
npx @appeeky/spec verify --playbook
|
|
142
|
+
npx @appeeky/spec reviews pull || true
|
|
143
|
+
npx @appeeky/spec competitors pull || true
|
|
144
|
+
npx @appeeky/spec social pull || true
|
|
145
|
+
npx @appeeky/spec ugc pull || true
|
|
146
|
+
# Your own keys: Search Console with a service account, Apple Search Ads with an API user, Google ranks on
|
|
147
|
+
# DataForSEO every SEO_RANKS_EVERY days (default 7), and AI visibility every N days
|
|
148
|
+
# (set the repository variable VISIBILITY_EVERY, e.g. 7) on OpenAI, Anthropic, Gemini or Perplexity.
|
|
149
|
+
if [ -n "$GSC_CREDENTIALS" ]; then npx @appeeky/spec seo pull || true; fi
|
|
150
|
+
if [ -n "$ASA_CLIENT_ID" ]; then npx @appeeky/spec search-ads pull || true; fi
|
|
151
|
+
if [ -n "$DATAFORSEO_LOGIN" ]; then npx @appeeky/spec seo ranks --every "\${SEO_RANKS_EVERY:-7}" --yes || true; fi
|
|
152
|
+
if [ -n "$VISIBILITY_EVERY" ]; then npx @appeeky/spec visibility scan --local --every "$VISIBILITY_EVERY" --yes || true; fi
|
|
153
|
+
env:
|
|
154
|
+
APPEEKY_API_KEY: \${{ secrets.APPEEKY_API_KEY }}
|
|
155
|
+
GSC_CREDENTIALS: \${{ secrets.GSC_CREDENTIALS }}
|
|
156
|
+
ASA_CLIENT_ID: \${{ secrets.ASA_CLIENT_ID }}
|
|
157
|
+
ASA_TEAM_ID: \${{ secrets.ASA_TEAM_ID }}
|
|
158
|
+
ASA_KEY_ID: \${{ secrets.ASA_KEY_ID }}
|
|
159
|
+
ASA_PRIVATE_KEY: \${{ secrets.ASA_PRIVATE_KEY }}
|
|
160
|
+
DATAFORSEO_LOGIN: \${{ secrets.DATAFORSEO_LOGIN }}
|
|
161
|
+
DATAFORSEO_PASSWORD: \${{ secrets.DATAFORSEO_PASSWORD }}
|
|
162
|
+
SEO_RANKS_EVERY: \${{ vars.SEO_RANKS_EVERY }}
|
|
163
|
+
VISIBILITY_EVERY: \${{ vars.VISIBILITY_EVERY }}
|
|
164
|
+
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
165
|
+
ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
|
|
166
|
+
GEMINI_API_KEY: \${{ secrets.GEMINI_API_KEY }}
|
|
167
|
+
PERPLEXITY_API_KEY: \${{ secrets.PERPLEXITY_API_KEY }}
|
|
168
|
+
- run: |
|
|
169
|
+
git config user.name "appeeky-spec"
|
|
170
|
+
git config user.email "spec@users.noreply.github.com"
|
|
171
|
+
git add appeeky && git commit -m "Daily: category, AI visibility, search, Search Ads, verify, reviews, competitors, social, UGC" && git push || true
|
|
172
|
+
`,
|
|
173
|
+
};
|
|
174
|
+
const ok = (area, detail) => ({ area, status: "ok", detail });
|
|
175
|
+
const warn = (area, detail, fix) => ({ area, status: "warn", detail, fix });
|
|
176
|
+
const fail = (area, detail, fix) => ({ area, status: "fail", detail, fix });
|
|
177
|
+
const hasLocalKeys = (store, env) => store === "app-store"
|
|
178
|
+
? Boolean(env.ASC_KEY_ID && (env.ASC_PRIVATE_KEY || env.ASC_PRIVATE_KEY_PATH))
|
|
179
|
+
: Boolean(env.GPS_CREDENTIALS || env.GOOGLE_PLAY_CREDENTIALS || env.GOOGLE_APPLICATION_CREDENTIALS);
|
|
180
|
+
/** Where each store CLI keeps its saved accounts (`appstore accounts add`, `gps accounts add`). */
|
|
181
|
+
export function savedAccountsFile(store, env = process.env) {
|
|
182
|
+
const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? env.USERPROFILE ?? "", ".config");
|
|
183
|
+
return join(base, store === "app-store" ? "appstore" : "gps", "config.json");
|
|
184
|
+
}
|
|
185
|
+
/** Names of the accounts saved with the store CLI, if any. */
|
|
186
|
+
export function savedAccounts(store, env = process.env) {
|
|
187
|
+
try {
|
|
188
|
+
const config = JSON.parse(readFileSync(savedAccountsFile(store, env), "utf8"));
|
|
189
|
+
const accounts = config.accounts;
|
|
190
|
+
return Array.isArray(accounts) ? accounts.map((a) => String(a.name ?? a)) : Object.keys(accounts ?? {});
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** What is set up for this repo, and what is missing. Works without a spec (it says so). */
|
|
197
|
+
export function diagnose(root, project, env = process.env, nodeVersion = process.versions.node) {
|
|
198
|
+
const out = [];
|
|
199
|
+
const major = Number(nodeVersion.split(".")[0]);
|
|
200
|
+
out.push(major >= 20 ? ok("node", `Node ${nodeVersion}`) : fail("node", `Node ${nodeVersion}`, "Install Node 20 or newer."));
|
|
201
|
+
if (!project) {
|
|
202
|
+
out.push(fail("spec", `No ${SPEC_FILE} here or above`, "Run `npx @appeeky/spec init` in your app's repo."));
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
out.push(ok("spec", `${SPEC_FILE} · ${project.spec.app.name}`));
|
|
206
|
+
const key = Boolean(env.APPEEKY_API_KEY);
|
|
207
|
+
const stores = [...(project.spec.stores.appStore ? ["app-store"] : []), ...(project.spec.stores.googlePlay ? ["google-play"] : [])];
|
|
208
|
+
if (stores.length === 0)
|
|
209
|
+
out.push(fail("stores", "No store in the spec", "Add stores.appStore.appId or stores.googlePlay.packageName, or run `init` again."));
|
|
210
|
+
for (const store of stores) {
|
|
211
|
+
const cli = STORE_CLIS[store];
|
|
212
|
+
const label = store === "app-store" ? "App Store" : "Google Play";
|
|
213
|
+
const via = (store === "app-store" ? project.spec.stores.appStore?.via : project.spec.stores.googlePlay?.via) ?? "auto";
|
|
214
|
+
const local = onPath(cli.bin, env);
|
|
215
|
+
// The CLI runs from PATH or through npx; either way it reads keys from the environment or its saved accounts.
|
|
216
|
+
const accounts = savedAccounts(store, env);
|
|
217
|
+
const keys = hasLocalKeys(store, env) ? "keys in the environment" : accounts.length ? `saved account${accounts.length > 1 ? "s" : ""} ${accounts.join(", ")}` : "";
|
|
218
|
+
const runner = local ? `${cli.bin} CLI` : `npx ${cli.npmPackage}`;
|
|
219
|
+
if (via === "hosted")
|
|
220
|
+
out.push(key ? ok(label, "hosted through your Appeeky connection (APPEEKY_API_KEY)") : fail(label, "via: hosted, but APPEEKY_API_KEY is not set", "Set APPEEKY_API_KEY (appeeky.com/dashboard/settings)."));
|
|
221
|
+
else if (keys && (via === "local" || local || !key))
|
|
222
|
+
out.push(ok(label, `your own key: ${runner}, ${keys}`));
|
|
223
|
+
else if (via !== "local" && key)
|
|
224
|
+
out.push(ok(label, "hosted through your Appeeky connection (APPEEKY_API_KEY)"));
|
|
225
|
+
else if (local)
|
|
226
|
+
out.push(fail(label, `${cli.bin} CLI is installed but has no saved account and no keys in the environment`, cli.setupHint));
|
|
227
|
+
else
|
|
228
|
+
out.push(fail(label, `No store key saved, no ${cli.bin} CLI and no APPEEKY_API_KEY`, cli.setupHint));
|
|
229
|
+
const files = Object.keys(project.listings[store]).length;
|
|
230
|
+
out.push(files ? ok(`${label} listing`, `${files} locale${files === 1 ? "" : "s"} in ${SPEC_DIR}/store/${store}/`) : warn(`${label} listing`, "Not pulled yet", `Run \`npx @appeeky/spec pull --store ${store}\`.`));
|
|
231
|
+
}
|
|
232
|
+
const targets = Object.entries(project.spec.targets).filter(([, list]) => list.length > 0);
|
|
233
|
+
const count = targets.reduce((n, [, list]) => n + list.length, 0);
|
|
234
|
+
out.push(count ? ok("targets", `${count} in ${targets.map(([cc]) => cc).join(", ")}`) : fail("targets", "None yet: coverage and verify have nothing to measure", "Add the searches to win under targets:, or run `npx @appeeky/spec suggest`."));
|
|
235
|
+
if (targets.length === 1)
|
|
236
|
+
out.push(warn("control group", "Targets in one storefront only", "Add a few targets for a second country: verify compares the storefronts a release changed with the ones it did not."));
|
|
237
|
+
out.push(key ? ok("Appeeky API", "APPEEKY_API_KEY set: popularity, both stores' ranks, conversion, revenue, ads") : warn("Appeeky API", "Not set: App Store ranks only, no popularity or outcome metrics", "Optional: set APPEEKY_API_KEY."));
|
|
238
|
+
const attribution = project.spec.attribution;
|
|
239
|
+
if (!attribution)
|
|
240
|
+
out.push(warn("attribution", "Not set up: releases are judged on ranks and store numbers", "Optional: `npx @appeeky/spec attribution init`."));
|
|
241
|
+
else if (attribution.via === "self" && !env.ATTRIBUTION_TOKEN)
|
|
242
|
+
out.push(fail("attribution", "via: self, but ATTRIBUTION_TOKEN is not set", "Set ATTRIBUTION_TOKEN to your server's ADMIN_TOKEN."));
|
|
243
|
+
else if (attribution.via === "hosted" && !key)
|
|
244
|
+
out.push(fail("attribution", "via: hosted, but APPEEKY_API_KEY is not set", "Set APPEEKY_API_KEY."));
|
|
245
|
+
else
|
|
246
|
+
out.push(ok("attribution", `${attribution.via === "self" ? attribution.url : "Appeeky hosted"}`));
|
|
247
|
+
const workflows = join(root, ".github", "workflows");
|
|
248
|
+
const found = existsSync(workflows) ? readdirSync(workflows).filter((f) => /\.ya?ml$/.test(f) && /appeeky\/appeeky-spec|@appeeky\/spec/.test(readFileSync(join(workflows, f), "utf8"))) : [];
|
|
249
|
+
out.push(found.length ? ok("GitHub Actions", found.join(", ")) : warn("GitHub Actions", "No workflow runs the spec", "Optional: `npx @appeeky/spec init --ci` writes the pull request check, push on merge and daily verify."));
|
|
250
|
+
const skills = [".claude/skills/appeeky-spec", ".agents/skills/appeeky-spec", ".cursor/skills/appeeky-spec"].some((p) => existsSync(join(root, p)));
|
|
251
|
+
out.push(skills ? ok("agent skills", "installed") : warn("agent skills", "Not installed in this repo", "Optional: `npx skills add appeeky/appeeky-spec`."));
|
|
252
|
+
const specialists = installedTargets(root);
|
|
253
|
+
out.push(specialists.length ? ok("specialist agents", `installed for ${specialists.join(", ")}`) : warn("specialist agents", "Not installed: no co-founder review in this repo", "Optional: `npx @appeeky/spec agents install` (add --for opencode or all)."));
|
|
254
|
+
const ignore = existsSync(join(root, ".gitignore")) ? readFileSync(join(root, ".gitignore"), "utf8") : "";
|
|
255
|
+
if (!/\.appeeky\/?(\s|$)|\.appeeky\/data|\.appeeky\/\*/m.test(ignore))
|
|
256
|
+
out.push(warn(".gitignore", ".appeeky/ is not ignored: data and cache could be committed", "Add `.appeeky/` to .gitignore."));
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=setup.js.map
|
package/dist/social.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AppeekyApi } from "./appeeky-api.js";
|
|
3
|
+
/**
|
|
4
|
+
* Organic social media in the repo, through the accounts connected in Appeeky (APPEEKY_API_KEY,
|
|
5
|
+
* Startup plan or above):
|
|
6
|
+
*
|
|
7
|
+
* appeeky/social/accounts.yaml the connected accounts (platform, name, id)
|
|
8
|
+
* appeeky/social/posts.jsonl recent posts, one per line, with each platform's result
|
|
9
|
+
* appeeky/social/summary.md the last 30 days per platform, and the posts that did best
|
|
10
|
+
* appeeky/social/drafts/<name>.yaml a post: drafted, reviewed in a pull request, then sent
|
|
11
|
+
*
|
|
12
|
+
* Sending creates the post in Appeeky, which publishes it right away, or at `when`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const METRICS: readonly ["views", "impressions", "reach", "likes", "comments", "shares", "reposts", "saves", "clicks"];
|
|
15
|
+
export type SocialMetric = (typeof METRICS)[number];
|
|
16
|
+
export interface SocialAccount {
|
|
17
|
+
id: string;
|
|
18
|
+
platform: string;
|
|
19
|
+
name: string;
|
|
20
|
+
active: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface SocialPlatformResult {
|
|
23
|
+
platform: string;
|
|
24
|
+
status: string;
|
|
25
|
+
url?: string;
|
|
26
|
+
publishedAt?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
metrics?: Partial<Record<SocialMetric, number>>;
|
|
29
|
+
}
|
|
30
|
+
export interface SocialPost {
|
|
31
|
+
id: string;
|
|
32
|
+
status: string;
|
|
33
|
+
text: string;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
scheduledFor?: string;
|
|
36
|
+
platforms: SocialPlatformResult[];
|
|
37
|
+
}
|
|
38
|
+
export declare const socialDir: (root: string) => string;
|
|
39
|
+
export declare const draftsDir: (root: string) => string;
|
|
40
|
+
interface AccountRow {
|
|
41
|
+
id: string;
|
|
42
|
+
platform: string;
|
|
43
|
+
displayName?: string | null;
|
|
44
|
+
platformUserId?: string | null;
|
|
45
|
+
isActive?: boolean | null;
|
|
46
|
+
needsReconnect?: boolean | null;
|
|
47
|
+
}
|
|
48
|
+
interface PostRow {
|
|
49
|
+
id: string;
|
|
50
|
+
content: string;
|
|
51
|
+
status: string;
|
|
52
|
+
created_at: string;
|
|
53
|
+
scheduled_for: string | null;
|
|
54
|
+
social_media_platform_posts?: Array<{
|
|
55
|
+
platform: string;
|
|
56
|
+
status: string;
|
|
57
|
+
platform_post_url: string | null;
|
|
58
|
+
published_at: string | null;
|
|
59
|
+
error_message: string | null;
|
|
60
|
+
}>;
|
|
61
|
+
}
|
|
62
|
+
interface AnalyticsItem {
|
|
63
|
+
platform: string;
|
|
64
|
+
analytics: Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
export declare function toAccount(r: AccountRow): SocialAccount;
|
|
67
|
+
export declare function toPost(r: PostRow, analytics?: AnalyticsItem[]): SocialPost;
|
|
68
|
+
/** Connected accounts and the latest posts, with analytics for the ones that went out. */
|
|
69
|
+
export declare function fetchSocial(api: AppeekyApi, options?: {
|
|
70
|
+
limit?: number;
|
|
71
|
+
}): Promise<{
|
|
72
|
+
accounts: SocialAccount[];
|
|
73
|
+
posts: SocialPost[];
|
|
74
|
+
}>;
|
|
75
|
+
export declare function summaryMarkdown(accounts: SocialAccount[], posts: SocialPost[], now?: Date): string;
|
|
76
|
+
export declare function saveSocial(root: string, data: {
|
|
77
|
+
accounts: SocialAccount[];
|
|
78
|
+
posts: SocialPost[];
|
|
79
|
+
}, now?: Date): Promise<void>;
|
|
80
|
+
export declare function readAccounts(root: string): Promise<SocialAccount[]>;
|
|
81
|
+
export declare const draftSchema: z.ZodObject<{
|
|
82
|
+
/** Platforms (twitter, instagram, tiktok, …) or account ids from accounts.yaml. */
|
|
83
|
+
accounts: z.ZodArray<z.ZodString, "many">;
|
|
84
|
+
text: z.ZodString;
|
|
85
|
+
/** ISO time to schedule it; without it, the post goes out when it is sent. */
|
|
86
|
+
when: z.ZodOptional<z.ZodString>;
|
|
87
|
+
/** Public image or video URLs. Instagram, TikTok and Pinterest need one. */
|
|
88
|
+
media: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
89
|
+
/** Why this post, for whoever reviews the pull request. */
|
|
90
|
+
why: z.ZodOptional<z.ZodString>;
|
|
91
|
+
status: z.ZodDefault<z.ZodEnum<["draft", "sent"]>>;
|
|
92
|
+
postId: z.ZodOptional<z.ZodString>;
|
|
93
|
+
sentAt: z.ZodOptional<z.ZodString>;
|
|
94
|
+
}, "strict", z.ZodTypeAny, {
|
|
95
|
+
status: "draft" | "sent";
|
|
96
|
+
accounts: string[];
|
|
97
|
+
text: string;
|
|
98
|
+
when?: string | undefined;
|
|
99
|
+
media?: string[] | undefined;
|
|
100
|
+
why?: string | undefined;
|
|
101
|
+
postId?: string | undefined;
|
|
102
|
+
sentAt?: string | undefined;
|
|
103
|
+
}, {
|
|
104
|
+
accounts: string[];
|
|
105
|
+
text: string;
|
|
106
|
+
status?: "draft" | "sent" | undefined;
|
|
107
|
+
when?: string | undefined;
|
|
108
|
+
media?: string[] | undefined;
|
|
109
|
+
why?: string | undefined;
|
|
110
|
+
postId?: string | undefined;
|
|
111
|
+
sentAt?: string | undefined;
|
|
112
|
+
}>;
|
|
113
|
+
export type Draft = z.infer<typeof draftSchema>;
|
|
114
|
+
/** Character limits per platform, for `check`-style warnings before sending. */
|
|
115
|
+
export declare const TEXT_LIMIT: Record<string, number>;
|
|
116
|
+
export declare function readDrafts(root: string): Promise<Array<Draft & {
|
|
117
|
+
file: string;
|
|
118
|
+
}>>;
|
|
119
|
+
/** The connected accounts a draft goes to: platform names match every active account on that platform. */
|
|
120
|
+
export declare function resolveAccounts(draft: Draft, accounts: SocialAccount[]): {
|
|
121
|
+
accounts: SocialAccount[];
|
|
122
|
+
missing: string[];
|
|
123
|
+
};
|
|
124
|
+
export declare function draftIssues(draft: Draft, accounts: SocialAccount[], now?: Date): string[];
|
|
125
|
+
/** Create the post in Appeeky (it publishes now, or at `when`) and mark the draft sent. */
|
|
126
|
+
export declare function sendDraft(api: AppeekyApi, draft: Draft & {
|
|
127
|
+
file: string;
|
|
128
|
+
}, accounts: SocialAccount[], now?: Date): Promise<string>;
|
|
129
|
+
export {};
|
package/dist/social.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SPEC_DIR, SpecError } from "./files.js";
|
|
7
|
+
/**
|
|
8
|
+
* Organic social media in the repo, through the accounts connected in Appeeky (APPEEKY_API_KEY,
|
|
9
|
+
* Startup plan or above):
|
|
10
|
+
*
|
|
11
|
+
* appeeky/social/accounts.yaml the connected accounts (platform, name, id)
|
|
12
|
+
* appeeky/social/posts.jsonl recent posts, one per line, with each platform's result
|
|
13
|
+
* appeeky/social/summary.md the last 30 days per platform, and the posts that did best
|
|
14
|
+
* appeeky/social/drafts/<name>.yaml a post: drafted, reviewed in a pull request, then sent
|
|
15
|
+
*
|
|
16
|
+
* Sending creates the post in Appeeky, which publishes it right away, or at `when`.
|
|
17
|
+
*/
|
|
18
|
+
export const METRICS = ["views", "impressions", "reach", "likes", "comments", "shares", "reposts", "saves", "clicks"];
|
|
19
|
+
export const socialDir = (root) => join(root, SPEC_DIR, "social");
|
|
20
|
+
export const draftsDir = (root) => join(socialDir(root), "drafts");
|
|
21
|
+
const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== ""));
|
|
22
|
+
export function toAccount(r) {
|
|
23
|
+
return { id: r.id, platform: r.platform, name: r.displayName || r.platformUserId || r.platform, active: r.isActive !== false && !r.needsReconnect };
|
|
24
|
+
}
|
|
25
|
+
export function toPost(r, analytics = []) {
|
|
26
|
+
return compact({
|
|
27
|
+
id: r.id,
|
|
28
|
+
status: r.status,
|
|
29
|
+
text: r.content,
|
|
30
|
+
createdAt: r.created_at,
|
|
31
|
+
scheduledFor: r.scheduled_for ?? undefined,
|
|
32
|
+
platforms: (r.social_media_platform_posts ?? []).map((p) => {
|
|
33
|
+
const a = analytics.find((x) => x.platform === p.platform)?.analytics ?? {};
|
|
34
|
+
const metrics = Object.fromEntries(METRICS.filter((k) => typeof a[k] === "number" && a[k] > 0).map((k) => [k, a[k]]));
|
|
35
|
+
return compact({
|
|
36
|
+
platform: p.platform,
|
|
37
|
+
status: p.status,
|
|
38
|
+
url: p.platform_post_url ?? undefined,
|
|
39
|
+
publishedAt: p.published_at ?? undefined,
|
|
40
|
+
error: p.error_message ?? undefined,
|
|
41
|
+
metrics: Object.keys(metrics).length ? metrics : undefined,
|
|
42
|
+
});
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Connected accounts and the latest posts, with analytics for the ones that went out. */
|
|
47
|
+
export async function fetchSocial(api, options = {}) {
|
|
48
|
+
const accounts = ((await api.get("/connect/social/accounts")) ?? []).map(toAccount);
|
|
49
|
+
const rows = (await api.get("/social/posts", { limit: String(options.limit ?? 100) })) ?? [];
|
|
50
|
+
const posts = [];
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
const published = (row.social_media_platform_posts ?? []).some((p) => p.published_at);
|
|
53
|
+
const analytics = published ? ((await api.get(`/social/posts/${encodeURIComponent(row.id)}/analytics`).catch(() => null))?.analytics ?? []) : [];
|
|
54
|
+
posts.push(toPost(row, analytics));
|
|
55
|
+
}
|
|
56
|
+
return { accounts, posts };
|
|
57
|
+
}
|
|
58
|
+
const total = (p, k) => p.metrics?.[k] ?? 0;
|
|
59
|
+
/** The reach of a post on one platform: views when the platform reports them, else impressions or reach. */
|
|
60
|
+
const seen = (p) => total(p, "views") || total(p, "impressions") || total(p, "reach");
|
|
61
|
+
export function summaryMarkdown(accounts, posts, now = new Date()) {
|
|
62
|
+
const since = new Date(now.getTime() - 30 * 86_400_000).toISOString();
|
|
63
|
+
const recent = posts.filter((p) => (p.platforms.find((x) => x.publishedAt)?.publishedAt ?? p.createdAt) >= since);
|
|
64
|
+
const lines = [`# Social media`, "", `Updated ${now.toISOString().slice(0, 10)}. The last 30 days, from the accounts connected in Appeeky.`, ""];
|
|
65
|
+
lines.push("## Accounts", "");
|
|
66
|
+
if (!accounts.length)
|
|
67
|
+
lines.push("None connected. Connect them in Appeeky (Social).");
|
|
68
|
+
for (const a of accounts)
|
|
69
|
+
lines.push(`- ${a.platform}: ${a.name}${a.active ? "" : " (needs reconnecting in Appeeky)"}`);
|
|
70
|
+
lines.push("", "## By platform", "");
|
|
71
|
+
const platforms = [...new Set(recent.flatMap((p) => p.platforms.map((x) => x.platform)))].sort();
|
|
72
|
+
if (!platforms.length)
|
|
73
|
+
lines.push("No posts in the last 30 days.");
|
|
74
|
+
else {
|
|
75
|
+
lines.push("| Platform | Posts | Seen | Likes | Comments | Shares | Failed |", "| --- | --- | --- | --- | --- | --- | --- |");
|
|
76
|
+
for (const platform of platforms) {
|
|
77
|
+
const results = recent.flatMap((p) => p.platforms.filter((x) => x.platform === platform));
|
|
78
|
+
const sum = (f) => results.reduce((n, x) => n + f(x), 0);
|
|
79
|
+
lines.push(`| ${platform} | ${results.filter((x) => x.publishedAt).length} | ${sum(seen)} | ${sum((x) => total(x, "likes"))} | ${sum((x) => total(x, "comments"))} | ${sum((x) => total(x, "shares") + total(x, "reposts"))} | ${results.filter((x) => x.error).length} |`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const best = recent
|
|
83
|
+
.flatMap((p) => p.platforms.filter((x) => x.publishedAt).map((x) => ({ post: p, result: x })))
|
|
84
|
+
.sort((a, b) => seen(b.result) + total(b.result, "likes") * 10 - (seen(a.result) + total(a.result, "likes") * 10))
|
|
85
|
+
.slice(0, 5);
|
|
86
|
+
if (best.length) {
|
|
87
|
+
lines.push("", "## Did best", "");
|
|
88
|
+
for (const { post, result } of best) {
|
|
89
|
+
const text = post.text.replace(/\s+/g, " ").slice(0, 90);
|
|
90
|
+
lines.push(`- ${result.platform}, ${result.publishedAt.slice(0, 10)}: ${seen(result)} seen, ${total(result, "likes")} likes. "${text}${post.text.length > 90 ? "…" : ""}"${result.url ? ` ${result.url}` : ""}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const waiting = posts.filter((p) => p.status === "scheduled" || p.status === "pending");
|
|
94
|
+
if (waiting.length) {
|
|
95
|
+
lines.push("", "## Waiting to go out", "");
|
|
96
|
+
for (const p of waiting)
|
|
97
|
+
lines.push(`- ${p.scheduledFor?.slice(0, 16).replace("T", " ") ?? "now"} on ${p.platforms.map((x) => x.platform).join(", ")}: "${p.text.replace(/\s+/g, " ").slice(0, 80)}"`);
|
|
98
|
+
}
|
|
99
|
+
return `${lines.join("\n")}\n`;
|
|
100
|
+
}
|
|
101
|
+
export async function saveSocial(root, data, now = new Date()) {
|
|
102
|
+
await mkdir(socialDir(root), { recursive: true });
|
|
103
|
+
await writeFile(join(socialDir(root), "accounts.yaml"), YAML.stringify({ accounts: data.accounts }, { lineWidth: 0 }));
|
|
104
|
+
await writeFile(join(socialDir(root), "posts.jsonl"), data.posts.map((p) => JSON.stringify(p)).join("\n") + (data.posts.length ? "\n" : ""));
|
|
105
|
+
await writeFile(join(socialDir(root), "summary.md"), summaryMarkdown(data.accounts, data.posts, now));
|
|
106
|
+
}
|
|
107
|
+
export async function readAccounts(root) {
|
|
108
|
+
const file = join(socialDir(root), "accounts.yaml");
|
|
109
|
+
if (!existsSync(file))
|
|
110
|
+
return [];
|
|
111
|
+
return (YAML.parse(await readFile(file, "utf8"))?.accounts ?? []);
|
|
112
|
+
}
|
|
113
|
+
// ── Drafts ──────────────────────────────────────────────────────────────────
|
|
114
|
+
export const draftSchema = z
|
|
115
|
+
.object({
|
|
116
|
+
/** Platforms (twitter, instagram, tiktok, …) or account ids from accounts.yaml. */
|
|
117
|
+
accounts: z.array(z.string().min(1)).min(1),
|
|
118
|
+
text: z.string().min(1),
|
|
119
|
+
/** ISO time to schedule it; without it, the post goes out when it is sent. */
|
|
120
|
+
when: z.string().optional(),
|
|
121
|
+
/** Public image or video URLs. Instagram, TikTok and Pinterest need one. */
|
|
122
|
+
media: z.array(z.string().url()).optional(),
|
|
123
|
+
/** Why this post, for whoever reviews the pull request. */
|
|
124
|
+
why: z.string().optional(),
|
|
125
|
+
status: z.enum(["draft", "sent"]).default("draft"),
|
|
126
|
+
postId: z.string().optional(),
|
|
127
|
+
sentAt: z.string().optional(),
|
|
128
|
+
})
|
|
129
|
+
.strict();
|
|
130
|
+
/** Character limits per platform, for `check`-style warnings before sending. */
|
|
131
|
+
export const TEXT_LIMIT = { twitter: 280, x: 280, threads: 500, bluesky: 300, linkedin: 3000, instagram: 2200, tiktok: 2200, facebook: 63206, pinterest: 500, reddit: 40000 };
|
|
132
|
+
const NEEDS_MEDIA = new Set(["instagram", "tiktok", "pinterest"]);
|
|
133
|
+
export async function readDrafts(root) {
|
|
134
|
+
const dir = draftsDir(root);
|
|
135
|
+
if (!existsSync(dir))
|
|
136
|
+
return [];
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const f of (await readdir(dir)).filter((f) => f.endsWith(".yaml")).sort()) {
|
|
139
|
+
const parsed = draftSchema.safeParse(YAML.parse(await readFile(join(dir, f), "utf8")));
|
|
140
|
+
if (!parsed.success)
|
|
141
|
+
throw new SpecError(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), join(dir, f));
|
|
142
|
+
out.push({ ...parsed.data, file: join(dir, f) });
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** The connected accounts a draft goes to: platform names match every active account on that platform. */
|
|
147
|
+
export function resolveAccounts(draft, accounts) {
|
|
148
|
+
const found = [];
|
|
149
|
+
const missing = [];
|
|
150
|
+
for (const ref of draft.accounts) {
|
|
151
|
+
const key = ref.toLowerCase() === "x" ? "twitter" : ref.toLowerCase();
|
|
152
|
+
const match = accounts.filter((a) => a.active && (a.id === ref || a.platform.toLowerCase() === key));
|
|
153
|
+
if (match.length)
|
|
154
|
+
found.push(...match.filter((a) => !found.includes(a)));
|
|
155
|
+
else
|
|
156
|
+
missing.push(ref);
|
|
157
|
+
}
|
|
158
|
+
return { accounts: found, missing };
|
|
159
|
+
}
|
|
160
|
+
export function draftIssues(draft, accounts, now = new Date()) {
|
|
161
|
+
const issues = [];
|
|
162
|
+
const { accounts: to, missing } = resolveAccounts(draft, accounts);
|
|
163
|
+
if (missing.length)
|
|
164
|
+
issues.push(`no connected account for ${missing.join(", ")}`);
|
|
165
|
+
for (const a of to) {
|
|
166
|
+
const limit = TEXT_LIMIT[a.platform.toLowerCase()];
|
|
167
|
+
if (limit && draft.text.length > limit)
|
|
168
|
+
issues.push(`${a.platform}: ${draft.text.length}/${limit} characters`);
|
|
169
|
+
if (NEEDS_MEDIA.has(a.platform.toLowerCase()) && !draft.media?.length)
|
|
170
|
+
issues.push(`${a.platform} needs an image or video (media:)`);
|
|
171
|
+
}
|
|
172
|
+
if (draft.when && !(new Date(draft.when) > now))
|
|
173
|
+
issues.push(`when ${draft.when} is not in the future`);
|
|
174
|
+
return issues;
|
|
175
|
+
}
|
|
176
|
+
/** Create the post in Appeeky (it publishes now, or at `when`) and mark the draft sent. */
|
|
177
|
+
export async function sendDraft(api, draft, accounts, now = new Date()) {
|
|
178
|
+
const { accounts: to } = resolveAccounts(draft, accounts);
|
|
179
|
+
const created = await api.post("/social/posts", {
|
|
180
|
+
content: draft.text,
|
|
181
|
+
account_ids: to.map((a) => a.id),
|
|
182
|
+
...(draft.when ? { scheduled_for: new Date(draft.when).toISOString() } : {}),
|
|
183
|
+
...(draft.media?.length ? { media_urls: draft.media } : {}),
|
|
184
|
+
});
|
|
185
|
+
const { file, ...rest } = draft;
|
|
186
|
+
await writeFile(file, YAML.stringify({ ...rest, status: "sent", postId: created.id, sentAt: now.toISOString() }, { lineWidth: 0 }));
|
|
187
|
+
return created.id;
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=social.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Project } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Listing locales each App Store storefront is commonly observed to index, in
|
|
4
|
+
* order of preference. Apple does not publish this list and it changes, so it
|
|
5
|
+
* is a starting point: override any storefront with `storefronts:` in
|
|
6
|
+
* `appeeky.spec.yaml`. Storefronts not listed here fall back to the app's
|
|
7
|
+
* primary locale.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_STOREFRONT_LOCALES: Record<string, string[]>;
|
|
10
|
+
/** Listing locales that count for a storefront, limited to locales the spec has. */
|
|
11
|
+
export declare function indexedLocales(project: Project, country: string): string[];
|
|
12
|
+
/** Best Google Play language for a storefront: exact match first, then the bare language, then the primary locale. */
|
|
13
|
+
export declare function playLanguageFor(project: Project, country: string): string | undefined;
|
|
14
|
+
/** The Google Play language for an App Store locale (`tr` → `tr-TR`, `zh-Hans` → `zh-CN`). */
|
|
15
|
+
export declare function playLanguageCode(appStoreLocale: string): string;
|