@sprid/cli 0.1.6 → 0.1.8
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 +20 -0
- package/README.md +13 -2
- package/bin.mjs +1 -1
- package/package.json +3 -3
- package/src/args.mjs +22 -1
- package/src/cli.mjs +27 -5
- package/src/commands/ads.mjs +110 -0
- package/src/commands/connect.mjs +45 -11
- 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 +32 -0
- package/src/docs/guides.generated.mjs +252 -5
- 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,26 @@
|
|
|
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.8 - 2026-09-25
|
|
7
|
+
|
|
8
|
+
- `sprid screenshots` draws a device that looks like one: a metal band with side
|
|
9
|
+
buttons, a thin bezel, and the camera each platform has. `frameImages` in the
|
|
10
|
+
config swaps in device artwork you hold a licence for (`@sprid/shots` 0.1.2).
|
|
11
|
+
|
|
12
|
+
## 0.1.7 - 2026-09-24
|
|
13
|
+
|
|
14
|
+
- `sprid connect meta-ads --account <slug>` connects the Meta ad account Sprid
|
|
15
|
+
runs campaigns in. `--page` and `--ad-account` pick among several, `--dataset`
|
|
16
|
+
binds the Events Manager dataset conversion campaigns report to. `sprid docs
|
|
17
|
+
meta-ads` is the setup guide.
|
|
18
|
+
- Usage telemetry. When signed in, each command reports its name, a documented
|
|
19
|
+
subcommand, exit code, error kind, duration and versions to your Sprid
|
|
20
|
+
account, so failures and slow commands show up without anyone filing a bug.
|
|
21
|
+
Never arguments, paths or file contents, never a third party. The first run
|
|
22
|
+
only prints a notice. `sprid telemetry off`, `DO_NOT_TRACK=1` or
|
|
23
|
+
`SPRID_TELEMETRY=0` turns it off; `sprid telemetry` shows which setting
|
|
24
|
+
decided.
|
|
25
|
+
|
|
6
26
|
## 0.1.6 - 2026-09-24
|
|
7
27
|
|
|
8
28
|
- `sprid connect <service> --key-from-clipboard` reads a copied key from 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.8",
|
|
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",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"node": ">=18"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sprid/shots": "0.1.
|
|
42
|
-
"@sprid/release": "0.1.
|
|
41
|
+
"@sprid/shots": "0.1.2",
|
|
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,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
|
@@ -22,6 +22,25 @@ import { confirm } from "./reviews.mjs";
|
|
|
22
22
|
import { expandHome, listProfiles, patchProfile, readKeyFile, readLocalApp, resolveProfile } from "../profiles.mjs";
|
|
23
23
|
|
|
24
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]);
|
|
25
44
|
export const SENSOR_SERVICES = [
|
|
26
45
|
"asc",
|
|
27
46
|
"play",
|
|
@@ -85,11 +104,11 @@ export async function connect(ctx) {
|
|
|
85
104
|
const [what] = ctx.positionals;
|
|
86
105
|
if (ctx.flags["from-local"]) return fromLocal(ctx);
|
|
87
106
|
if (!what) return wizard(ctx);
|
|
88
|
-
if (
|
|
107
|
+
if (channelPlatform(what)) return connectChannel(ctx, channelPlatform(what));
|
|
89
108
|
if (SENSOR_SERVICES.includes(what)) return connectSensor(ctx, what);
|
|
90
109
|
if (PROVIDERS.includes(what)) return connectProvider(ctx, what);
|
|
91
110
|
throw new UsageError(
|
|
92
|
-
`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(", ")}.`,
|
|
93
112
|
);
|
|
94
113
|
}
|
|
95
114
|
|
|
@@ -215,10 +234,19 @@ async function connectChannel(ctx, platform) {
|
|
|
215
234
|
const returnUrl = `${ctx.appUrl()}/connected`;
|
|
216
235
|
const auth = ctx.auth();
|
|
217
236
|
const pageId = ctx.flags.page;
|
|
218
|
-
|
|
219
|
-
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 ?? ""}`);
|
|
220
248
|
const previous = pending.read();
|
|
221
|
-
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 });
|
|
222
250
|
if (!url) throw new ApiError(500, "The server returned no authorize URL.");
|
|
223
251
|
if (!previous && pendingId) pending.write({ url, pendingId, expiresAt: Date.now() + PENDING_TIMEOUT_MS });
|
|
224
252
|
|
|
@@ -244,7 +272,8 @@ async function connectChannel(ctx, platform) {
|
|
|
244
272
|
const s = last?.status ?? "pending";
|
|
245
273
|
if (s === "connected") {
|
|
246
274
|
pending.clear();
|
|
247
|
-
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}`;
|
|
248
277
|
if (ctx.json) ctx.out({ ok: true, status: s, channel: last.channel, account });
|
|
249
278
|
else ctx.print(` ${C.ok(OK)} ${handle} connected to ${account}`);
|
|
250
279
|
return 0;
|
|
@@ -254,7 +283,7 @@ async function connectChannel(ctx, platform) {
|
|
|
254
283
|
? `${last.error || "That login is already connected to another account."} Move it in Settings → Accounts, or connect a different login.`
|
|
255
284
|
: s === "expired" || s === "pending"
|
|
256
285
|
? "Timed out waiting for the browser (10 minutes). Run the command again."
|
|
257
|
-
: last?.error || `Connect ${s}.`;
|
|
286
|
+
: cliFlags(last?.error) || `Connect ${s}.`;
|
|
258
287
|
if (s !== "pending") pending.clear();
|
|
259
288
|
if (ctx.json) {
|
|
260
289
|
ctx.out({ ok: false, status: s, error: message, channel: last?.channel ?? null });
|
|
@@ -264,7 +293,12 @@ async function connectChannel(ctx, platform) {
|
|
|
264
293
|
}
|
|
265
294
|
|
|
266
295
|
function label(platform) {
|
|
267
|
-
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");
|
|
268
302
|
}
|
|
269
303
|
|
|
270
304
|
// ─── sensors (keys) ──────────────────────────────────────────────────────────
|
|
@@ -595,12 +629,12 @@ async function fromLocal(ctx) {
|
|
|
595
629
|
* server refuses to forget a channel that has published.
|
|
596
630
|
*/
|
|
597
631
|
export async function disconnect(ctx) {
|
|
598
|
-
const
|
|
632
|
+
const platform = channelPlatform(ctx.positionals[0]);
|
|
599
633
|
const account = ctx.flags.account;
|
|
600
634
|
const forget = !!ctx.flags.forget;
|
|
601
|
-
if (!platform
|
|
635
|
+
if (!platform) {
|
|
602
636
|
throw new UsageError(
|
|
603
|
-
`sprid disconnect <${CHANNEL_PLATFORMS.join("|")}> --account <slug> [--forget]`,
|
|
637
|
+
`sprid disconnect <${[...CHANNEL_PLATFORMS, "meta-ads"].join("|")}> --account <slug> [--forget]`,
|
|
604
638
|
);
|
|
605
639
|
}
|
|
606
640
|
if (!account) throw new UsageError(`sprid disconnect ${platform} --account <slug>`);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// sprid events: the milestones Insights draws on its graphs. A change that
|
|
2
|
+
// can move a number gets logged with the graphs it explains (--areas) and why,
|
|
3
|
+
// or the next read of that graph cannot be explained.
|
|
4
|
+
|
|
5
|
+
import { UsageError } from "../args.mjs";
|
|
6
|
+
import { C, OK } from "../format.mjs";
|
|
7
|
+
import { callTool, listFlag } from "../tools.mjs";
|
|
8
|
+
|
|
9
|
+
const KINDS = ["release", "listing", "screenshots", "submission", "note"];
|
|
10
|
+
const USAGE = `sprid events [list] [--days N] | add --kind ${KINDS.join("|")} --label "…" [--app <slug>] [--areas social,store,…] [--why "…"] [--at ISO]`;
|
|
11
|
+
|
|
12
|
+
export async function events(ctx) {
|
|
13
|
+
const [sub] = ctx.positionals;
|
|
14
|
+
if (!sub || sub === "list") return list(ctx);
|
|
15
|
+
if (sub === "add") return add(ctx);
|
|
16
|
+
throw new UsageError(USAGE);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function workspaceArg(ctx) {
|
|
20
|
+
return ctx.flags.workspace ? { workspace: String(ctx.flags.workspace) } : {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function list(ctx) {
|
|
24
|
+
const days = ctx.flags.days ? Number(ctx.flags.days) : undefined;
|
|
25
|
+
const res = await callTool(ctx, "list_events", { ...workspaceArg(ctx), ...(days ? { days } : {}) });
|
|
26
|
+
if (ctx.json) {
|
|
27
|
+
ctx.out(res);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
const rows = Array.isArray(res) ? res : res?.events ?? [];
|
|
31
|
+
if (rows.length === 0) {
|
|
32
|
+
ctx.print(" No events in this window.");
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
for (const e of rows) {
|
|
36
|
+
const areas = e.meta?.areas?.length ? C.dim(` [${e.meta.areas.join(", ")}]`) : "";
|
|
37
|
+
ctx.print(` ${String(e.at ?? e.createdAt ?? "").slice(0, 10)} ${e.kind.padEnd(11)} ${e.label}${areas}`);
|
|
38
|
+
if (e.meta?.why) ctx.print(C.dim(` ${e.meta.why}`));
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function add(ctx) {
|
|
44
|
+
const kind = ctx.flags.kind;
|
|
45
|
+
const label = ctx.flags.label;
|
|
46
|
+
if (!KINDS.includes(kind) || typeof label !== "string") throw new UsageError(USAGE);
|
|
47
|
+
const args = {
|
|
48
|
+
...workspaceArg(ctx),
|
|
49
|
+
kind,
|
|
50
|
+
label,
|
|
51
|
+
...(ctx.flags.app ? { app: String(ctx.flags.app) } : {}),
|
|
52
|
+
...(listFlag(ctx.flags.areas) ? { areas: listFlag(ctx.flags.areas) } : {}),
|
|
53
|
+
...(typeof ctx.flags.why === "string" ? { why: ctx.flags.why } : {}),
|
|
54
|
+
...(typeof ctx.flags.at === "string" ? { at: ctx.flags.at } : {}),
|
|
55
|
+
};
|
|
56
|
+
const res = await callTool(ctx, "add_event", args);
|
|
57
|
+
if (ctx.json) ctx.out(res);
|
|
58
|
+
else ctx.print(` ${C.ok(OK)} Logged ${kind}: ${label}`);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// sprid inbox: comments on posts and ads plus store reviews, as the one ranked
|
|
2
|
+
// queue the app's Inbox shows. Replying is public, so `reply` shows the text
|
|
3
|
+
// and asks, the way `reviews reply` does. Store reviews are replied to with
|
|
4
|
+
// `sprid reviews reply`; here they can only be filed.
|
|
5
|
+
|
|
6
|
+
import { UsageError } from "../args.mjs";
|
|
7
|
+
import { C, OK } from "../format.mjs";
|
|
8
|
+
import { callTool, listFlag } from "../tools.mjs";
|
|
9
|
+
import { confirm, stars, truncate } from "./reviews.mjs";
|
|
10
|
+
|
|
11
|
+
const USAGE = "sprid inbox [--view needs_you|all|done] [--kind all|comment|review] [--accounts a,b] [--app <slug>] [--limit N] | draft <commentId> | reply <commentId> --text \"…\" [--yes] | done|flag|reopen <c:id|r:id>";
|
|
12
|
+
const SOURCE = { instagram: "Instagram", youtube: "YouTube", app_store: "App Store", google_play: "Google Play" };
|
|
13
|
+
|
|
14
|
+
export async function inbox(ctx) {
|
|
15
|
+
const [sub, ref] = ctx.positionals;
|
|
16
|
+
if (!sub) return list(ctx);
|
|
17
|
+
if (sub === "draft") return draft(ctx, ref);
|
|
18
|
+
if (sub === "reply") return reply(ctx, ref);
|
|
19
|
+
if (sub === "done" || sub === "flag" || sub === "reopen") return file(ctx, sub, ref);
|
|
20
|
+
throw new UsageError(USAGE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function list(ctx) {
|
|
24
|
+
const args = {
|
|
25
|
+
...(ctx.flags.workspace ? { workspace: String(ctx.flags.workspace) } : {}),
|
|
26
|
+
...(listFlag(ctx.flags.accounts ?? ctx.flags.account) ? { accounts: listFlag(ctx.flags.accounts ?? ctx.flags.account) } : {}),
|
|
27
|
+
...(ctx.flags.app ? { app: String(ctx.flags.app) } : {}),
|
|
28
|
+
...(typeof ctx.flags.view === "string" ? { view: ctx.flags.view } : {}),
|
|
29
|
+
...(typeof ctx.flags.kind === "string" ? { kind: ctx.flags.kind } : {}),
|
|
30
|
+
...(typeof ctx.flags.intent === "string" ? { intent: ctx.flags.intent } : {}),
|
|
31
|
+
...(ctx.flags.limit ? { limit: Number(ctx.flags.limit) } : {}),
|
|
32
|
+
};
|
|
33
|
+
const res = await callTool(ctx, "list_inbox", args);
|
|
34
|
+
if (ctx.json) {
|
|
35
|
+
ctx.out(res);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const items = res?.items ?? [];
|
|
39
|
+
if (items.length === 0) {
|
|
40
|
+
ctx.print(" Nothing here.");
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
ctx.print(items.map(render).join("\n\n"));
|
|
44
|
+
if (res.more) ctx.print(C.dim(`\n ${res.more} more. Raise --limit to see them.`));
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function render(item) {
|
|
49
|
+
const where = item.account?.slug ?? item.app?.slug ?? "";
|
|
50
|
+
const flag = item.urgent ? C.bad(" urgent") : item.overdue ? C.warn(" overdue") : "";
|
|
51
|
+
const rating = item.kind === "review" && item.rating ? ` ${stars(item.rating)}` : "";
|
|
52
|
+
const head = ` ${item.kind === "comment" ? "c" : "r"}:${item.id} ${where} ${SOURCE[item.source] ?? item.source}${item.onAd ? " ad" : ""} ${item.intent}${rating}${flag} ${C.dim(String(item.at ?? "").slice(0, 10))}`;
|
|
53
|
+
const lines = [head];
|
|
54
|
+
const text = [item.title, item.text].filter(Boolean).join(" · ");
|
|
55
|
+
lines.push(` ${item.author ? `${item.author}: ` : ""}${truncate(text, 200)}`);
|
|
56
|
+
if (item.post?.title || item.post?.caption) lines.push(C.dim(` on: ${truncate(item.post.title ?? item.post.caption, 80)}`));
|
|
57
|
+
lines.push(C.dim(item.kind === "comment" ? ` sprid inbox draft ${item.id}` : ` sprid reviews draft ${item.id}`));
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `c:12` or a bare `12` is a comment; `r:41` is a store review. */
|
|
62
|
+
function parseRef(ref, usage) {
|
|
63
|
+
const m = String(ref ?? "").match(/^(?:(c|r):)?([1-9]\d*)$/i);
|
|
64
|
+
if (!m) throw new UsageError(usage);
|
|
65
|
+
return { kind: (m[1] ?? "c").toLowerCase() === "r" ? "review" : "comment", id: Number(m[2]) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function draft(ctx, ref) {
|
|
69
|
+
const { kind, id } = parseRef(ref, "sprid inbox draft <commentId>");
|
|
70
|
+
if (kind === "review") throw new UsageError(`Store reviews are drafted with: sprid reviews draft ${id}`);
|
|
71
|
+
const res = await callTool(ctx, "draft_comment_reply", { commentId: id, ...(ctx.flags.count ? { count: Number(ctx.flags.count) } : {}) });
|
|
72
|
+
if (ctx.json) {
|
|
73
|
+
ctx.out(res);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
const suggestions = res?.suggestions ?? [];
|
|
77
|
+
if (res?.shouldEscalate) ctx.print(C.warn(" This one should be answered by a person, not a template."));
|
|
78
|
+
if (res?.note) ctx.print(C.dim(` ${res.note}`));
|
|
79
|
+
suggestions.forEach((s, i) => ctx.print(` ${i + 1}. ${typeof s === "string" ? s : s.text ?? JSON.stringify(s)}`));
|
|
80
|
+
if (suggestions.length) ctx.print(C.dim(`\n Send one: sprid inbox reply ${id} --text "…"`));
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function reply(ctx, ref) {
|
|
85
|
+
const { kind, id } = parseRef(ref, "sprid inbox reply <commentId> --text \"…\" [--yes]");
|
|
86
|
+
if (kind === "review") throw new UsageError(`Store reviews are replied to with: sprid reviews reply ${id}`);
|
|
87
|
+
const text = typeof ctx.flags.text === "string" ? ctx.flags.text.trim() : "";
|
|
88
|
+
if (!text) throw new UsageError(`Pass the reply with --text. Suggestions: sprid inbox draft ${id}`);
|
|
89
|
+
if (text.length > 500) throw new UsageError(`${text.length} characters; a comment reply allows 500.`);
|
|
90
|
+
if (!ctx.flags.yes) {
|
|
91
|
+
const msg = "Replying is public. Re-run with --yes to send without the prompt.";
|
|
92
|
+
if (ctx.json || !ctx.stdin?.isTTY) {
|
|
93
|
+
if (ctx.json) ctx.out({ ok: false, error: msg, commentId: id, text });
|
|
94
|
+
else {
|
|
95
|
+
ctx.print(` Will reply to comment ${id}:\n ${text}`);
|
|
96
|
+
ctx.warn(` ✗ ${msg}`);
|
|
97
|
+
}
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
ctx.print(` Will reply publicly to comment ${id}:\n\n ${text}\n`);
|
|
101
|
+
if (!(await confirm(ctx, " Send it? [y/N] "))) {
|
|
102
|
+
ctx.print(" Not sent.");
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const res = await callTool(ctx, "reply_comment", { commentId: id, text });
|
|
107
|
+
if (ctx.json) ctx.out(res);
|
|
108
|
+
else ctx.print(` ${C.ok(OK)} Reply sent to comment ${id}.`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function file(ctx, verb, ref) {
|
|
113
|
+
const { kind, id } = parseRef(ref, `sprid inbox ${verb} <c:id|r:id>`);
|
|
114
|
+
let res;
|
|
115
|
+
if (kind === "review") {
|
|
116
|
+
if (verb === "flag") throw new UsageError("Store reviews cannot be flagged; file them with done or put them back with reopen.");
|
|
117
|
+
res = await callTool(ctx, "dismiss_store_review", { reviewId: id, dismissed: verb === "done" });
|
|
118
|
+
} else {
|
|
119
|
+
res = await callTool(ctx, "update_comment", { commentId: id, state: verb === "reopen" ? "needs_you" : verb });
|
|
120
|
+
}
|
|
121
|
+
if (ctx.json) ctx.out(res);
|
|
122
|
+
else ctx.print(` ${C.ok(OK)} ${kind === "review" ? "Review" : "Comment"} ${id} ${verb === "done" ? "filed" : verb === "flag" ? "flagged" : "back in Needs you"}.`);
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
package/src/commands/post.mjs
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { fork } from "node:child_process";
|
|
3
3
|
import { run as runPost } from "../post/cli.mjs";
|
|
4
4
|
import { remotePost } from './studio.mjs';
|
|
5
|
+
import { publishing, PUBLISHING_VERBS } from './publishing.mjs';
|
|
5
6
|
|
|
6
7
|
export async function post(ctx) {
|
|
8
|
+
if (PUBLISHING_VERBS.includes(ctx.positionals[0])) return publishing(ctx);
|
|
7
9
|
if (await remotePost(ctx)) return 0;
|
|
8
10
|
return localPost(ctx);
|
|
9
11
|
}
|