@sprid/cli 0.1.2
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/CHANGELOG.md +12 -0
- package/LICENSE +14 -0
- package/README-post.md +615 -0
- package/README.md +249 -0
- package/SECURITY.md +18 -0
- package/bin.mjs +12 -0
- package/package.json +47 -0
- package/src/args.mjs +134 -0
- package/src/browser.mjs +28 -0
- package/src/cli.mjs +257 -0
- package/src/commands/auth.mjs +192 -0
- package/src/commands/completion.mjs +90 -0
- package/src/commands/connect.mjs +586 -0
- package/src/commands/docs.mjs +53 -0
- package/src/commands/family.mjs +96 -0
- package/src/commands/init.mjs +137 -0
- package/src/commands/local-tools.mjs +31 -0
- package/src/commands/marketing-review.mjs +106 -0
- package/src/commands/mcp.mjs +53 -0
- package/src/commands/misc.mjs +94 -0
- package/src/commands/pinterest.mjs +120 -0
- package/src/commands/plan.mjs +230 -0
- package/src/commands/post.mjs +41 -0
- package/src/commands/reviews.mjs +149 -0
- package/src/commands/setup.mjs +220 -0
- package/src/commands/status.mjs +217 -0
- package/src/commands/studio.mjs +69 -0
- package/src/commands/update.mjs +69 -0
- package/src/creds.mjs +126 -0
- package/src/docs/commands.mjs +152 -0
- package/src/docs/guides.generated.mjs +1178 -0
- package/src/docs/help.mjs +77 -0
- package/src/docs/index.d.mts +18 -0
- package/src/docs/index.mjs +45 -0
- package/src/docs/queries.d.mts +11 -0
- package/src/docs/queries.mjs +77 -0
- package/src/endpoint.mjs +17 -0
- package/src/evidence.mjs +15 -0
- package/src/format.mjs +71 -0
- package/src/http.mjs +117 -0
- package/src/pending.mjs +27 -0
- package/src/post/cli.mjs +2285 -0
- package/src/post/json-worker.mjs +12 -0
- package/src/post/preview-server.mjs +58 -0
- package/src/post/preview.mjs +660 -0
- package/src/post/recipes/screen.mjs +290 -0
- package/src/post/recipes/stills.mjs +146 -0
- package/src/post/rules/platform-rules.d.mts +27 -0
- package/src/post/rules/platform-rules.mjs +164 -0
- package/src/post/screen/captions.mjs +131 -0
- package/src/post/screen/compose.mjs +284 -0
- package/src/post/screen/input.mjs +163 -0
- package/src/post/screen/sim.mjs +443 -0
- package/src/post/screen/simkit.swift +328 -0
- package/src/profiles.mjs +61 -0
- package/src/release.ts +2 -0
- package/src/screenshots.ts +1 -0
- package/src/updates.mjs +152 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
// connect: the one command that crosses from "a key on this machine" to "a
|
|
2
|
+
// sensor on the server". Three shapes:
|
|
3
|
+
//
|
|
4
|
+
// sprid connect the wizard: what is missing, and the line that fixes each
|
|
5
|
+
// sprid connect instagram --account x OAuth in the browser, polled here until the tick
|
|
6
|
+
// sprid connect asc --key … --key-id … read the file, PATCH the profile's secrets. Never printed.
|
|
7
|
+
// sprid connect --from-local every key ~/.sprid/secrets/<slug>.json points at
|
|
8
|
+
//
|
|
9
|
+
// Nothing here prints a key's contents, in any mode. `--json` reports which
|
|
10
|
+
// secret keys are now set, never their values.
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { UsageError } from "../args.mjs";
|
|
15
|
+
import { secretsPath } from "../creds.mjs";
|
|
16
|
+
import { ApiError } from "../http.mjs";
|
|
17
|
+
import { pendingGrant } from "../pending.mjs";
|
|
18
|
+
import { C, BAD, EXPIRING, NONE, OK, table, wrap } from "../format.mjs";
|
|
19
|
+
import { connectionRows } from "./status.mjs";
|
|
20
|
+
import { confirm } from "./reviews.mjs";
|
|
21
|
+
import { expandHome, listProfiles, patchProfile, readKeyFile, readLocalApp, resolveProfile } from "../profiles.mjs";
|
|
22
|
+
|
|
23
|
+
export const CHANNEL_PLATFORMS = ["instagram", "tiktok", "youtube", "linkedin", "facebook", "pinterest", "x"];
|
|
24
|
+
export const SENSOR_SERVICES = [
|
|
25
|
+
"asc",
|
|
26
|
+
"play",
|
|
27
|
+
"gsc",
|
|
28
|
+
"posthog",
|
|
29
|
+
// The revenue rails. RevenueCat reads store sales; the other four read web
|
|
30
|
+
// ones, and a customer may legitimately have two of them.
|
|
31
|
+
"revenuecat",
|
|
32
|
+
"stripe",
|
|
33
|
+
"polar",
|
|
34
|
+
"lemonsqueezy",
|
|
35
|
+
"paddle",
|
|
36
|
+
"cloudflare",
|
|
37
|
+
];
|
|
38
|
+
/** Model / generation keys held per WORKSPACE (BYOK), not per App Profile. */
|
|
39
|
+
export const PROVIDERS = ["anthropic", "openai", "fal", "google", "replicate"];
|
|
40
|
+
|
|
41
|
+
/** Every secret key a service owns on the App Profile; `--clear` nulls all of them. */
|
|
42
|
+
export const SENSOR_KEYS = {
|
|
43
|
+
asc: ["ascPrivateKey", "ascKeyId", "ascIssuerId"],
|
|
44
|
+
play: ["playServiceAccountJson"],
|
|
45
|
+
gsc: ["gscServiceAccountJson"],
|
|
46
|
+
posthog: ["posthogApiKey"],
|
|
47
|
+
revenuecat: ["revenuecatApiKey"],
|
|
48
|
+
stripe: ["stripeApiKey"],
|
|
49
|
+
polar: ["polarApiKey"],
|
|
50
|
+
lemonsqueezy: ["lemonSqueezyApiKey"],
|
|
51
|
+
paddle: ["paddleApiKey"],
|
|
52
|
+
cloudflare: ["cloudflareApiToken"],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const SENSOR_LABEL = {
|
|
56
|
+
asc: "App Store Connect",
|
|
57
|
+
play: "Google Play",
|
|
58
|
+
gsc: "Search Console",
|
|
59
|
+
posthog: "PostHog",
|
|
60
|
+
revenuecat: "RevenueCat",
|
|
61
|
+
stripe: "Stripe",
|
|
62
|
+
polar: "Polar",
|
|
63
|
+
lemonsqueezy: "Lemon Squeezy",
|
|
64
|
+
paddle: "Paddle",
|
|
65
|
+
cloudflare: "Cloudflare",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const PENDING_POLL_MS = 3000;
|
|
69
|
+
const PENDING_TIMEOUT_MS = 10 * 60 * 1000;
|
|
70
|
+
|
|
71
|
+
export async function connect(ctx) {
|
|
72
|
+
const [what] = ctx.positionals;
|
|
73
|
+
if (ctx.flags["from-local"]) return fromLocal(ctx);
|
|
74
|
+
if (!what) return wizard(ctx);
|
|
75
|
+
if (CHANNEL_PLATFORMS.includes(what)) return connectChannel(ctx, what);
|
|
76
|
+
if (SENSOR_SERVICES.includes(what)) return connectSensor(ctx, what);
|
|
77
|
+
if (PROVIDERS.includes(what)) return connectProvider(ctx, what);
|
|
78
|
+
throw new UsageError(
|
|
79
|
+
`Unknown target "${what}". Channels: ${CHANNEL_PLATFORMS.join(", ")}. Keys: ${SENSOR_SERVICES.join(", ")}. Model keys: ${PROVIDERS.join(", ")}.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── the wizard ──────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
async function wizard(ctx) {
|
|
86
|
+
const wid = await ctx.workspaceId();
|
|
87
|
+
const { connections = [] } = await ctx.api().get(`/api/connections?workspaceId=${wid}`);
|
|
88
|
+
|
|
89
|
+
// Channels that do not exist at all are not rows in /connections, so ask
|
|
90
|
+
// for the accounts and mark the core platforms that have no channel.
|
|
91
|
+
let accounts = [];
|
|
92
|
+
try {
|
|
93
|
+
const rows = await ctx.api().get("/api/accounts");
|
|
94
|
+
accounts = (Array.isArray(rows) ? rows : rows?.accounts ?? []).filter((a) => a.workspaceId === wid);
|
|
95
|
+
} catch {
|
|
96
|
+
accounts = [];
|
|
97
|
+
}
|
|
98
|
+
// Sensors whose id is not even set are optional; list them per profile.
|
|
99
|
+
let profiles = [];
|
|
100
|
+
try {
|
|
101
|
+
profiles = await listProfiles(ctx, wid);
|
|
102
|
+
} catch {
|
|
103
|
+
profiles = [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { next = [] } = await ctx.api().get(`/api/status?workspaceId=${wid}`);
|
|
107
|
+
const setup = next.filter((a) => a.kind === "first-app" || a.ref?.setup === "account");
|
|
108
|
+
|
|
109
|
+
const missingKeys = connections.filter((c) => c.kind === "sensor" && c.health === "missing");
|
|
110
|
+
// A key that is set but whose last pull failed: the store's own error text
|
|
111
|
+
// is the useful part, so it gets its own group and the detail in full.
|
|
112
|
+
const failing = connections.filter((c) => c.health === "error");
|
|
113
|
+
// Only revoked is a reconnect. Expiring usually refreshes itself hourly.
|
|
114
|
+
const brokenChannels = connections.filter((c) => c.kind === "channel" && c.health === "revoked");
|
|
115
|
+
const expiring = connections.filter((c) => c.health === "expiring");
|
|
116
|
+
const notConnected = [];
|
|
117
|
+
for (const a of accounts) {
|
|
118
|
+
for (const p of ["instagram", "tiktok", "youtube", "linkedin", "pinterest", "x"]) {
|
|
119
|
+
if (!connections.some((c) => c.kind === "channel" && c.scope === a.slug && c.service === p)) {
|
|
120
|
+
notConnected.push({ account: a.slug, platform: p, cli: `sprid connect ${p} --account ${a.slug}` });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const optional = [];
|
|
125
|
+
for (const p of profiles) {
|
|
126
|
+
const opts = [];
|
|
127
|
+
if (!p.posthogProjectId) opts.push("PostHog");
|
|
128
|
+
if (!p.revenuecatProjectId) opts.push("RevenueCat");
|
|
129
|
+
if (!p.gscProperty) opts.push("Search Console");
|
|
130
|
+
if (!p.cloudflareZoneId) opts.push("Cloudflare");
|
|
131
|
+
if (!p.appStoreId) opts.push("App Store Connect");
|
|
132
|
+
if (!p.playPackageName) opts.push("Google Play");
|
|
133
|
+
if (opts.length) optional.push({ app: p.slug, services: opts });
|
|
134
|
+
}
|
|
135
|
+
const ok = connections.filter((c) => c.health === "ok");
|
|
136
|
+
|
|
137
|
+
if (ctx.json) {
|
|
138
|
+
ctx.out({ workspaceId: wid, setup, missingKeys, failing, brokenChannels, expiring, notConnected, optional, ok });
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lines = setup.map((a) => ` ${a.title} → ${a.cli}\n ${a.why}`);
|
|
143
|
+
if (missingKeys.length) {
|
|
144
|
+
lines.push(C.bold(" Missing keys"));
|
|
145
|
+
lines.push(table(missingKeys.map((c) => [`${NONE} ${c.label}`, C.dim("→"), c.cli]), { indent: " " }));
|
|
146
|
+
}
|
|
147
|
+
if (failing.length) {
|
|
148
|
+
lines.push(C.bold(" Failing"));
|
|
149
|
+
for (const c of failing) {
|
|
150
|
+
lines.push(` ${BAD} ${c.label}`);
|
|
151
|
+
lines.push(...failureLines(c, " "));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (brokenChannels.length) {
|
|
155
|
+
lines.push(C.bold(" Channels to reconnect"));
|
|
156
|
+
lines.push(table(brokenChannels.map((c) => [`${BAD} ${c.label}`, C.dim(c.detail ?? c.health), c.cli]), { indent: " " }));
|
|
157
|
+
}
|
|
158
|
+
if (expiring.length) {
|
|
159
|
+
lines.push(C.bold(" Expiring soon, usually refreshes itself"));
|
|
160
|
+
lines.push(table(expiring.map((c) => [`${EXPIRING} ${c.label}`, C.dim(c.detail ?? "")]), { indent: " " }));
|
|
161
|
+
}
|
|
162
|
+
if (notConnected.length) {
|
|
163
|
+
lines.push(C.bold(" Channels not connected"));
|
|
164
|
+
lines.push(table(notConnected.map((c) => [`${NONE} ${c.platform} @${c.account}`, C.dim("→"), c.cli]), { indent: " " }));
|
|
165
|
+
}
|
|
166
|
+
if (optional.length) {
|
|
167
|
+
lines.push(C.bold(" Optional (no id on the profile yet)"));
|
|
168
|
+
for (const o of optional) lines.push(` ${o.app}: ${o.services.join(", ")} ${C.dim("→ sprid init, or add the id in Settings → App Profiles")}`);
|
|
169
|
+
}
|
|
170
|
+
if (ok.length) {
|
|
171
|
+
lines.push(C.bold(" Connected"));
|
|
172
|
+
const rows = connectionRows(ok);
|
|
173
|
+
const byok = ok.filter((c) => PROVIDERS.includes(c.service));
|
|
174
|
+
if (byok.length) rows.push(["BYOK", byok.map((c) => `${c.service} ${OK}`).join(" ")]);
|
|
175
|
+
lines.push(table(rows, { indent: " " }));
|
|
176
|
+
}
|
|
177
|
+
if (!lines.length) lines.push(" Everything that has an id is connected. Nothing to do.");
|
|
178
|
+
ctx.print(lines.join("\n"));
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A failing row, as the server now describes it: `detail` is a plain sentence,
|
|
184
|
+
* `fix` is the imperative, `url` is where to do it. Printed in that order, then
|
|
185
|
+
* the command. Nothing raw from the store is printed any more.
|
|
186
|
+
*/
|
|
187
|
+
export function failureLines(c, indent = "") {
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const l of wrap(c.detail ?? "The last pull failed.", 100)) out.push(C.dim(`${indent}${l}`));
|
|
190
|
+
if (c.fix) for (const l of wrap(`Fix: ${c.fix}`, 100)) out.push(`${indent}${l}`);
|
|
191
|
+
if (c.url) out.push(`${indent}${c.url}`);
|
|
192
|
+
if (c.cli) out.push(`${indent}${c.cli}`);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── channels (OAuth) ────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
async function connectChannel(ctx, platform) {
|
|
199
|
+
const account = ctx.flags.account;
|
|
200
|
+
if (!account) throw new UsageError(`sprid connect ${platform} --account <slug>`);
|
|
201
|
+
const returnUrl = `${ctx.appUrl()}/connected`;
|
|
202
|
+
const auth = ctx.auth();
|
|
203
|
+
const pageId = ctx.flags.page;
|
|
204
|
+
if (pageId && platform !== "facebook") throw new UsageError("--page is only supported for Facebook connections.");
|
|
205
|
+
const pending = pendingGrant(ctx, `channel:${auth.apiUrl}:${auth.token}:${account}:${platform}:${pageId ?? ""}`);
|
|
206
|
+
const previous = pending.read();
|
|
207
|
+
const { url, pendingId } = previous ?? await ctx.api().post(`/api/channels/connect/${platform}`, { accountSlug: account, returnUrl, ...(pageId ? { pageId } : {}) });
|
|
208
|
+
if (!url) throw new ApiError(500, "The server returned no authorize URL.");
|
|
209
|
+
if (!previous && pendingId) pending.write({ url, pendingId, expiresAt: Date.now() + PENDING_TIMEOUT_MS });
|
|
210
|
+
|
|
211
|
+
if (ctx.json) ctx.warn(JSON.stringify({ url, pendingId }));
|
|
212
|
+
else ctx.print(` Open ${C.bold(url)}`);
|
|
213
|
+
const opened = previous ? false : await ctx.openBrowser(url);
|
|
214
|
+
if (!ctx.json) ctx.print(C.dim(` ${opened ? "Opening the browser… " : ""}waiting for ${label(platform)}`));
|
|
215
|
+
|
|
216
|
+
if (!pendingId) {
|
|
217
|
+
// Older server: nothing to poll. Say so instead of pretending.
|
|
218
|
+
if (ctx.json) ctx.out({ ok: true, polled: false, url });
|
|
219
|
+
else ctx.print(` Finish in the browser, then check with \`sprid status\`.`);
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const deadline = Date.now() + PENDING_TIMEOUT_MS;
|
|
224
|
+
let last = null;
|
|
225
|
+
while (Date.now() < deadline) {
|
|
226
|
+
await ctx.sleep(PENDING_POLL_MS);
|
|
227
|
+
last = await ctx.api().get(`/api/channels/pending/${pendingId}`);
|
|
228
|
+
if (last.status && last.status !== "pending") break;
|
|
229
|
+
}
|
|
230
|
+
const s = last?.status ?? "pending";
|
|
231
|
+
if (s === "connected") {
|
|
232
|
+
pending.clear();
|
|
233
|
+
const handle = last.channel?.displayName ? `@${last.channel.displayName}` : label(platform);
|
|
234
|
+
if (ctx.json) ctx.out({ ok: true, status: s, channel: last.channel, account });
|
|
235
|
+
else ctx.print(` ${C.ok(OK)} ${handle} connected to ${account}`);
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
const message =
|
|
239
|
+
s === "conflict"
|
|
240
|
+
? `${last.error || "That login is already connected to another account."} Move it in Settings → Accounts, or connect a different login.`
|
|
241
|
+
: s === "expired" || s === "pending"
|
|
242
|
+
? "Timed out waiting for the browser (10 minutes). Run the command again."
|
|
243
|
+
: last?.error || `Connect ${s}.`;
|
|
244
|
+
if (s !== "pending") pending.clear();
|
|
245
|
+
if (ctx.json) {
|
|
246
|
+
ctx.out({ ok: false, status: s, error: message, channel: last?.channel ?? null });
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
throw new ApiError(0, message);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function label(platform) {
|
|
253
|
+
return { instagram: "Instagram", tiktok: "TikTok", youtube: "YouTube", linkedin: "LinkedIn", facebook: "Facebook", pinterest: "Pinterest", x: "X" }[platform] ?? platform;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── sensors (keys) ──────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A token flag takes a file path or the value itself. The guides say file, so
|
|
260
|
+
* the secret never sits in shell history; the bare value still works for a
|
|
261
|
+
* one-off. Contents are trimmed (a trailing newline is not part of a key).
|
|
262
|
+
*/
|
|
263
|
+
export function fileOrValue(arg, cwd) {
|
|
264
|
+
const p = resolve(cwd, expandHome(String(arg)));
|
|
265
|
+
if (existsSync(p) && statSync(p).isFile()) {
|
|
266
|
+
const text = readFileSync(p, "utf8").trim();
|
|
267
|
+
if (!text) throw new UsageError(`Key file is empty: ${p}`);
|
|
268
|
+
return text;
|
|
269
|
+
}
|
|
270
|
+
return String(arg);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Flags → the PATCH body for one service. Reads files; never echoes them. */
|
|
274
|
+
export function sensorPatch(service, flags, cwd) {
|
|
275
|
+
const need = (name) => {
|
|
276
|
+
const v = flags[name];
|
|
277
|
+
if (!v || v === true) throw new UsageError(`sprid connect ${service} needs --${name}`);
|
|
278
|
+
return String(v);
|
|
279
|
+
};
|
|
280
|
+
switch (service) {
|
|
281
|
+
case "asc":
|
|
282
|
+
return {
|
|
283
|
+
secrets: {
|
|
284
|
+
ascPrivateKey: readKeyFile(need("key"), cwd),
|
|
285
|
+
ascKeyId: need("key-id"),
|
|
286
|
+
ascIssuerId: need("issuer"),
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
case "play":
|
|
290
|
+
return { secrets: { playServiceAccountJson: readKeyFile(need("key"), cwd) } };
|
|
291
|
+
case "gsc":
|
|
292
|
+
return {
|
|
293
|
+
gscProperty: need("property"),
|
|
294
|
+
secrets: { gscServiceAccountJson: readKeyFile(need("key"), cwd) },
|
|
295
|
+
};
|
|
296
|
+
case "posthog": {
|
|
297
|
+
const body = { posthogProjectId: need("project"), secrets: { posthogApiKey: fileOrValue(need("key"), cwd) } };
|
|
298
|
+
if (flags.host && flags.host !== true) {
|
|
299
|
+
const host = String(flags.host);
|
|
300
|
+
body.posthogHost = host === "eu" || host === "us" ? `https://${host}.posthog.com` : host;
|
|
301
|
+
}
|
|
302
|
+
return body;
|
|
303
|
+
}
|
|
304
|
+
case "revenuecat":
|
|
305
|
+
return { revenuecatProjectId: need("project"), secrets: { revenuecatApiKey: fileOrValue(need("key"), cwd) } };
|
|
306
|
+
// Stripe and Paddle need no id: the key names the account. Polar takes an
|
|
307
|
+
// optional organization id (an org token already implies one), Lemon
|
|
308
|
+
// Squeezy needs its store id.
|
|
309
|
+
case "stripe":
|
|
310
|
+
return { secrets: { stripeApiKey: fileOrValue(need("key"), cwd) } };
|
|
311
|
+
case "polar": {
|
|
312
|
+
const body = { secrets: { polarApiKey: fileOrValue(need("key"), cwd) } };
|
|
313
|
+
if (flags.org && flags.org !== true) body.polarOrganizationId = String(flags.org);
|
|
314
|
+
return body;
|
|
315
|
+
}
|
|
316
|
+
case "lemonsqueezy":
|
|
317
|
+
return {
|
|
318
|
+
lemonSqueezyStoreId: String(need("store")),
|
|
319
|
+
secrets: { lemonSqueezyApiKey: fileOrValue(need("key"), cwd) },
|
|
320
|
+
};
|
|
321
|
+
case "paddle": {
|
|
322
|
+
const body = { secrets: { paddleApiKey: fileOrValue(need("key"), cwd) } };
|
|
323
|
+
if (flags.env && flags.env !== true) body.paddleEnvironment = String(flags.env) === "sandbox" ? "sandbox" : "live";
|
|
324
|
+
return body;
|
|
325
|
+
}
|
|
326
|
+
case "cloudflare":
|
|
327
|
+
return { cloudflareZoneId: need("zone"), secrets: { cloudflareApiToken: fileOrValue(need("token"), cwd) } };
|
|
328
|
+
default:
|
|
329
|
+
throw new UsageError(`Unknown key type "${service}"`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function connectSensor(ctx, service) {
|
|
334
|
+
if (ctx.flags.clear) return clearSensor(ctx, service);
|
|
335
|
+
const body = sensorPatch(service, ctx.flags, ctx.cwd);
|
|
336
|
+
const wid = await ctx.workspaceId();
|
|
337
|
+
const profile = await resolveProfile(ctx, wid);
|
|
338
|
+
const updated = await patchProfile(ctx, wid, profile.id, body);
|
|
339
|
+
report(ctx, updated, Object.keys(body.secrets), `${SENSOR_LABEL[service]} key`, wid, service);
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** `--clear`: PATCH every key of the service to null. The id stays; only the key goes. */
|
|
344
|
+
async function clearSensor(ctx, service) {
|
|
345
|
+
const keys = SENSOR_KEYS[service];
|
|
346
|
+
const wid = await ctx.workspaceId();
|
|
347
|
+
const profile = await resolveProfile(ctx, wid);
|
|
348
|
+
const updated = await patchProfile(ctx, wid, profile.id, { secrets: Object.fromEntries(keys.map((k) => [k, null])) });
|
|
349
|
+
if (ctx.json) {
|
|
350
|
+
ctx.out({ ok: true, cleared: keys, profile: { id: updated.id, slug: updated.slug, name: updated.name }, secretsSet: updated.secretsSet, sensors: updated.sensors });
|
|
351
|
+
} else {
|
|
352
|
+
ctx.print(` ${C.ok(OK)} ${SENSOR_LABEL[service]} key removed from ${updated.slug} ${C.dim(keys.join(", "))}`);
|
|
353
|
+
}
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function report(ctx, profile, keys, what, workspaceId, service) {
|
|
358
|
+
const app = "'" + profile.slug.replace(/'/g, "'\\''") + "'";
|
|
359
|
+
const verifyCommand = `sprid marketing-review --workspace ${workspaceId} --app ${app} --days 7 --json`;
|
|
360
|
+
const guideUrl = service ? `https://sprid.studio/docs/connect/${({ asc: "app-store-connect", play: "google-play", gsc: "search-console" })[service] ?? service}` : undefined;
|
|
361
|
+
if (ctx.json) {
|
|
362
|
+
ctx.out({ ok: true, verified: false, verifyCommand, guideUrl, profile: { id: profile.id, slug: profile.slug, name: profile.name }, set: keys, secretsSet: profile.secretsSet, sensors: profile.sensors });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
ctx.print(` ${C.ok(OK)} ${what} set on ${profile.slug} ${C.dim(keys.join(", "))}`);
|
|
366
|
+
const s = profile.sensors ?? {};
|
|
367
|
+
const running = Object.entries(s)
|
|
368
|
+
.filter(([, v]) => v)
|
|
369
|
+
.map(([k]) => k);
|
|
370
|
+
if (running.length) ctx.print(C.dim(` Sensors configured: ${running.join(", ")}`));
|
|
371
|
+
ctx.print(` Key saved; provider access has not been checked. Verify: ${verifyCommand}`);
|
|
372
|
+
if (guideUrl) ctx.print(C.dim(` Setup and required permissions: ${guideUrl}`));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ─── provider keys (BYOK, per workspace) ─────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* `sprid connect anthropic --key <file or sk-ant-…>`. The server validates the
|
|
379
|
+
* key live and stores it encrypted; what comes back is a display hint
|
|
380
|
+
* ("sk-ant...ab12"), which is all this ever prints.
|
|
381
|
+
*/
|
|
382
|
+
async function connectProvider(ctx, provider) {
|
|
383
|
+
if (ctx.flags.clear) return clearProvider(ctx, provider);
|
|
384
|
+
const raw = ctx.flags.key;
|
|
385
|
+
if (!raw || raw === true) throw new UsageError(`sprid connect ${provider} --key <file or key>`);
|
|
386
|
+
const apiKey = fileOrValue(raw, ctx.cwd);
|
|
387
|
+
if (apiKey.length < 8) throw new UsageError("That key is too short to be one.");
|
|
388
|
+
const wid = await ctx.workspaceId();
|
|
389
|
+
let created;
|
|
390
|
+
try {
|
|
391
|
+
created = await ctx.api().post("/api/provider-keys", { workspaceId: wid, provider, apiKey });
|
|
392
|
+
} catch (e) {
|
|
393
|
+
// "anthropic key validation failed" is the server's own verdict; pass it on as is.
|
|
394
|
+
if (e instanceof ApiError && e.status === 400) throw new ApiError(400, e.message);
|
|
395
|
+
throw e;
|
|
396
|
+
}
|
|
397
|
+
const hint = created?.keyDisplayHint ?? null;
|
|
398
|
+
if (ctx.json) {
|
|
399
|
+
ctx.out({ ok: true, provider, id: created?.id ?? null, keyDisplayHint: hint, status: created?.status ?? "active", workspaceId: wid });
|
|
400
|
+
} else {
|
|
401
|
+
ctx.print(` ${C.ok(OK)} ${provider} key set on the workspace${hint ? ` ${C.dim(hint)}` : ""}`);
|
|
402
|
+
if (provider === "anthropic" || provider === "openai") ctx.print(C.dim(" Review replies and other drafting now run on your own key."));
|
|
403
|
+
}
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** `--clear`: revoke the workspace's active key for the provider (id from the list). */
|
|
408
|
+
async function clearProvider(ctx, provider) {
|
|
409
|
+
const wid = await ctx.workspaceId();
|
|
410
|
+
const rows = await ctx.api().get(`/api/provider-keys?workspaceId=${wid}`);
|
|
411
|
+
const list = Array.isArray(rows) ? rows : rows?.keys ?? [];
|
|
412
|
+
const hits = list.filter((r) => r.provider === provider && (r.status ?? "active") === "active");
|
|
413
|
+
if (hits.length === 0) {
|
|
414
|
+
if (ctx.json) ctx.out({ ok: true, cleared: [], provider, note: "no active key" });
|
|
415
|
+
else ctx.print(` No ${provider} key on this workspace. Nothing to remove.`);
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
for (const r of hits) await ctx.api().del(`/api/provider-keys/${r.id}`);
|
|
419
|
+
if (ctx.json) ctx.out({ ok: true, cleared: hits.map((r) => r.id), provider, workspaceId: wid });
|
|
420
|
+
else ctx.print(` ${C.ok(OK)} ${provider} key removed from the workspace${hits[0].keyDisplayHint ? ` ${C.dim(hits[0].keyDisplayHint)}` : ""}`);
|
|
421
|
+
return 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── --from-local ────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* ~/.sprid/secrets/<slug>.json, the shape the plugin's pull scripts read:
|
|
428
|
+
* { gscServiceAccountPath, cloudflareApiToken,
|
|
429
|
+
* asc: { keyId, issuerId, keyPath },
|
|
430
|
+
* playServiceAccounts: { "<packageName>": "<sa json path>" } }
|
|
431
|
+
* Extras accepted when present: posthogApiKey, revenuecatApiKey.
|
|
432
|
+
*/
|
|
433
|
+
export function localSecretsPatch(local, profile, cwd) {
|
|
434
|
+
const secrets = {};
|
|
435
|
+
const skipped = [];
|
|
436
|
+
const file = (p) => readFileSync(expandHome(p), "utf8");
|
|
437
|
+
const exists = (p) => existsSync(expandHome(p));
|
|
438
|
+
|
|
439
|
+
if (local.asc?.keyPath && local.asc.keyId && local.asc.issuerId) {
|
|
440
|
+
if (exists(local.asc.keyPath)) {
|
|
441
|
+
secrets.ascPrivateKey = file(local.asc.keyPath);
|
|
442
|
+
secrets.ascKeyId = String(local.asc.keyId);
|
|
443
|
+
secrets.ascIssuerId = String(local.asc.issuerId);
|
|
444
|
+
} else skipped.push(`asc: ${local.asc.keyPath} not found`);
|
|
445
|
+
}
|
|
446
|
+
if (local.playServiceAccounts && typeof local.playServiceAccounts === "object") {
|
|
447
|
+
const entries = Object.entries(local.playServiceAccounts);
|
|
448
|
+
const pick = profile?.playPackageName ? entries.find(([pkg]) => pkg === profile.playPackageName) : entries.length === 1 ? entries[0] : null;
|
|
449
|
+
if (pick) {
|
|
450
|
+
if (exists(pick[1])) secrets.playServiceAccountJson = file(pick[1]);
|
|
451
|
+
else skipped.push(`play: ${pick[1]} not found`);
|
|
452
|
+
} else if (entries.length) {
|
|
453
|
+
skipped.push(`play: no entry for ${profile?.playPackageName ?? "(profile has no playPackageName)"}; have ${entries.map(([k]) => k).join(", ")}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (local.gscServiceAccountPath) {
|
|
457
|
+
if (exists(local.gscServiceAccountPath)) secrets.gscServiceAccountJson = file(local.gscServiceAccountPath);
|
|
458
|
+
else skipped.push(`gsc: ${local.gscServiceAccountPath} not found`);
|
|
459
|
+
}
|
|
460
|
+
if (local.cloudflareApiToken) secrets.cloudflareApiToken = String(local.cloudflareApiToken);
|
|
461
|
+
if (local.posthogApiKey) secrets.posthogApiKey = String(local.posthogApiKey);
|
|
462
|
+
if (local.revenuecatApiKey) secrets.revenuecatApiKey = String(local.revenuecatApiKey);
|
|
463
|
+
|
|
464
|
+
return { body: { secrets }, keys: Object.keys(secrets), skipped };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function fromLocal(ctx) {
|
|
468
|
+
const wid = await ctx.workspaceId();
|
|
469
|
+
const profile = await resolveProfile(ctx, wid);
|
|
470
|
+
const slug = ctx.flags.app || readLocalApp(ctx.cwd)?.data?.slug || profile.slug;
|
|
471
|
+
const path = secretsPath(slug, ctx.env);
|
|
472
|
+
if (!existsSync(path)) throw new UsageError(`No local secrets at ${path}.`);
|
|
473
|
+
let local;
|
|
474
|
+
try {
|
|
475
|
+
local = JSON.parse(readFileSync(path, "utf8"));
|
|
476
|
+
} catch (e) {
|
|
477
|
+
throw new UsageError(`${path} is not valid JSON: ${e.message}`);
|
|
478
|
+
}
|
|
479
|
+
const { body, keys, skipped } = localSecretsPatch(local, profile, ctx.cwd);
|
|
480
|
+
if (keys.length === 0) {
|
|
481
|
+
if (ctx.json) ctx.out({ ok: false, set: [], skipped, error: `Nothing usable in ${path}` });
|
|
482
|
+
else {
|
|
483
|
+
ctx.print(` Nothing usable in ${path}.`);
|
|
484
|
+
for (const s of skipped) ctx.print(C.dim(` ${NONE} ${s}`));
|
|
485
|
+
}
|
|
486
|
+
return 1;
|
|
487
|
+
}
|
|
488
|
+
const updated = await patchProfile(ctx, wid, profile.id, body);
|
|
489
|
+
report(ctx, updated, keys, `${keys.length} key${keys.length === 1 ? "" : "s"} from ${path}`, wid);
|
|
490
|
+
if (!ctx.json) for (const s of skipped) ctx.print(C.dim(` ${NONE} skipped ${s}`));
|
|
491
|
+
return 0;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── disconnect ──────────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* sprid disconnect <platform> --account <slug> [--forget] [--id <channelId>]
|
|
498
|
+
*
|
|
499
|
+
* Two different things, and the difference matters:
|
|
500
|
+
* plain the grant is dropped, the row stays. Reconnecting the same
|
|
501
|
+
* login revives it with its publish history intact.
|
|
502
|
+
* --forget the row goes. This is the only way to clear a channel that
|
|
503
|
+
* was connected to the wrong account — a plain disconnect
|
|
504
|
+
* leaves a dead row that keeps showing in Connections. The
|
|
505
|
+
* server refuses to forget a channel that has published.
|
|
506
|
+
*/
|
|
507
|
+
export async function disconnect(ctx) {
|
|
508
|
+
const [platform] = ctx.positionals;
|
|
509
|
+
const account = ctx.flags.account;
|
|
510
|
+
const forget = !!ctx.flags.forget;
|
|
511
|
+
if (!platform || !CHANNEL_PLATFORMS.includes(platform)) {
|
|
512
|
+
throw new UsageError(
|
|
513
|
+
`sprid disconnect <${CHANNEL_PLATFORMS.join("|")}> --account <slug> [--forget]`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (!account) throw new UsageError(`sprid disconnect ${platform} --account <slug>`);
|
|
517
|
+
|
|
518
|
+
const rows = await ctx.api().get(`/api/channels?account=${encodeURIComponent(account)}`);
|
|
519
|
+
let hits = (rows ?? []).filter((r) => r.platform === platform);
|
|
520
|
+
if (ctx.flags.id) hits = hits.filter((r) => String(r.id) === String(ctx.flags.id));
|
|
521
|
+
// Without --id, a plain disconnect means the live one and --forget means
|
|
522
|
+
// the dead ones: that is what each word is for, and it is unambiguous
|
|
523
|
+
// whenever an account holds both.
|
|
524
|
+
else hits = forget
|
|
525
|
+
? (hits.some((r) => r.status !== "active") ? hits.filter((r) => r.status !== "active") : hits)
|
|
526
|
+
: hits.filter((r) => r.status === "active");
|
|
527
|
+
|
|
528
|
+
if (hits.length === 0) {
|
|
529
|
+
const msg = `No ${label(platform)} channel on ${account}${forget ? " to forget" : " is connected"}.`;
|
|
530
|
+
if (ctx.json) ctx.out({ ok: true, changed: [], note: msg });
|
|
531
|
+
else ctx.print(` ${NONE} ${msg}`);
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
534
|
+
if (hits.length > 1 && !ctx.flags.id) {
|
|
535
|
+
const list = hits.map((r) => ` --id ${r.id} ${r.displayName ?? r.platformUserId} (${r.status})`);
|
|
536
|
+
if (ctx.json) {
|
|
537
|
+
ctx.out({ ok: false, error: "Several channels match; pass --id.", channels: hits });
|
|
538
|
+
return 1;
|
|
539
|
+
}
|
|
540
|
+
ctx.print(` ${BAD} Several ${label(platform)} channels on ${account}. Pass one:`);
|
|
541
|
+
for (const l of list) ctx.print(C.dim(l));
|
|
542
|
+
return 1;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// A disconnect stops a live channel publishing, and it is one word away
|
|
546
|
+
// from the harmless --forget. Ask, the way `reviews reply` asks.
|
|
547
|
+
const what = hits
|
|
548
|
+
.map((r) => `${label(platform)} ${r.displayName ?? r.platformUserId} (${r.status})`)
|
|
549
|
+
.join(", ");
|
|
550
|
+
if (!ctx.flags.yes) {
|
|
551
|
+
const interactive = Boolean(ctx.stdin?.isTTY);
|
|
552
|
+
const verb = forget ? "Removing" : "Disconnecting";
|
|
553
|
+
if (ctx.json || !interactive) {
|
|
554
|
+
const msg = `${verb} ${what} on ${account}. Re-run with --yes to do it without the prompt.`;
|
|
555
|
+
if (ctx.json) ctx.out({ ok: false, error: msg, channels: hits });
|
|
556
|
+
else ctx.warn(` ${BAD} ${msg}`);
|
|
557
|
+
return 2;
|
|
558
|
+
}
|
|
559
|
+
ctx.print(` ${verb} ${what} on ${account}.`);
|
|
560
|
+
ctx.print(
|
|
561
|
+
C.dim(
|
|
562
|
+
forget
|
|
563
|
+
? " The row goes. Nothing that was published is affected."
|
|
564
|
+
: " Scheduled posts on it will fail until you connect again.",
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
const ok = await confirm(ctx, forget ? " Remove it? [y/N] " : " Disconnect it? [y/N] ");
|
|
568
|
+
if (!ok) {
|
|
569
|
+
ctx.print(" Nothing changed.");
|
|
570
|
+
return 1;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const done = [];
|
|
575
|
+
for (const r of hits) {
|
|
576
|
+
await ctx.api().del(`/api/channels/${r.id}${forget ? "?purge=1" : ""}`);
|
|
577
|
+
done.push({ id: r.id, handle: r.displayName ?? r.platformUserId, forgotten: forget });
|
|
578
|
+
}
|
|
579
|
+
if (ctx.json) ctx.out({ ok: true, changed: done, account, platform });
|
|
580
|
+
else
|
|
581
|
+
for (const d of done)
|
|
582
|
+
ctx.print(
|
|
583
|
+
` ${C.ok(OK)} ${d.handle} ${forget ? "removed from" : "disconnected from"} ${account}`,
|
|
584
|
+
);
|
|
585
|
+
return 0;
|
|
586
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { getDocumentation, searchDocumentation } from '../docs/index.mjs';
|
|
2
|
+
import { DEFAULT_API_URL } from '../creds.mjs';
|
|
3
|
+
import { UsageError } from '../args.mjs';
|
|
4
|
+
|
|
5
|
+
export async function docs(ctx) {
|
|
6
|
+
const [topic] = ctx.positionals;
|
|
7
|
+
if (topic === 'search') {
|
|
8
|
+
const query = ctx.positionals.slice(1).join(' ').trim();
|
|
9
|
+
if (!query) throw new UsageError('Supply search words, for example: sprid docs search posthog permissions');
|
|
10
|
+
const limit = Number(ctx.flags.limit ?? 10);
|
|
11
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new UsageError('--limit must be an integer from 1 to 100.');
|
|
12
|
+
const matches = searchDocumentation(query);
|
|
13
|
+
const results = matches.slice(0, limit);
|
|
14
|
+
if (ctx.json) ctx.out({ topic: 'search', query, source: 'bundled', total: matches.length, results });
|
|
15
|
+
else ctx.print(results.length
|
|
16
|
+
? `Bundled documentation · ${matches.length} matches · showing ${results.length}\n\n${results.map(result => `${result.title}\n ${result.read}\n ${result.excerpt}`).join('\n\n')}`
|
|
17
|
+
: `No bundled documentation matches "${query}". Run sprid docs to list guides.`);
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
if (ctx.positionals.length > 1) throw new UsageError('Use sprid docs <topic>, or sprid docs search <words>.');
|
|
21
|
+
if (topic && !/^[a-z0-9-]+$/.test(topic)) throw new UsageError('Use a guide name or cli. Run sprid docs to list guides.');
|
|
22
|
+
let document;
|
|
23
|
+
try { document = getDocumentation(topic); }
|
|
24
|
+
catch (error) { if (ctx.flags.offline) throw new UsageError(error.message); }
|
|
25
|
+
let source = 'bundled';
|
|
26
|
+
let note;
|
|
27
|
+
// CLI syntax must describe this executable, even when the server is newer.
|
|
28
|
+
if (!ctx.flags.offline && topic !== 'cli') {
|
|
29
|
+
try {
|
|
30
|
+
const base = ctx.env.SPRID_URL || DEFAULT_API_URL;
|
|
31
|
+
const response = await ctx.fetch(`${base.replace(/\/$/, '')}/api/docs${topic ? '/' + encodeURIComponent(topic) : ''}`, { signal: AbortSignal.timeout(3000) });
|
|
32
|
+
if (!response.ok) throw new Error('Guide request failed');
|
|
33
|
+
const live = await response.json();
|
|
34
|
+
const expectedTopic = document?.topic ?? live.topic;
|
|
35
|
+
if ((!document && !['connect', 'content'].includes(live.topic)) || live.topic !== expectedTopic ||
|
|
36
|
+
(['connect', 'content'].includes(expectedTopic) && live.id !== (document?.id ?? topic)) ||
|
|
37
|
+
(expectedTopic !== 'index' && typeof live.markdown !== 'string') ||
|
|
38
|
+
(expectedTopic === 'index' && !Array.isArray(live.guides))) throw new Error('Invalid guide response');
|
|
39
|
+
document = live;
|
|
40
|
+
source = 'live';
|
|
41
|
+
} catch {
|
|
42
|
+
note = 'Could not read the current guide. This is the copy shipped with your CLI; the website may have newer steps.';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!document) throw new UsageError(`Guide "${topic}" is unavailable. Run sprid docs to list services.`);
|
|
46
|
+
if (ctx.json) ctx.out({ ...document, source, ...(note ? { note } : {}) });
|
|
47
|
+
else {
|
|
48
|
+
if (note) ctx.print(note + '\n');
|
|
49
|
+
if (document.markdown) ctx.print(document.markdown);
|
|
50
|
+
else ctx.print(document.guides.map(guide => `${guide.title}\n sprid docs ${guide.id}\n ${guide.summary}`).join('\n\n'));
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|