@sprid/cli 0.1.5 → 0.1.7
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 +24 -0
- package/README.md +13 -2
- package/bin.mjs +1 -1
- package/package.json +2 -2
- package/src/args.mjs +22 -1
- package/src/cli.mjs +27 -5
- package/src/clipboard.mjs +75 -0
- package/src/commands/ads.mjs +110 -0
- package/src/commands/connect.mjs +99 -26
- package/src/commands/events.mjs +60 -0
- package/src/commands/inbox.mjs +124 -0
- package/src/commands/post.mjs +2 -0
- package/src/commands/publishing.mjs +109 -0
- package/src/commands/status.mjs +9 -1
- package/src/commands/tool.mjs +59 -0
- package/src/docs/commands.mjs +33 -0
- package/src/docs/guides.generated.mjs +299 -52
- package/src/docs/help.mjs +1 -0
- package/src/telemetry.mjs +140 -0
- package/src/tools.mjs +85 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,30 @@
|
|
|
3
3
|
sprid follows semver: a breaking change to a command's arguments, its output
|
|
4
4
|
shape or its exit codes is a major release.
|
|
5
5
|
|
|
6
|
+
## 0.1.7 - 2026-09-24
|
|
7
|
+
|
|
8
|
+
- `sprid connect meta-ads --account <slug>` connects the Meta ad account Sprid
|
|
9
|
+
runs campaigns in. `--page` and `--ad-account` pick among several, `--dataset`
|
|
10
|
+
binds the Events Manager dataset conversion campaigns report to. `sprid docs
|
|
11
|
+
meta-ads` is the setup guide.
|
|
12
|
+
- Usage telemetry. When signed in, each command reports its name, a documented
|
|
13
|
+
subcommand, exit code, error kind, duration and versions to your Sprid
|
|
14
|
+
account, so failures and slow commands show up without anyone filing a bug.
|
|
15
|
+
Never arguments, paths or file contents, never a third party. The first run
|
|
16
|
+
only prints a notice. `sprid telemetry off`, `DO_NOT_TRACK=1` or
|
|
17
|
+
`SPRID_TELEMETRY=0` turns it off; `sprid telemetry` shows which setting
|
|
18
|
+
decided.
|
|
19
|
+
|
|
20
|
+
## 0.1.6 - 2026-09-24
|
|
21
|
+
|
|
22
|
+
- `sprid connect <service> --key-from-clipboard` reads a copied key from the
|
|
23
|
+
clipboard, saves it to Sprid and then empties the clipboard, so the key never
|
|
24
|
+
reaches the screen, shell history or a file. It covers every service whose
|
|
25
|
+
key is a copied string (RevenueCat, Stripe, PostHog, Polar, Paddle, Lemon
|
|
26
|
+
Squeezy, Plausible, Umami, Cloudflare); the ones whose credential is a
|
|
27
|
+
downloaded file keep `--key <file>`. macOS, Windows, and Linux through
|
|
28
|
+
`wl-paste`, `xclip` or `xsel`.
|
|
29
|
+
|
|
6
30
|
## 0.1.5 - 2026-09-24
|
|
7
31
|
|
|
8
32
|
- `sprid connect ga4|plausible|umami` saves a website-analytics key beside the
|
package/README.md
CHANGED
|
@@ -216,8 +216,19 @@ body of `PATCH /api/app-profiles/:id`. They are stored encrypted and no route ev
|
|
|
216
216
|
returns them. `sprid connect <platform>` only opens the platform’s OAuth page and waits for the
|
|
217
217
|
result. Publishing requires the relevant approval or publishing command.
|
|
218
218
|
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
Usage telemetry: when you are signed in, each command ends with one request to
|
|
220
|
+
`POST /api/cli/events` on the API you are signed in to, carrying the command name,
|
|
221
|
+
a documented subcommand (`post push`, never a slug or a file name), the exit code,
|
|
222
|
+
an error kind (an HTTP status, or usage/network/other, never the message), the
|
|
223
|
+
duration, the CLI and Node versions, the OS and whether `CI` is set. Never
|
|
224
|
+
arguments, paths, output or file contents, and nothing goes to any third party.
|
|
225
|
+
The first run that could send only prints a notice. It waits at most one second
|
|
226
|
+
and a failure is ignored. Turn it off with `sprid telemetry off` (saved in
|
|
227
|
+
`~/.sprid/telemetry.json`), or for one shell with `DO_NOT_TRACK=1` or
|
|
228
|
+
`SPRID_TELEMETRY=0`; `help`, `version`, `completion` and `mcp` never send.
|
|
229
|
+
|
|
230
|
+
Credentials stay out of normal output. Build tools can print their own logs; do
|
|
231
|
+
not include secrets in custom build commands.
|
|
221
232
|
|
|
222
233
|
## Updates
|
|
223
234
|
|
package/bin.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { run } from "./src/cli.mjs";
|
|
3
3
|
|
|
4
|
-
run(process.argv.slice(2), { notifyUpdates: true }).then(
|
|
4
|
+
run(process.argv.slice(2), { notifyUpdates: true, telemetry: true }).then(
|
|
5
5
|
// Let stdout drain. An explicit exit truncates large review packets when
|
|
6
6
|
// another process is reading the CLI through a pipe.
|
|
7
7
|
(code) => { process.exitCode = code ?? 0; },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sprid/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "The Sprid command line: connect your app, review results, prepare posts and store screenshots, and release mobile apps.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@sprid/shots": "0.1.1",
|
|
42
|
-
"@sprid/release": "0.1.
|
|
42
|
+
"@sprid/release": "0.1.2"
|
|
43
43
|
},
|
|
44
44
|
"publishConfig": {
|
|
45
45
|
"access": "public"
|
package/src/args.mjs
CHANGED
|
@@ -15,7 +15,10 @@ export const VALUE_FLAGS = new Set([
|
|
|
15
15
|
"platform",
|
|
16
16
|
"entry",
|
|
17
17
|
"account",
|
|
18
|
+
"accounts",
|
|
18
19
|
"page",
|
|
20
|
+
"ad-account",
|
|
21
|
+
"dataset",
|
|
19
22
|
"app",
|
|
20
23
|
"key",
|
|
21
24
|
"key-id",
|
|
@@ -62,9 +65,27 @@ export const VALUE_FLAGS = new Set([
|
|
|
62
65
|
"alt-text",
|
|
63
66
|
"ai-disclosures",
|
|
64
67
|
"bookmark",
|
|
68
|
+
// tool, events, inbox, ads, schedule
|
|
69
|
+
"args",
|
|
70
|
+
"kind",
|
|
71
|
+
"label",
|
|
72
|
+
"areas",
|
|
73
|
+
"why",
|
|
74
|
+
"at",
|
|
75
|
+
"intent",
|
|
76
|
+
"count",
|
|
77
|
+
"daily-cents",
|
|
78
|
+
"objective",
|
|
79
|
+
"platforms",
|
|
80
|
+
"options",
|
|
81
|
+
"ids",
|
|
82
|
+
"cadence",
|
|
83
|
+
"times",
|
|
84
|
+
"start",
|
|
85
|
+
"time",
|
|
65
86
|
]);
|
|
66
87
|
|
|
67
|
-
export const BOOL_FLAGS = new Set(["json", "offline", "check", "no-browser", "from-local", "help", "version", "unanswered", "yes", "clear", "refresh", "cached-only"]);
|
|
88
|
+
export const BOOL_FLAGS = new Set(["json", "offline", "check", "no-browser", "from-local", "help", "version", "unanswered", "yes", "clear", "refresh", "cached-only", "next", "queue-end", "shift-later", "dry-run", "skip-busy"]);
|
|
68
89
|
|
|
69
90
|
/**
|
|
70
91
|
* `sprid connect asc --app myapp --key ./k.p8 --json`
|
package/src/cli.mjs
CHANGED
|
@@ -39,6 +39,11 @@ import { getHelp, renderHelp, suggest } from "./docs/help.mjs";
|
|
|
39
39
|
import { completion } from "./commands/completion.mjs";
|
|
40
40
|
|
|
41
41
|
import { doctor, update, notifyUpdate } from './commands/update.mjs';
|
|
42
|
+
import { telemetry, reportCommand } from './telemetry.mjs';
|
|
43
|
+
import { tool } from "./commands/tool.mjs";
|
|
44
|
+
import { events } from "./commands/events.mjs";
|
|
45
|
+
import { inbox } from "./commands/inbox.mjs";
|
|
46
|
+
import { ads } from "./commands/ads.mjs";
|
|
42
47
|
|
|
43
48
|
export const VERSION = readVersion();
|
|
44
49
|
|
|
@@ -54,7 +59,7 @@ function readVersion() {
|
|
|
54
59
|
export const COMMANDS = {
|
|
55
60
|
plan, research,
|
|
56
61
|
media, capabilities, completion,
|
|
57
|
-
screenshots, release, doctor, update,
|
|
62
|
+
screenshots, release, doctor, update, telemetry,
|
|
58
63
|
apps, insights, account,
|
|
59
64
|
docs,
|
|
60
65
|
login: auth.login,
|
|
@@ -75,6 +80,10 @@ export const COMMANDS = {
|
|
|
75
80
|
reviews: reviews.reviews,
|
|
76
81
|
"marketing-review": marketingReview,
|
|
77
82
|
post: post.post,
|
|
83
|
+
tool,
|
|
84
|
+
events,
|
|
85
|
+
inbox,
|
|
86
|
+
ads,
|
|
78
87
|
pinterest,
|
|
79
88
|
help: help,
|
|
80
89
|
};
|
|
@@ -240,18 +249,31 @@ export async function run(argv, io = {}) {
|
|
|
240
249
|
return 2;
|
|
241
250
|
}
|
|
242
251
|
|
|
252
|
+
const startedAt = Date.now();
|
|
253
|
+
let code, failure = null;
|
|
243
254
|
try {
|
|
244
255
|
if (io.notifyUpdates && !flags.offline && ["status", "next"].includes(command)) await notifyUpdate(ctx).catch(() => {});
|
|
245
|
-
|
|
246
|
-
return code ?? 0;
|
|
256
|
+
code = (await fn(ctx)) ?? 0;
|
|
247
257
|
} catch (e) {
|
|
248
|
-
|
|
258
|
+
failure = e;
|
|
259
|
+
code = e && e.exitCode ? e.exitCode : 1;
|
|
249
260
|
const message = e && e.message ? e.message : String(e);
|
|
250
261
|
if (json) {
|
|
251
262
|
write(JSON.stringify({ ok: false, error: message, status: e?.status ?? null }) + "\n");
|
|
252
263
|
} else {
|
|
253
264
|
writeErr(` ${C.bad("✗")} ${message}\n`);
|
|
254
265
|
}
|
|
255
|
-
return code;
|
|
256
266
|
}
|
|
267
|
+
// Only the installed binary reports (bin.mjs passes this); tests and
|
|
268
|
+
// embedders do not. See telemetry.mjs for exactly what is sent.
|
|
269
|
+
if (io.telemetry) {
|
|
270
|
+
await reportCommand(ctx, {
|
|
271
|
+
command,
|
|
272
|
+
words: passthrough ? parsed.commandArgv : positionals,
|
|
273
|
+
exitCode: code,
|
|
274
|
+
durationMs: Date.now() - startedAt,
|
|
275
|
+
error: failure,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return code;
|
|
257
279
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The clipboard as a key source. A key copied out of a provider's dashboard
|
|
2
|
+
// goes straight from the clipboard into the request: it never reaches the
|
|
3
|
+
// screen, a chat, shell history or a file on disk. Nothing here prints what it
|
|
4
|
+
// read.
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { UsageError } from "./args.mjs";
|
|
8
|
+
|
|
9
|
+
// First reader that exists wins. Linux has no single clipboard tool, so try
|
|
10
|
+
// Wayland, then the two X11 ones.
|
|
11
|
+
const READERS = {
|
|
12
|
+
darwin: [["pbpaste", []]],
|
|
13
|
+
win32: [["powershell", ["-NoProfile", "-Command", "Get-Clipboard -Raw"]]],
|
|
14
|
+
linux: [
|
|
15
|
+
["wl-paste", ["--no-newline"]],
|
|
16
|
+
["xclip", ["-selection", "clipboard", "-o"]],
|
|
17
|
+
["xsel", ["--clipboard", "--output"]],
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
const CLEARERS = {
|
|
21
|
+
darwin: [["pbcopy", []]],
|
|
22
|
+
win32: [["powershell", ["-NoProfile", "-Command", "Set-Clipboard -Value $null"]]],
|
|
23
|
+
linux: [
|
|
24
|
+
["wl-copy", ["--clear"]],
|
|
25
|
+
["xclip", ["-selection", "clipboard", "-i"]],
|
|
26
|
+
["xsel", ["--clipboard", "--clear"]],
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// A clearer's stdout is ignored, not piped: xclip forks a child that keeps
|
|
31
|
+
// owning the selection, and a pipe it inherits would hold spawnSync open.
|
|
32
|
+
function run(candidates, input) {
|
|
33
|
+
const clearing = input !== undefined;
|
|
34
|
+
for (const [cmd, args] of candidates ?? []) {
|
|
35
|
+
const r = spawnSync(cmd, args, { input: input ?? "", encoding: "utf8", stdio: ["pipe", clearing ? "ignore" : "pipe", "ignore"] });
|
|
36
|
+
if (r.error?.code === "ENOENT") continue;
|
|
37
|
+
if (r.status === 0) return { ok: true, out: r.stdout ?? "" };
|
|
38
|
+
}
|
|
39
|
+
return { ok: false, out: "" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check what the clipboard holds looks like a key, not what is usually there
|
|
44
|
+
* by mistake: nothing, or the command itself (copied out of a guide or a chat
|
|
45
|
+
* a moment before). Keys and tokens from every provider we read are one
|
|
46
|
+
* unbroken string, so any whitespace means it is not one.
|
|
47
|
+
*/
|
|
48
|
+
export function checkClipboardKey(raw, what = "the key") {
|
|
49
|
+
const text = String(raw ?? "").trim();
|
|
50
|
+
if (!text) throw new UsageError(`The clipboard is empty. Copy ${what}, then run this again.`);
|
|
51
|
+
if (/\s/.test(text)) {
|
|
52
|
+
throw new UsageError(
|
|
53
|
+
`The clipboard holds text with spaces, not a key (often this command itself). Copy ${what}, then run this again.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (text.length < 8) throw new UsageError(`The clipboard holds something too short to be a key. Copy ${what}, then run this again.`);
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readClipboardKey(what, platform = process.platform, exec = run) {
|
|
61
|
+
const r = exec(READERS[platform]);
|
|
62
|
+
if (!r.ok) {
|
|
63
|
+
throw new UsageError(
|
|
64
|
+
platform === "linux"
|
|
65
|
+
? "Could not read the clipboard. Install wl-clipboard, xclip or xsel, or save the key to a file and pass --key <file>."
|
|
66
|
+
: "Could not read the clipboard. Save the key to a file and pass --key <file> instead.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return checkClipboardKey(r.out, what);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Best effort: a key left on the clipboard is the next paste's accident. */
|
|
73
|
+
export function clearClipboard(platform = process.platform, exec = run) {
|
|
74
|
+
return exec(CLEARERS[platform], "").ok;
|
|
75
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// sprid ads: paid campaigns on Meta through the same tools the ads skill
|
|
2
|
+
// calls. Everything is created paused. The two verbs that change what is spent
|
|
3
|
+
// (launch, budget) show what they commit and ask, and need a token holding
|
|
4
|
+
// ads:spend; the server refuses them otherwise.
|
|
5
|
+
|
|
6
|
+
import { UsageError } from "../args.mjs";
|
|
7
|
+
import { C, OK } from "../format.mjs";
|
|
8
|
+
import { callTool, needInt, readJson, show } from "../tools.mjs";
|
|
9
|
+
import { confirm } from "./reviews.mjs";
|
|
10
|
+
|
|
11
|
+
const USAGE = [
|
|
12
|
+
"sprid ads [overview] --account <slug>",
|
|
13
|
+
"sprid ads status|council|metrics <adSetId> --account <slug> [--days N]",
|
|
14
|
+
"sprid ads create campaign|set|creative|ad|batch --file <args.json> --account <slug>",
|
|
15
|
+
"sprid ads launch <adSetId> --account <slug> [--yes]",
|
|
16
|
+
"sprid ads budget <adSetId> --daily-cents N --account <slug> [--yes]",
|
|
17
|
+
"sprid ads pause <adSetId> --account <slug> [--yes]",
|
|
18
|
+
"sprid ads close <adSetId> --account <slug>",
|
|
19
|
+
].join("\n ");
|
|
20
|
+
|
|
21
|
+
/** What `create <thing>` calls. The file carries the tool's arguments. */
|
|
22
|
+
const CREATE = {
|
|
23
|
+
campaign: "create_ad_campaign",
|
|
24
|
+
set: "create_ad_set",
|
|
25
|
+
creative: "create_ad_creative",
|
|
26
|
+
ad: "create_ad",
|
|
27
|
+
batch: "create_ad_batch",
|
|
28
|
+
};
|
|
29
|
+
const READS = { status: "ad_status", council: "ad_council", metrics: "ad_metrics" };
|
|
30
|
+
|
|
31
|
+
export async function ads(ctx) {
|
|
32
|
+
const [sub, arg] = ctx.positionals;
|
|
33
|
+
const account = ctx.flags.account;
|
|
34
|
+
if (typeof account !== "string") throw new UsageError(USAGE);
|
|
35
|
+
if (!sub || sub === "overview") {
|
|
36
|
+
show(ctx, await callTool(ctx, "ads_overview", { account }));
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
if (READS[sub]) {
|
|
40
|
+
const adSetId = needInt(arg, `sprid ads ${sub} <adSetId> --account <slug>`);
|
|
41
|
+
const days = ctx.flags.days && sub !== "status" ? { days: Number(ctx.flags.days) } : {};
|
|
42
|
+
show(ctx, await callTool(ctx, READS[sub], { account, adSetId, ...days }));
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
if (sub === "create") {
|
|
46
|
+
const tool = CREATE[arg];
|
|
47
|
+
if (!tool || typeof ctx.flags.file !== "string") throw new UsageError(`sprid ads create ${Object.keys(CREATE).join("|")} --file <args.json> --account <slug>\n Arguments: sprid tool describe ${tool ?? "create_ad_campaign"}`);
|
|
48
|
+
show(ctx, await callTool(ctx, tool, { ...(await readJson(ctx, ctx.flags.file)), account }));
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
if (sub === "launch") return launch(ctx, account, needInt(arg, "sprid ads launch <adSetId> --account <slug> [--yes]"));
|
|
52
|
+
if (sub === "budget") return budget(ctx, account, needInt(arg, "sprid ads budget <adSetId> --daily-cents N --account <slug> [--yes]"));
|
|
53
|
+
if (sub === "pause") return pause(ctx, account, needInt(arg, "sprid ads pause <adSetId> --account <slug> [--yes]"));
|
|
54
|
+
if (sub === "close") {
|
|
55
|
+
show(ctx, await callTool(ctx, "close_ad_set", { account, adSetId: needInt(arg, "sprid ads close <adSetId> --account <slug>") }));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
throw new UsageError(USAGE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ask before a call that spends or resets learning. --yes skips the prompt.
|
|
63
|
+
* Without a terminal (or with --json) there is nobody to ask, so it stops
|
|
64
|
+
* with exit 2; a person who answers no gets exit 1.
|
|
65
|
+
*/
|
|
66
|
+
async function approve(ctx, summary, question) {
|
|
67
|
+
if (ctx.flags.yes) return 0;
|
|
68
|
+
if (ctx.json || !ctx.stdin?.isTTY) {
|
|
69
|
+
const msg = "Re-run with --yes to go ahead without the prompt.";
|
|
70
|
+
if (ctx.json) ctx.out({ ok: false, error: msg, summary });
|
|
71
|
+
else {
|
|
72
|
+
ctx.print(` ${summary}`);
|
|
73
|
+
ctx.warn(` ✗ ${msg}`);
|
|
74
|
+
}
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
ctx.print(` ${summary}\n`);
|
|
78
|
+
if (await confirm(ctx, ` ${question} [y/N] `)) return 0;
|
|
79
|
+
ctx.print(" Nothing changed.");
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function launch(ctx, account, adSetId) {
|
|
84
|
+
const preview = await callTool(ctx, "launch_ads", { account, adSetId, confirmed: false });
|
|
85
|
+
const stop = await approve(ctx, preview?.message ?? `Launch ad set ${adSetId}.`, "Launch and start spending?");
|
|
86
|
+
if (stop) return stop;
|
|
87
|
+
const res = await callTool(ctx, "launch_ads", { account, adSetId, confirmed: true });
|
|
88
|
+
if (ctx.json) ctx.out(res);
|
|
89
|
+
else ctx.print(` ${C.ok(OK)} Ad set ${adSetId} is live. Leave it alone for 3 days: every edit resets Meta's learning phase.`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function budget(ctx, account, adSetId) {
|
|
94
|
+
const cents = Number(ctx.flags["daily-cents"]);
|
|
95
|
+
if (!Number.isInteger(cents) || cents <= 0) throw new UsageError("Pass the new daily budget in cents: --daily-cents 1500 is 15.00 a day.");
|
|
96
|
+
const summary = `Set ad set ${adSetId}'s daily budget to ${(cents / 100).toFixed(2)}. It is checked against the account's daily and per-campaign caps.`;
|
|
97
|
+
const stop = await approve(ctx, summary, "Change the budget?");
|
|
98
|
+
if (stop) return stop;
|
|
99
|
+
show(ctx, await callTool(ctx, "update_ad_budget", { account, adSetId, dailyBudgetCents: cents }));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function pause(ctx, account, adSetId) {
|
|
104
|
+
const stop = await approve(ctx, `Pause ad set ${adSetId}. Pausing resets Meta's learning phase when it resumes.`, "Pause it?");
|
|
105
|
+
if (stop) return stop;
|
|
106
|
+
const res = await callTool(ctx, "pause_ad_set", { account, adSetId });
|
|
107
|
+
if (ctx.json) ctx.out(res);
|
|
108
|
+
else ctx.print(` ${C.ok(OK)} Ad set ${adSetId} paused.`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
package/src/commands/connect.mjs
CHANGED
|
@@ -15,12 +15,32 @@ import { UsageError } from "../args.mjs";
|
|
|
15
15
|
import { secretsPath } from "../creds.mjs";
|
|
16
16
|
import { ApiError } from "../http.mjs";
|
|
17
17
|
import { pendingGrant } from "../pending.mjs";
|
|
18
|
+
import { clearClipboard, readClipboardKey } from "../clipboard.mjs";
|
|
18
19
|
import { C, BAD, EXPIRING, NONE, OK, table, wrap } from "../format.mjs";
|
|
19
20
|
import { connectionRows } from "./status.mjs";
|
|
20
21
|
import { confirm } from "./reviews.mjs";
|
|
21
22
|
import { expandHome, listProfiles, patchProfile, readKeyFile, readLocalApp, resolveProfile } from "../profiles.mjs";
|
|
22
23
|
|
|
23
24
|
export const CHANNEL_PLATFORMS = ["instagram", "tiktok", "youtube", "linkedin", "facebook", "pinterest", "x"];
|
|
25
|
+
// Connects like a channel, through the platform's sign-in, but never publishes:
|
|
26
|
+
// it is the ad account Sprid runs campaigns in. The CLI spells it with a hyphen
|
|
27
|
+
// like every other target; the server's platform id has an underscore.
|
|
28
|
+
const AD_CHANNELS = {
|
|
29
|
+
"meta-ads": "meta_ads",
|
|
30
|
+
meta_ads: "meta_ads",
|
|
31
|
+
"google-ads": "google_ads",
|
|
32
|
+
google_ads: "google_ads",
|
|
33
|
+
"tiktok-ads": "tiktok_ads",
|
|
34
|
+
tiktok_ads: "tiktok_ads",
|
|
35
|
+
};
|
|
36
|
+
// Connects like a channel but neither publishes nor pays: the TikTok account
|
|
37
|
+
// holder's grant that lets the Inbox read and answer comments on its posts.
|
|
38
|
+
const INBOX_CHANNELS = {
|
|
39
|
+
"tiktok-comments": "tiktok_accounts",
|
|
40
|
+
"tiktok-accounts": "tiktok_accounts",
|
|
41
|
+
tiktok_accounts: "tiktok_accounts",
|
|
42
|
+
};
|
|
43
|
+
const channelPlatform = (what) => (CHANNEL_PLATFORMS.includes(what) ? what : AD_CHANNELS[what] ?? INBOX_CHANNELS[what]);
|
|
24
44
|
export const SENSOR_SERVICES = [
|
|
25
45
|
"asc",
|
|
26
46
|
"play",
|
|
@@ -84,11 +104,11 @@ export async function connect(ctx) {
|
|
|
84
104
|
const [what] = ctx.positionals;
|
|
85
105
|
if (ctx.flags["from-local"]) return fromLocal(ctx);
|
|
86
106
|
if (!what) return wizard(ctx);
|
|
87
|
-
if (
|
|
107
|
+
if (channelPlatform(what)) return connectChannel(ctx, channelPlatform(what));
|
|
88
108
|
if (SENSOR_SERVICES.includes(what)) return connectSensor(ctx, what);
|
|
89
109
|
if (PROVIDERS.includes(what)) return connectProvider(ctx, what);
|
|
90
110
|
throw new UsageError(
|
|
91
|
-
`Unknown target "${what}". Channels: ${CHANNEL_PLATFORMS.join(", ")}. Keys: ${SENSOR_SERVICES.join(", ")}. Model keys: ${PROVIDERS.join(", ")}.`,
|
|
111
|
+
`Unknown target "${what}". Channels: ${CHANNEL_PLATFORMS.join(", ")}, meta-ads. Keys: ${SENSOR_SERVICES.join(", ")}. Model keys: ${PROVIDERS.join(", ")}.`,
|
|
92
112
|
);
|
|
93
113
|
}
|
|
94
114
|
|
|
@@ -214,10 +234,19 @@ async function connectChannel(ctx, platform) {
|
|
|
214
234
|
const returnUrl = `${ctx.appUrl()}/connected`;
|
|
215
235
|
const auth = ctx.auth();
|
|
216
236
|
const pageId = ctx.flags.page;
|
|
217
|
-
|
|
218
|
-
const
|
|
237
|
+
const adAccountId = ctx.flags["ad-account"];
|
|
238
|
+
const pixelId = ctx.flags.dataset;
|
|
239
|
+
if (pageId && platform !== "facebook" && platform !== "meta_ads") throw new UsageError("--page is only supported for Facebook and Meta Ads connections.");
|
|
240
|
+
if (adAccountId && !["meta_ads", "google_ads", "tiktok_ads"].includes(platform)) throw new UsageError("--ad-account is only supported for Meta Ads, Google Ads and TikTok Ads connections.");
|
|
241
|
+
if (pixelId && platform !== "meta_ads") throw new UsageError("--dataset is only supported for Meta Ads connections.");
|
|
242
|
+
const selection = {
|
|
243
|
+
...(pageId ? { pageId: String(pageId) } : {}),
|
|
244
|
+
...(adAccountId ? { adAccountId: String(adAccountId) } : {}),
|
|
245
|
+
...(pixelId ? { pixelId: String(pixelId) } : {}),
|
|
246
|
+
};
|
|
247
|
+
const pending = pendingGrant(ctx, `channel:${auth.apiUrl}:${auth.token}:${account}:${platform}:${pageId ?? ""}:${adAccountId ?? ""}:${pixelId ?? ""}`);
|
|
219
248
|
const previous = pending.read();
|
|
220
|
-
const { url, pendingId } = previous ?? await ctx.api().post(`/api/channels/connect/${platform}`, { accountSlug: account, returnUrl, ...
|
|
249
|
+
const { url, pendingId } = previous ?? await ctx.api().post(`/api/channels/connect/${platform}`, { accountSlug: account, returnUrl, ...selection });
|
|
221
250
|
if (!url) throw new ApiError(500, "The server returned no authorize URL.");
|
|
222
251
|
if (!previous && pendingId) pending.write({ url, pendingId, expiresAt: Date.now() + PENDING_TIMEOUT_MS });
|
|
223
252
|
|
|
@@ -243,7 +272,8 @@ async function connectChannel(ctx, platform) {
|
|
|
243
272
|
const s = last?.status ?? "pending";
|
|
244
273
|
if (s === "connected") {
|
|
245
274
|
pending.clear();
|
|
246
|
-
const handle = last.channel?.displayName ?
|
|
275
|
+
const handle = !last.channel?.displayName ? label(platform)
|
|
276
|
+
: ["meta_ads", "google_ads", "tiktok_ads"].includes(platform) ? last.channel.displayName : `@${last.channel.displayName}`;
|
|
247
277
|
if (ctx.json) ctx.out({ ok: true, status: s, channel: last.channel, account });
|
|
248
278
|
else ctx.print(` ${C.ok(OK)} ${handle} connected to ${account}`);
|
|
249
279
|
return 0;
|
|
@@ -253,7 +283,7 @@ async function connectChannel(ctx, platform) {
|
|
|
253
283
|
? `${last.error || "That login is already connected to another account."} Move it in Settings → Accounts, or connect a different login.`
|
|
254
284
|
: s === "expired" || s === "pending"
|
|
255
285
|
? "Timed out waiting for the browser (10 minutes). Run the command again."
|
|
256
|
-
: last?.error || `Connect ${s}.`;
|
|
286
|
+
: cliFlags(last?.error) || `Connect ${s}.`;
|
|
257
287
|
if (s !== "pending") pending.clear();
|
|
258
288
|
if (ctx.json) {
|
|
259
289
|
ctx.out({ ok: false, status: s, error: message, channel: last?.channel ?? null });
|
|
@@ -263,7 +293,12 @@ async function connectChannel(ctx, platform) {
|
|
|
263
293
|
}
|
|
264
294
|
|
|
265
295
|
function label(platform) {
|
|
266
|
-
return { instagram: "Instagram", tiktok: "TikTok", youtube: "YouTube", linkedin: "LinkedIn", facebook: "Facebook", pinterest: "Pinterest", x: "X" }[platform] ?? platform;
|
|
296
|
+
return { instagram: "Instagram", tiktok: "TikTok", youtube: "YouTube", linkedin: "LinkedIn", facebook: "Facebook", pinterest: "Pinterest", x: "X", meta_ads: "Meta Ads", google_ads: "Google Ads", tiktok_ads: "TikTok Ads", tiktok_accounts: "TikTok comments" }[platform] ?? platform;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The server names the fields to re-run with; here they are flags. */
|
|
300
|
+
function cliFlags(message) {
|
|
301
|
+
return message?.replace(/\bpageId\b/g, "--page").replace(/\badAccountId\b/g, "--ad-account");
|
|
267
302
|
}
|
|
268
303
|
|
|
269
304
|
// ─── sensors (keys) ──────────────────────────────────────────────────────────
|
|
@@ -283,13 +318,25 @@ export function fileOrValue(arg, cwd) {
|
|
|
283
318
|
return String(arg);
|
|
284
319
|
}
|
|
285
320
|
|
|
286
|
-
|
|
287
|
-
|
|
321
|
+
// Services whose credential is a downloaded file (a .p8, a service-account
|
|
322
|
+
// JSON). There is nothing to copy, so the clipboard is not offered for them.
|
|
323
|
+
const FILE_KEY_SERVICES = new Set(["asc", "play", "gsc", "ga4"]);
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Flags → the PATCH body for one service. Reads files; never echoes them.
|
|
327
|
+
* `pasted` is a key already read from the clipboard (`--key-from-clipboard`);
|
|
328
|
+
* it takes the place of the --key / --token file.
|
|
329
|
+
*/
|
|
330
|
+
export function sensorPatch(service, flags, cwd, pasted) {
|
|
288
331
|
const need = (name) => {
|
|
289
332
|
const v = flags[name];
|
|
290
|
-
if (!v || v === true)
|
|
333
|
+
if (!v || v === true) {
|
|
334
|
+
const hint = !FILE_KEY_SERVICES.has(service) && (name === "key" || name === "token") ? ` (or --key-from-clipboard)` : "";
|
|
335
|
+
throw new UsageError(`sprid connect ${service} needs --${name}${hint}`);
|
|
336
|
+
}
|
|
291
337
|
return String(v);
|
|
292
338
|
};
|
|
339
|
+
const secret = (name) => pasted ?? fileOrValue(need(name), cwd);
|
|
293
340
|
switch (service) {
|
|
294
341
|
case "asc":
|
|
295
342
|
return {
|
|
@@ -307,7 +354,7 @@ export function sensorPatch(service, flags, cwd) {
|
|
|
307
354
|
secrets: { gscServiceAccountJson: readKeyFile(need("key"), cwd) },
|
|
308
355
|
};
|
|
309
356
|
case "posthog": {
|
|
310
|
-
const body = { posthogProjectId: need("project"), secrets: { posthogApiKey:
|
|
357
|
+
const body = { posthogProjectId: need("project"), secrets: { posthogApiKey: secret("key") } };
|
|
311
358
|
if (flags.host && flags.host !== true) {
|
|
312
359
|
const host = String(flags.host);
|
|
313
360
|
body.posthogHost = host === "eu" || host === "us" ? `https://${host}.posthog.com` : host;
|
|
@@ -331,7 +378,7 @@ export function sensorPatch(service, flags, cwd) {
|
|
|
331
378
|
case "plausible": {
|
|
332
379
|
const body = {
|
|
333
380
|
plausibleSiteId: String(need("site")).replace(/^https?:\/\//, "").replace(/\/+$/, ""),
|
|
334
|
-
secrets: { plausibleApiKey:
|
|
381
|
+
secrets: { plausibleApiKey: secret("key") },
|
|
335
382
|
};
|
|
336
383
|
if (flags.host && flags.host !== true) body.plausibleHost = String(flags.host).replace(/\/+$/, "");
|
|
337
384
|
if (flags.use) body.trafficProvider = "plausible";
|
|
@@ -340,7 +387,7 @@ export function sensorPatch(service, flags, cwd) {
|
|
|
340
387
|
case "umami": {
|
|
341
388
|
const body = {
|
|
342
389
|
umamiWebsiteId: String(need("site")),
|
|
343
|
-
secrets: { umamiApiKey:
|
|
390
|
+
secrets: { umamiApiKey: secret("key") },
|
|
344
391
|
};
|
|
345
392
|
// A self-hosted Umami serves its API under /api. Leaving that off is the
|
|
346
393
|
// commonest setup mistake, and it fails as a 404 on every read, so add
|
|
@@ -353,41 +400,65 @@ export function sensorPatch(service, flags, cwd) {
|
|
|
353
400
|
return body;
|
|
354
401
|
}
|
|
355
402
|
case "revenuecat":
|
|
356
|
-
return { revenuecatProjectId: need("project"), secrets: { revenuecatApiKey:
|
|
403
|
+
return { revenuecatProjectId: need("project"), secrets: { revenuecatApiKey: secret("key") } };
|
|
357
404
|
// Stripe and Paddle need no id: the key names the account. Polar takes an
|
|
358
405
|
// optional organization id (an org token already implies one), Lemon
|
|
359
406
|
// Squeezy needs its store id.
|
|
360
407
|
case "stripe":
|
|
361
|
-
return { secrets: { stripeApiKey:
|
|
408
|
+
return { secrets: { stripeApiKey: secret("key") } };
|
|
362
409
|
case "polar": {
|
|
363
|
-
const body = { secrets: { polarApiKey:
|
|
410
|
+
const body = { secrets: { polarApiKey: secret("key") } };
|
|
364
411
|
if (flags.org && flags.org !== true) body.polarOrganizationId = String(flags.org);
|
|
365
412
|
return body;
|
|
366
413
|
}
|
|
367
414
|
case "lemonsqueezy":
|
|
368
415
|
return {
|
|
369
416
|
lemonSqueezyStoreId: String(need("store")),
|
|
370
|
-
secrets: { lemonSqueezyApiKey:
|
|
417
|
+
secrets: { lemonSqueezyApiKey: secret("key") },
|
|
371
418
|
};
|
|
372
419
|
case "paddle": {
|
|
373
|
-
const body = { secrets: { paddleApiKey:
|
|
420
|
+
const body = { secrets: { paddleApiKey: secret("key") } };
|
|
374
421
|
if (flags.env && flags.env !== true) body.paddleEnvironment = String(flags.env) === "sandbox" ? "sandbox" : "live";
|
|
375
422
|
return body;
|
|
376
423
|
}
|
|
377
424
|
case "cloudflare":
|
|
378
|
-
return { cloudflareZoneId: need("zone"), secrets: { cloudflareApiToken:
|
|
425
|
+
return { cloudflareZoneId: need("zone"), secrets: { cloudflareApiToken: secret("token") } };
|
|
379
426
|
default:
|
|
380
427
|
throw new UsageError(`Unknown key type "${service}"`);
|
|
381
428
|
}
|
|
382
429
|
}
|
|
383
430
|
|
|
431
|
+
/**
|
|
432
|
+
* `--key-from-clipboard`: the key a provider just showed, read at run time.
|
|
433
|
+
* Refused with a file flag beside it (which one would win is a guess) and for
|
|
434
|
+
* services whose credential is a download.
|
|
435
|
+
*/
|
|
436
|
+
function pastedKey(ctx, service, label) {
|
|
437
|
+
if (!ctx.flags["key-from-clipboard"]) return undefined;
|
|
438
|
+
if (FILE_KEY_SERVICES.has(service)) {
|
|
439
|
+
throw new UsageError(`${label} uses a downloaded key file. Pass --key <path to the file> instead of --key-from-clipboard.`);
|
|
440
|
+
}
|
|
441
|
+
if ((ctx.flags.key && ctx.flags.key !== true) || (ctx.flags.token && ctx.flags.token !== true)) {
|
|
442
|
+
throw new UsageError("Pass either --key-from-clipboard or a key file, not both.");
|
|
443
|
+
}
|
|
444
|
+
return readClipboardKey(`the ${label} key`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function clearPasted(ctx, pasted) {
|
|
448
|
+
if (pasted === undefined) return;
|
|
449
|
+
const cleared = clearClipboard();
|
|
450
|
+
if (!ctx.json) ctx.print(C.dim(cleared ? " Clipboard cleared." : " Could not clear the clipboard. Copy something else over the key."));
|
|
451
|
+
}
|
|
452
|
+
|
|
384
453
|
async function connectSensor(ctx, service) {
|
|
385
454
|
if (ctx.flags.clear) return clearSensor(ctx, service);
|
|
386
|
-
const
|
|
455
|
+
const pasted = pastedKey(ctx, service, SENSOR_LABEL[service]);
|
|
456
|
+
const body = sensorPatch(service, ctx.flags, ctx.cwd, pasted);
|
|
387
457
|
const wid = await ctx.workspaceId();
|
|
388
458
|
const profile = await resolveProfile(ctx, wid);
|
|
389
459
|
const updated = await patchProfile(ctx, wid, profile.id, body);
|
|
390
460
|
report(ctx, updated, Object.keys(body.secrets), `${SENSOR_LABEL[service]} key`, wid, service);
|
|
461
|
+
clearPasted(ctx, pasted);
|
|
391
462
|
return 0;
|
|
392
463
|
}
|
|
393
464
|
|
|
@@ -432,9 +503,10 @@ function report(ctx, profile, keys, what, workspaceId, service) {
|
|
|
432
503
|
*/
|
|
433
504
|
async function connectProvider(ctx, provider) {
|
|
434
505
|
if (ctx.flags.clear) return clearProvider(ctx, provider);
|
|
506
|
+
const pasted = pastedKey(ctx, provider, provider);
|
|
435
507
|
const raw = ctx.flags.key;
|
|
436
|
-
if (!raw || raw === true) throw new UsageError(`sprid connect ${provider} --key <file or key
|
|
437
|
-
const apiKey = fileOrValue(raw, ctx.cwd);
|
|
508
|
+
if (pasted === undefined && (!raw || raw === true)) throw new UsageError(`sprid connect ${provider} --key <file or key> (or --key-from-clipboard)`);
|
|
509
|
+
const apiKey = pasted ?? fileOrValue(raw, ctx.cwd);
|
|
438
510
|
if (apiKey.length < 8) throw new UsageError("That key is too short to be one.");
|
|
439
511
|
const wid = await ctx.workspaceId();
|
|
440
512
|
let created;
|
|
@@ -452,6 +524,7 @@ async function connectProvider(ctx, provider) {
|
|
|
452
524
|
ctx.print(` ${C.ok(OK)} ${provider} key set on the workspace${hint ? ` ${C.dim(hint)}` : ""}`);
|
|
453
525
|
if (provider === "anthropic" || provider === "openai") ctx.print(C.dim(" Review replies and other drafting now run on your own key."));
|
|
454
526
|
}
|
|
527
|
+
clearPasted(ctx, pasted);
|
|
455
528
|
return 0;
|
|
456
529
|
}
|
|
457
530
|
|
|
@@ -556,12 +629,12 @@ async function fromLocal(ctx) {
|
|
|
556
629
|
* server refuses to forget a channel that has published.
|
|
557
630
|
*/
|
|
558
631
|
export async function disconnect(ctx) {
|
|
559
|
-
const
|
|
632
|
+
const platform = channelPlatform(ctx.positionals[0]);
|
|
560
633
|
const account = ctx.flags.account;
|
|
561
634
|
const forget = !!ctx.flags.forget;
|
|
562
|
-
if (!platform
|
|
635
|
+
if (!platform) {
|
|
563
636
|
throw new UsageError(
|
|
564
|
-
`sprid disconnect <${CHANNEL_PLATFORMS.join("|")}> --account <slug> [--forget]`,
|
|
637
|
+
`sprid disconnect <${[...CHANNEL_PLATFORMS, "meta-ads"].join("|")}> --account <slug> [--forget]`,
|
|
565
638
|
);
|
|
566
639
|
}
|
|
567
640
|
if (!account) throw new UsageError(`sprid disconnect ${platform} --account <slug>`);
|