@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +14 -0
  3. package/README-post.md +615 -0
  4. package/README.md +249 -0
  5. package/SECURITY.md +18 -0
  6. package/bin.mjs +12 -0
  7. package/package.json +47 -0
  8. package/src/args.mjs +134 -0
  9. package/src/browser.mjs +28 -0
  10. package/src/cli.mjs +257 -0
  11. package/src/commands/auth.mjs +192 -0
  12. package/src/commands/completion.mjs +90 -0
  13. package/src/commands/connect.mjs +586 -0
  14. package/src/commands/docs.mjs +53 -0
  15. package/src/commands/family.mjs +96 -0
  16. package/src/commands/init.mjs +137 -0
  17. package/src/commands/local-tools.mjs +31 -0
  18. package/src/commands/marketing-review.mjs +106 -0
  19. package/src/commands/mcp.mjs +53 -0
  20. package/src/commands/misc.mjs +94 -0
  21. package/src/commands/pinterest.mjs +120 -0
  22. package/src/commands/plan.mjs +230 -0
  23. package/src/commands/post.mjs +41 -0
  24. package/src/commands/reviews.mjs +149 -0
  25. package/src/commands/setup.mjs +220 -0
  26. package/src/commands/status.mjs +217 -0
  27. package/src/commands/studio.mjs +69 -0
  28. package/src/commands/update.mjs +69 -0
  29. package/src/creds.mjs +126 -0
  30. package/src/docs/commands.mjs +152 -0
  31. package/src/docs/guides.generated.mjs +1178 -0
  32. package/src/docs/help.mjs +77 -0
  33. package/src/docs/index.d.mts +18 -0
  34. package/src/docs/index.mjs +45 -0
  35. package/src/docs/queries.d.mts +11 -0
  36. package/src/docs/queries.mjs +77 -0
  37. package/src/endpoint.mjs +17 -0
  38. package/src/evidence.mjs +15 -0
  39. package/src/format.mjs +71 -0
  40. package/src/http.mjs +117 -0
  41. package/src/pending.mjs +27 -0
  42. package/src/post/cli.mjs +2285 -0
  43. package/src/post/json-worker.mjs +12 -0
  44. package/src/post/preview-server.mjs +58 -0
  45. package/src/post/preview.mjs +660 -0
  46. package/src/post/recipes/screen.mjs +290 -0
  47. package/src/post/recipes/stills.mjs +146 -0
  48. package/src/post/rules/platform-rules.d.mts +27 -0
  49. package/src/post/rules/platform-rules.mjs +164 -0
  50. package/src/post/screen/captions.mjs +131 -0
  51. package/src/post/screen/compose.mjs +284 -0
  52. package/src/post/screen/input.mjs +163 -0
  53. package/src/post/screen/sim.mjs +443 -0
  54. package/src/post/screen/simkit.swift +328 -0
  55. package/src/profiles.mjs +61 -0
  56. package/src/release.ts +2 -0
  57. package/src/screenshots.ts +1 -0
  58. package/src/updates.mjs +152 -0
@@ -0,0 +1,96 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { UsageError } from "../args.mjs";
4
+ import { resolveProfile } from "../profiles.mjs";
5
+
6
+ function objectFile(ctx) {
7
+ if (!ctx.flags.file)
8
+ throw new UsageError(
9
+ "Pass --file <payload.json>. The file must contain a JSON object.",
10
+ );
11
+ const data = JSON.parse(
12
+ readFileSync(resolve(ctx.cwd, String(ctx.flags.file)), "utf8"),
13
+ );
14
+ if (!data || Array.isArray(data) || typeof data !== "object")
15
+ throw new UsageError("Expected a JSON object.");
16
+ return data;
17
+ }
18
+ function positiveId(raw) {
19
+ const id = Number(raw);
20
+ if (!Number.isInteger(id) || id <= 0)
21
+ throw new UsageError("Pass a positive family or edition ID.");
22
+ return id;
23
+ }
24
+ export async function apps(ctx) {
25
+ ctx.out(await ctx.api().get("/api/app-profiles/hierarchy"));
26
+ return 0;
27
+ }
28
+ export async function family(ctx) {
29
+ const [verb = "list", raw] = ctx.positionals;
30
+ let result;
31
+ if (verb === "list" || verb === "compare") {
32
+ const workspaceId = await ctx.workspaceId();
33
+ const app = await resolveProfile(ctx, workspaceId);
34
+ result = await ctx
35
+ .api()
36
+ .get(
37
+ `/api/content-families${verb === "compare" ? "/performance" : ""}?workspaceId=${workspaceId}&app=${app.id}&ageDays=${encodeURIComponent(ctx.flags.days ?? 7)}`,
38
+ );
39
+ } else if (verb === "create")
40
+ result = await ctx.api().post("/api/content-families", objectFile(ctx));
41
+ else {
42
+ const id = positiveId(raw);
43
+ const edition = ["get", "preview", "review", "localize"].includes(verb);
44
+ const path = edition
45
+ ? `/api/content-families/editions/${id}`
46
+ : `/api/content-families/${id}`;
47
+ if (verb === "get") result = await ctx.api().get(path);
48
+ else if (verb === "preview")
49
+ result = await ctx.api().post(`${path}/preview`, {});
50
+ else if (
51
+ ["edition", "review", "localize", "plan", "schedule"].includes(verb)
52
+ )
53
+ result = await ctx
54
+ .api()
55
+ .post(
56
+ `${path}/${verb === "edition" ? "editions" : verb}`,
57
+ objectFile(ctx),
58
+ );
59
+ else
60
+ throw new UsageError(
61
+ "sprid family list|create|edition|get|localize|preview|review|plan|schedule|compare [id] [--file payload.json]",
62
+ );
63
+ }
64
+ // Always print exact IDs, preview URLs, blockers and revision fingerprints.
65
+ ctx.out(result);
66
+ return 0;
67
+ }
68
+ export async function insights(ctx) {
69
+ const workspaceId = await ctx.workspaceId();
70
+ const app = await resolveProfile(ctx, workspaceId);
71
+ const query = new URLSearchParams({
72
+ workspaceId: String(workspaceId),
73
+ app: String(app.id),
74
+ days: String(ctx.flags.days ?? 30),
75
+ });
76
+ if (ctx.flags.account) query.set("account", ctx.flags.account);
77
+ ctx.out(await ctx.api().get(`/api/insights?${query}`));
78
+ return 0;
79
+ }
80
+
81
+ export async function account(ctx) {
82
+ const [verb, raw] = ctx.positionals;
83
+ if (verb === "create")
84
+ ctx.out(await ctx.api().post("/api/accounts", objectFile(ctx)));
85
+ else if (verb === "update")
86
+ ctx.out(
87
+ await ctx
88
+ .api()
89
+ .patch(`/api/accounts/${positiveId(raw)}`, objectFile(ctx)),
90
+ );
91
+ else
92
+ throw new UsageError(
93
+ "sprid account create|update [id] --file payload.json",
94
+ );
95
+ return 0;
96
+ }
@@ -0,0 +1,137 @@
1
+ // init: .sprid/app.json (written by /sprid:bootstrap, or by the flags here)
2
+ // becomes an App Profile on the server. Ids only; keys go through `sprid
3
+ // connect`, which is the line between the free diagnosis and the paid sensor.
4
+
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
+ import { dirname, resolve } from "node:path";
7
+ import { UsageError } from "../args.mjs";
8
+ import { C, NONE, OK, table } from "../format.mjs";
9
+ import { createProfile, listProfiles, patchProfile, readLocalApp } from "../profiles.mjs";
10
+
11
+ /** The App Profile's own field names, as bootstrap writes them. */
12
+ export const PROFILE_FIELDS = [
13
+ "slug",
14
+ "name",
15
+ "appStoreId",
16
+ "playPackageName",
17
+ "gscProperty",
18
+ "posthogProjectId",
19
+ "posthogHost",
20
+ "revenuecatProjectId",
21
+ "cloudflareZoneId",
22
+ "cloudflareAccountTag",
23
+ "playExportBucket",
24
+ "repoUrl",
25
+ "websiteUrl",
26
+ "accountId",
27
+ "posthogEvents",
28
+ "posthogConfig",
29
+ "metricsCollectionEnabled",
30
+ "polarOrganizationId",
31
+ "lemonSqueezyStoreId",
32
+ "paddleEnvironment",
33
+ ];
34
+
35
+ const FLAG_TO_FIELD = {
36
+ name: "name",
37
+ slug: "slug",
38
+ "app-store-id": "appStoreId",
39
+ "play-package": "playPackageName",
40
+ "gsc-property": "gscProperty",
41
+ "posthog-project": "posthogProjectId",
42
+ "posthog-host": "posthogHost",
43
+ "revenuecat-project": "revenuecatProjectId",
44
+ "cloudflare-zone": "cloudflareZoneId",
45
+ "repo-url": "repoUrl",
46
+ "website-url": "websiteUrl",
47
+ };
48
+
49
+ export function profileFromFlags(flags) {
50
+ const out = {};
51
+ for (const [flag, field] of Object.entries(FLAG_TO_FIELD)) {
52
+ if (flags[flag] && flags[flag] !== true) out[field] = String(flags[flag]);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ export function pickProfileFields(data) {
58
+ const out = {};
59
+ for (const k of PROFILE_FIELDS) {
60
+ if (data[k] !== undefined && data[k] !== null && data[k] !== "") out[k] = data[k];
61
+ }
62
+ return out;
63
+ }
64
+
65
+ export async function init(ctx) {
66
+ const local = readLocalApp(ctx.cwd);
67
+ const fromFlags = profileFromFlags(ctx.flags);
68
+ let input;
69
+ let source;
70
+ if (Object.keys(fromFlags).length) {
71
+ if (!fromFlags.name) throw new UsageError("sprid init --name <app name> [--app-store-id N] [--play-package P] …");
72
+ input = { ...(local ? pickProfileFields(local.data) : {}), ...fromFlags };
73
+ source = "flags";
74
+ } else if (local) {
75
+ input = pickProfileFields(local.data);
76
+ source = local.path;
77
+ if (!input.name) throw new UsageError(`${local.path} has no "name".`);
78
+ } else {
79
+ const msg = [
80
+ ` No .sprid/app.json in ${ctx.cwd}. Three ways to make one:`,
81
+ ` 1. In the app's repo, in your agent: /sprid:bootstrap (reads the repo, grades the listing, writes the file)`,
82
+ ` 2. sprid init --name "My App" --app-store-id 123456789 --play-package com.example.app [--gsc-property sc-domain:example.com …]`,
83
+ ` 3. The web form: ${ctx.appUrlSafe()}/settings/app-profiles`,
84
+ ].join("\n");
85
+ if (ctx.json) {
86
+ ctx.out({ ok: false, error: "No .sprid/app.json", ways: ["/sprid:bootstrap", "sprid init --name … flags", `${ctx.appUrlSafe()}/settings/app-profiles`] });
87
+ } else {
88
+ ctx.print(msg);
89
+ }
90
+ return 2;
91
+ }
92
+
93
+ const wid = await ctx.workspaceId();
94
+ if (local?.data.workspaceId && Number(local.data.workspaceId) !== Number(wid))
95
+ throw new UsageError(`This repo belongs to workspace ${local.data.workspaceId}. Use --workspace ${local.data.workspaceId}; do not register it in a different workspace.`);
96
+ const profiles = await listProfiles(ctx, wid);
97
+ const pinnedId = local?.data.appId;
98
+ const existing = pinnedId
99
+ ? profiles.find(p => Number(p.id) === Number(pinnedId))
100
+ : profiles.find(p => input.slug && p.slug === input.slug);
101
+ if (pinnedId && !existing) throw new UsageError(`App ${pinnedId} is unavailable in workspace ${wid}. Restore access before registering again.`);
102
+ // A server rename does not change the app this repo is attached to.
103
+ if (pinnedId && !fromFlags.slug) input.slug = existing.slug;
104
+ if (pinnedId && !fromFlags.name) input.name = existing.name;
105
+ const profile = existing ? await patchProfile(ctx, wid, existing.id, input) : await createProfile(ctx, wid, input);
106
+
107
+ // Pin server identity after either bootstrap-file or flags initialization.
108
+ {
109
+ const p = resolve(ctx.cwd, ".sprid", "app.json");
110
+ mkdirSync(dirname(p), { recursive: true });
111
+ writeFileSync(p, JSON.stringify({ ...local?.data, ...pickProfileFields({ ...input, slug: profile.slug }), appId: profile.id, workspaceId: wid }, null, 2) + "\n");
112
+ source = p;
113
+ }
114
+
115
+ // What is still missing, for this app only.
116
+ let missing = [];
117
+ try {
118
+ const { connections = [] } = await ctx.api().get(`/api/connections?workspaceId=${wid}`);
119
+ missing = connections.filter((c) => c.kind === "sensor" && c.scope === profile.slug && c.health !== "ok");
120
+ } catch {
121
+ missing = [];
122
+ }
123
+
124
+ if (ctx.json) {
125
+ ctx.out({ ok: true, created: !existing, source, profile, missing });
126
+ return 0;
127
+ }
128
+ ctx.print(` ${C.ok(OK)} ${existing ? "Updated" : "Created"} App Profile ${C.bold(profile.slug)} (${profile.name}) from ${source}`);
129
+ const set = PROFILE_FIELDS.filter((k) => k !== "slug" && k !== "name" && profile[k]).map((k) => [k, profile[k]]);
130
+ if (set.length) ctx.print(table(set, { indent: " " }));
131
+ if (missing.length) {
132
+ ctx.print(" Still missing:");
133
+ ctx.print(table(missing.map((m) => [`${NONE} ${m.label.split(" · ")[0]}`, C.dim("→"), m.cli]), { indent: " " }));
134
+ }
135
+ ctx.print(" Next: sprid connect. Your dashboard: " + ctx.appUrlSafe() + "/home?workspaceId=" + wid);
136
+ return 0;
137
+ }
@@ -0,0 +1,31 @@
1
+ import { createRequire } from 'node:module';
2
+ import { spawn } from 'node:child_process';
3
+
4
+ const require = createRequire(import.meta.url);
5
+
6
+ // Packages are installed with Sprid; never download or execute a fresh npx target.
7
+ export async function localTool(ctx, packageName) {
8
+ const entry = require.resolve(`${packageName}/cli`);
9
+ const args = (ctx.commandArgv ?? []).filter(arg => arg !== '--json');
10
+ return new Promise((resolve, reject) => {
11
+ const child = spawn('bun', [entry, ...args], { cwd: ctx.cwd, env: ctx.env, stdio: ['inherit', 'pipe', 'pipe'] });
12
+ let output = '', truncated = false, failedToStart = false;
13
+ child.stdout.on('data', chunk => {
14
+ if (!ctx.json) return ctx.rawWrite(chunk.toString());
15
+ const remaining = 8 * 1024 * 1024 - output.length;
16
+ output += chunk.toString().slice(0, Math.max(0, remaining));
17
+ if (chunk.length > remaining) truncated = true;
18
+ });
19
+ child.stderr.on('data', chunk => ctx.warn(chunk.toString()));
20
+ child.on('error', error => { failedToStart = true; reject(new Error(error.code === 'ENOENT' ? 'Screenshots and releases need Bun. Install Bun, then run the same sprid command again.' : error.message)); });
21
+ child.on('close', code => {
22
+ if (failedToStart) return;
23
+ const exitCode = code ?? 1;
24
+ if (ctx.json) ctx.out({ ok: exitCode === 0, exitCode, output, outputTruncated: truncated });
25
+ resolve(exitCode);
26
+ });
27
+ });
28
+ }
29
+
30
+ export const screenshots = ctx => localTool(ctx, '@sprid/shots');
31
+ export const release = ctx => localTool(ctx, '@sprid/release');
@@ -0,0 +1,106 @@
1
+ // Read the shared server contract. No provider key or local analytics credential needed.
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { UsageError } from '../args.mjs';
5
+ import { readLocalApp, resolveProfile, patchProfile } from '../profiles.mjs';
6
+ import { saveEvidence } from '../evidence.mjs';
7
+
8
+ export async function marketingReview(ctx) {
9
+ const local = readLocalApp(ctx.cwd)?.data;
10
+ if ((local?.enabled === false || local?.review?.enabled === false) && !['context', 'collection', 'evidence'].includes(ctx.positionals[0])) throw new UsageError(`Review paused: ${local.review?.disabledReason || local.disabledReason || local.slug}`);
11
+ if (ctx.positionals.length > 1 || (ctx.positionals.length && !['query', 'capabilities', 'context', 'collection', 'evidence'].includes(ctx.positionals[0]))) throw new UsageError('Use sprid marketing-review [capabilities | query | context | collection | evidence]');
12
+ if (['context', 'collection', 'evidence'].includes(ctx.positionals[0])) return reviewSettings(ctx);
13
+ if (ctx.positionals[0] === 'capabilities' || (ctx.positionals[0] === 'query' && ctx.flags.source)) return connectedQuery(ctx);
14
+ if (ctx.positionals[0] === 'query') {
15
+ if (!ctx.flags['query-file']) throw new UsageError('sprid marketing-review query --query-file <file.sql> --app <slug>');
16
+ const query = readFileSync(resolve(ctx.cwd, ctx.flags['query-file']), 'utf8');
17
+ if (!/^(SELECT|WITH)\b/i.test(query.trim()) || query.length > 20000) throw new UsageError('Use a SELECT/WITH HogQL query of at most 20000 characters');
18
+ const wid = await ctx.workspaceId();
19
+ const profile = await resolveProfile(ctx, wid);
20
+ const result = await ctx.api().post(`/api/marketing-review/query?${new URLSearchParams({ workspaceId: String(wid), app: profile.slug })}`, { query });
21
+ const evidence = saveEvidence(ctx.cwd, 'query', { request: { workspaceId: wid, app: profile.slug, source: 'posthog', query }, response: result });
22
+ ctx.out({ ...result, evidence });
23
+ return 0;
24
+ }
25
+ const end = ctx.flags.end || new Date().toISOString().slice(0, 10);
26
+ const date = value => {
27
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0,10) !== value) throw new UsageError('Use valid YYYY-MM-DD dates');
28
+ return Date.parse(value);
29
+ };
30
+ date(end);
31
+ if (ctx.flags.since && ctx.flags.days) throw new UsageError('Use --since or --days, not both');
32
+ const days = ctx.flags.since ? (date(end) - date(ctx.flags.since)) / 86400000 : Number(ctx.flags.days ?? 30);
33
+ if (!Number.isInteger(days) || days < 1 || days > 90) throw new UsageError('Review period must be 1 to 90 days');
34
+ const wid = await ctx.workspaceId();
35
+ const profile = await resolveProfile(ctx, wid);
36
+ const query = new URLSearchParams({ workspaceId: String(wid), app: profile.slug, days: String(days), end, check: String(Boolean(ctx.flags.check)) });
37
+ if (ctx.flags.view) {
38
+ if (!['summary', 'full'].includes(ctx.flags.view)) throw new UsageError('--view must be summary or full');
39
+ query.set('view', ctx.flags.view);
40
+ }
41
+ if (ctx.flags.sources) query.set('sources', ctx.flags.sources);
42
+ if (ctx.flags.refresh) query.set('refresh', 'true');
43
+ if (ctx.flags['cached-only']) query.set('cachedOnly', 'true');
44
+ if (ctx.flags.refresh && ctx.flags['cached-only']) throw new UsageError('--refresh and --cached-only cannot be combined');
45
+ const result = await ctx.api().get(`/api/marketing-review?${query}`, { timeoutMs: 120000 });
46
+ if (result.dataset && !ctx.flags.check && ctx.flags.view !== 'summary') result.evidence = saveEvidence(ctx.cwd, 'review', result);
47
+ ctx.out(result);
48
+ return 0;
49
+ }
50
+
51
+ async function connectedQuery(ctx) {
52
+ let input;
53
+ if (ctx.positionals[0] === 'query') {
54
+ if (!ctx.flags.operation || ctx.flags['query-file']) throw new UsageError('Use --source <source> --operation <operation> [--params-file <params.json>]. Discover parameters with marketing-review capabilities.');
55
+ let params = {};
56
+ if (ctx.flags['params-file']) {
57
+ const text = readFileSync(resolve(ctx.cwd, ctx.flags['params-file']), 'utf8');
58
+ if (text.length > 64000) throw new UsageError('Query parameters exceed 64000 characters');
59
+ try { params = JSON.parse(text); } catch { throw new UsageError('Parameter file must contain valid JSON'); }
60
+ if (!params || Array.isArray(params) || typeof params !== 'object') throw new UsageError('Parameter file must contain a JSON object');
61
+ }
62
+ input = { source: ctx.flags.source, operation: ctx.flags.operation, params };
63
+ }
64
+ const wid = await ctx.workspaceId();
65
+ const profile = await resolveProfile(ctx, wid);
66
+ const query = new URLSearchParams({ workspaceId: String(wid), app: profile.slug });
67
+ if (!input) {
68
+ const result = await ctx.api().get(`/api/marketing-review/capabilities?${query}`);
69
+ if (ctx.flags.source) {
70
+ result.operations = result.operations.filter(op => op.source === ctx.flags.source);
71
+ if (!result.operations.length) throw new UsageError('Unknown source; list capabilities without --source');
72
+ }
73
+ ctx.out(result);
74
+ return 0;
75
+ }
76
+ const result = await ctx.api().post(`/api/marketing-review/query-source?${query}`, input);
77
+ if (result.measurement) result.evidence = saveEvidence(ctx.cwd, 'query', result);
78
+ ctx.out(result);
79
+ return ['error', 'unconfigured', 'pending'].includes(result.status) ? 1 : 0;
80
+ }
81
+
82
+ async function reviewSettings(ctx) {
83
+ const wid = await ctx.workspaceId();
84
+ const profile = await resolveProfile(ctx, wid);
85
+ if (ctx.positionals[0] === 'evidence') {
86
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ctx.flags.id ?? '')) throw new UsageError('Use marketing-review evidence --id <evidence.id> --app <slug>');
87
+ const result = await ctx.api().get(`/api/marketing-review/evidence/${ctx.flags.id}?${new URLSearchParams({ workspaceId: String(wid), app: profile.slug })}`);
88
+ ctx.out({ ...result, localEvidence: saveEvidence(ctx.cwd, 'snapshot', result) });
89
+ return 0;
90
+ }
91
+ if (ctx.positionals[0] === 'collection') {
92
+ if (!['true', 'false'].includes(ctx.flags.enabled)) throw new UsageError('Use marketing-review collection --enabled true|false --app <slug>');
93
+ ctx.out(await patchProfile(ctx, wid, profile.id, { metricsCollectionEnabled: ctx.flags.enabled === 'true' }));
94
+ return 0;
95
+ }
96
+ const path = `/api/marketing-review/context?${new URLSearchParams({ workspaceId: String(wid), app: profile.slug })}`;
97
+ if (!ctx.flags.file) { ctx.out(await ctx.api().get(path)); return 0; }
98
+ const text = readFileSync(resolve(ctx.cwd, ctx.flags.file), 'utf8');
99
+ if (text.length > 70000) throw new UsageError('Context file exceeds 70000 characters');
100
+ let input;
101
+ try { input = JSON.parse(text); } catch { throw new UsageError('Context file must contain JSON with baseRevision and context'); }
102
+ if (!Number.isInteger(input.baseRevision) || input.baseRevision < 0 || !input.context) throw new UsageError('Context file needs baseRevision from the latest read and a context object');
103
+ const response = await ctx.api().raw('PUT', path, input);
104
+ ctx.out(response.data);
105
+ return response.status >= 200 && response.status < 300 ? 0 : 1;
106
+ }
@@ -0,0 +1,53 @@
1
+ // sprid mcp: the stdio adapter for MCP clients that cannot do OAuth on an
2
+ // HTTP server. Newline-delimited JSON-RPC in on stdin, forwarded to
3
+ // /api/mcp with the stored login (SPRID_PAT still wins), responses out on
4
+ // stdout. Nothing else is written to stdout, ever: the client parses it.
5
+
6
+ import readline from "node:readline";
7
+
8
+ export async function mcp(ctx) {
9
+ const a = ctx.auth();
10
+ const surface = ctx.flags.surface && ctx.flags.surface !== true ? String(ctx.flags.surface) : "core";
11
+ if (!["core", "all"].includes(surface)) {
12
+ ctx.warn("sprid mcp: --surface must be core or all");
13
+ return 2;
14
+ }
15
+ const url = `${a.apiUrl}/api/mcp?surface=${surface}`;
16
+ const rl = readline.createInterface({ input: ctx.stdin });
17
+ const write = ctx.rawWrite;
18
+
19
+ await new Promise((resolve) => {
20
+ let inflight = 0;
21
+ let closed = false;
22
+ const maybeDone = () => {
23
+ if (closed && inflight === 0) resolve();
24
+ };
25
+ rl.on("line", async (line) => {
26
+ if (!line.trim()) return;
27
+ inflight++;
28
+ try {
29
+ const res = await ctx.fetch(url, {
30
+ method: "POST",
31
+ redirect: 'error',
32
+ signal: AbortSignal.timeout(120000),
33
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${a.token}` },
34
+ body: line,
35
+ });
36
+ const text = await res.text();
37
+ // A notification gets 202/204 with no body; nothing to hand back.
38
+ if (text.trim()) write(text.trim() + "\n");
39
+ else if (!res.ok) ctx.warn(`sprid mcp: ${res.status} from ${url}`);
40
+ } catch (err) {
41
+ ctx.warn(`sprid mcp: forward error: ${err && err.message}`);
42
+ } finally {
43
+ inflight--;
44
+ maybeDone();
45
+ }
46
+ });
47
+ rl.on("close", () => {
48
+ closed = true;
49
+ maybeDone();
50
+ });
51
+ });
52
+ return 0;
53
+ }
@@ -0,0 +1,94 @@
1
+ // open / invite / upgrade: deep links and the two workspace verbs.
2
+
3
+ import { UsageError } from "../args.mjs";
4
+ import { ApiError } from "../http.mjs";
5
+ import { C, OK } from "../format.mjs";
6
+
7
+ export async function open(ctx) {
8
+ const [what, arg] = ctx.positionals;
9
+ const base = ctx.appUrl();
10
+ let path;
11
+ switch (what ?? "app") {
12
+ case "app":
13
+ path = "/";
14
+ break;
15
+ case "post": {
16
+ const id = Number(arg);
17
+ if (!Number.isInteger(id) || id <= 0) throw new UsageError("sprid open post <id>");
18
+ path = `/p/${id}`;
19
+ break;
20
+ }
21
+ case "calendar":
22
+ path = "/calendar";
23
+ break;
24
+ case "draft":
25
+ path = "/posts?new=1";
26
+ break;
27
+ case "accounts":
28
+ path = `/settings/accounts/new?workspaceId=${await ctx.workspaceId()}`;
29
+ break;
30
+ case "connections":
31
+ path = `/settings/connections?workspaceId=${await ctx.workspaceId()}`;
32
+ break;
33
+ case "billing":
34
+ // The plan screen, not the workspace settings page: this is the one the
35
+ // trial, the prices and the Stripe portal live on.
36
+ path = "/billing";
37
+ break;
38
+ case "spend":
39
+ // The AI spend caps. `next` points here when one is hit, so the target
40
+ // has to exist or the command it prints is a lie.
41
+ path = "/settings/ai-spend";
42
+ break;
43
+ default:
44
+ throw new UsageError("sprid open [post <id>|draft|calendar|connections|accounts|billing|spend|app]");
45
+ }
46
+ const url = `${base}${path}`;
47
+ const opened = await ctx.openBrowser(url);
48
+ if (ctx.json) ctx.out({ url, opened });
49
+ else ctx.print(` ${opened ? "Opened" : "Open"} ${url}`);
50
+ return 0;
51
+ }
52
+
53
+ export async function invite(ctx) {
54
+ const [email] = ctx.positionals;
55
+ if (!email || !email.includes("@")) throw new UsageError("sprid invite <email> [--role editor|admin] [--account <slug>]");
56
+ const role = ctx.flags.role ? String(ctx.flags.role) : "editor";
57
+ if (!["editor", "admin"].includes(role)) throw new UsageError("--role must be editor or admin");
58
+ const wid = await ctx.workspaceId();
59
+ const body = { email, role, scope: "workspace" };
60
+ if (ctx.flags.account) {
61
+ if (role !== "editor") throw new UsageError("An account-scoped collaborator is always an editor; drop --role.");
62
+ const rows = await ctx.api().get("/api/accounts");
63
+ const list = Array.isArray(rows) ? rows : rows?.accounts ?? [];
64
+ const acct = list.find((a) => a.slug === ctx.flags.account);
65
+ if (!acct) throw new UsageError(`No account "${ctx.flags.account}" visible to this token.`);
66
+ body.scope = "account";
67
+ body.accountIds = [acct.id];
68
+ }
69
+ const res = await ctx.api().post(`/api/workspaces/${wid}/invites`, body);
70
+ if (ctx.json) ctx.out({ ok: true, invite: res });
71
+ else ctx.print(` ${C.ok(OK)} Invited ${email} as ${role}${body.scope === "account" ? ` on ${ctx.flags.account}` : ""}. They get a mail with the link.`);
72
+ return 0;
73
+ }
74
+
75
+ const TIER_ARG = { pulse: "starter", push: "growth", founding: "founding", starter: "starter", growth: "growth" };
76
+
77
+ export async function upgrade(ctx) {
78
+ const [want] = ctx.positionals;
79
+ const tier = want ? TIER_ARG[want.toLowerCase()] : "starter";
80
+ if (!tier) throw new UsageError("sprid upgrade [pulse|push|founding]");
81
+ const wid = await ctx.workspaceId();
82
+ let res;
83
+ try {
84
+ res = await ctx.api().post("/api/billing/checkout", { workspaceId: wid, tier });
85
+ } catch (e) {
86
+ if (e instanceof ApiError && e.status === 403) throw new ApiError(403, "Only the workspace owner can change the plan.");
87
+ throw e;
88
+ }
89
+ if (!res?.url) throw new ApiError(500, "Checkout returned no URL.");
90
+ const opened = await ctx.openBrowser(res.url);
91
+ if (ctx.json) ctx.out({ tier, url: res.url, opened });
92
+ else ctx.print(` ${opened ? "Opened" : "Open"} checkout for ${want ?? "pulse"}: ${res.url}`);
93
+ return 0;
94
+ }
@@ -0,0 +1,120 @@
1
+ import { UsageError } from "../args.mjs";
2
+ import { C } from "../format.mjs";
3
+
4
+ function required(ctx, name) {
5
+ const value = ctx.flags[name];
6
+ if (!value || value === true) throw new UsageError(`--${name} needs a value`);
7
+ return String(value);
8
+ }
9
+
10
+ async function channel(ctx) {
11
+ const account = required(ctx, "account");
12
+ const rows = await ctx.api().get(`/api/channels?account=${encodeURIComponent(account)}`);
13
+ const active = (rows ?? []).filter((row) => row.platform === "pinterest" && row.status === "active");
14
+ const wanted = ctx.flags.channel ? active.filter((row) => String(row.id) === String(ctx.flags.channel)) : active;
15
+ if (wanted.length !== 1) {
16
+ if (!active.length) throw new UsageError(`No active Pinterest channel on ${account}. Run sprid connect pinterest --account ${account}.`);
17
+ throw new UsageError(`Several Pinterest channels are active on ${account}. Pass --channel <id>: ${active.map((row) => row.id).join(", ")}`);
18
+ }
19
+ return { account, channel: wanted[0] };
20
+ }
21
+
22
+ export async function pinterest(ctx) {
23
+ const [verb, ref] = ctx.positionals;
24
+ if (verb === "boards") {
25
+ const selected = await channel(ctx);
26
+ const bookmark = ctx.flags.bookmark ? `?bookmark=${encodeURIComponent(String(ctx.flags.bookmark))}` : "";
27
+ const result = await ctx.api().get(`/api/channels/${selected.channel.id}/pinterest/boards${bookmark}`);
28
+ if (ctx.json) ctx.out({ ...result, account: selected.account, channelId: selected.channel.id });
29
+ else {
30
+ ctx.print(` Pinterest boards for ${selected.channel.displayName ?? selected.account}`);
31
+ for (const board of result.items ?? []) {
32
+ ctx.print(` ${C.bold(board.id)} ${board.name}${board.privacy ? C.dim(` ${board.privacy}`) : ""}`);
33
+ for (const section of board.sections ?? []) ctx.print(C.dim(` ${section.id} ${section.name}`));
34
+ }
35
+ if (!(result.items ?? []).length) ctx.print(` No Pinterest boards yet. Create one with: sprid pinterest create --account ${selected.account} --name <name>`);
36
+ if (result.bookmark) ctx.print(C.dim(` More boards: sprid pinterest boards --account ${selected.account} --channel ${selected.channel.id} --bookmark ${result.bookmark}`));
37
+ }
38
+ return 0;
39
+ }
40
+
41
+ if (verb === "create") {
42
+ const selected = await channel(ctx);
43
+ const name = required(ctx, "name").trim();
44
+ const description = ctx.flags.description ? String(ctx.flags.description).trim() : "";
45
+ const privacy = String(ctx.flags.privacy ?? "public").toUpperCase();
46
+ if (!name) throw new UsageError("--name needs a value");
47
+ if (!["PUBLIC", "SECRET"].includes(privacy)) throw new UsageError("--privacy must be public or secret");
48
+ const board = await ctx.api().post(`/api/channels/${selected.channel.id}/pinterest/boards`, {
49
+ name,
50
+ ...(description ? { description } : {}),
51
+ privacy,
52
+ });
53
+ if (ctx.json) ctx.out({ ...board, account: selected.account, channelId: selected.channel.id });
54
+ else ctx.print(` ${C.ok("✓")} Created Pinterest board ${C.bold(board.name)} (${board.id}).`);
55
+ return 0;
56
+ }
57
+
58
+ if (verb === "set") {
59
+ if (!ref || !/^\d+$/.test(ref)) throw new UsageError("sprid pinterest set <postId> --board <id> --link <url> [metadata]");
60
+ const post = await ctx.api().get(`/api/posts/${ref}`);
61
+ const previous = post.pinterestOptions ?? {};
62
+ const hasFlag = (name) => Object.prototype.hasOwnProperty.call(ctx.flags, name);
63
+ const boardChanged = hasFlag("board") && String(ctx.flags.board) !== String(previous.boardId ?? "");
64
+ const boardId = ctx.flags.board ? String(ctx.flags.board) : previous.boardId;
65
+ const link = ctx.flags.link ? String(ctx.flags.link) : previous.link;
66
+ if (!boardId) throw new UsageError("Pinterest metadata needs --board <id>.");
67
+ if (!/^\d+$/.test(boardId)) throw new UsageError("--board must be the numeric Pinterest board id returned by `sprid pinterest boards`.");
68
+ if (hasFlag("section") && ctx.flags.section && !/^\d+$/.test(String(ctx.flags.section))) throw new UsageError("--section must be a numeric Pinterest section id.");
69
+ if (!link) throw new UsageError("Pinterest metadata needs --link <absolute-url>.");
70
+ let url;
71
+ try { url = new URL(link); } catch { throw new UsageError("--link must be an absolute http or https URL."); }
72
+ if (!["http:", "https:"].includes(url.protocol)) throw new UsageError("--link must be an absolute http or https URL.");
73
+ const disclosures = hasFlag("ai-disclosures")
74
+ ? String(ctx.flags["ai-disclosures"] === true ? "" : ctx.flags["ai-disclosures"]).split(",").filter(Boolean)
75
+ : previous.aiDisclosures;
76
+ const allowed = new Set(["AI_MODIFIED", "SYNTHETIC_PERFORMER"]);
77
+ if (disclosures?.some((value) => !allowed.has(value))) throw new UsageError("--ai-disclosures accepts AI_MODIFIED,SYNTHETIC_PERFORMER.");
78
+ const value = {
79
+ boardId,
80
+ ...(
81
+ hasFlag("section")
82
+ ? (ctx.flags.section ? { boardSectionId: String(ctx.flags.section) } : {})
83
+ : (!boardChanged && previous.boardSectionId ? { boardSectionId: String(previous.boardSectionId) } : {})
84
+ ),
85
+ ...(ctx.flags.title || previous.title ? { title: String(ctx.flags.title ?? previous.title) } : {}),
86
+ ...(ctx.flags.description || previous.description ? { description: String(ctx.flags.description ?? previous.description) } : {}),
87
+ link: url.toString(),
88
+ ...(ctx.flags["alt-text"] || previous.altText ? { altText: String(ctx.flags["alt-text"] ?? previous.altText) } : {}),
89
+ ...(disclosures?.length ? { aiDisclosures: disclosures } : {}),
90
+ };
91
+ if ((value.title?.length ?? 0) > 100) throw new UsageError("Pinterest title exceeds 100 characters.");
92
+ if ((value.description?.length ?? 0) > 800) throw new UsageError("Pinterest description exceeds 800 characters.");
93
+ if ((value.altText?.length ?? 0) > 500) throw new UsageError("Pinterest alt text exceeds 500 characters.");
94
+ const updated = await ctx.api().patch(`/api/posts/${ref}`, { pinterestOptions: value });
95
+ if (ctx.json) ctx.out({ ok: true, postId: Number(ref), pinterestOptions: updated.pinterestOptions });
96
+ else ctx.print(` ${C.ok("✓")} Pinterest details saved on post ${ref}. Review the visual and schedule in Sprid.`);
97
+ return 0;
98
+ }
99
+
100
+ if (verb === "metrics") {
101
+ if (!ref || !/^\d+$/.test(ref)) throw new UsageError("sprid pinterest metrics <publishId> --account <slug> [--days 30] [--refresh]");
102
+ const account = required(ctx, "account");
103
+ const params = new URLSearchParams({ account, publishId: ref, days: String(ctx.flags.days ?? 30) });
104
+ if (ctx.flags.refresh) params.set("refresh", "true");
105
+ const result = await ctx.api().get(`/api/metrics/pinterest?${params}`);
106
+ if (ctx.json) ctx.out(result);
107
+ else if (!result.available) ctx.print(` Pinterest metrics unavailable: ${result.reason ?? "no data returned"}`);
108
+ else {
109
+ ctx.print(` Pinterest ${result.from} → ${result.to}`);
110
+ ctx.print(` impressions ${result.impressions ?? "–"} clicks ${result.pinClicks ?? "–"} outbound ${result.outboundClicks ?? "–"} saves ${result.saves ?? "–"}`);
111
+ if (typeof result.observedDays === "number" && typeof result.expectedDays === "number") {
112
+ ctx.print(` coverage ${result.observedDays}/${result.expectedDays} days · ${result.source ?? "live"}`);
113
+ }
114
+ for (const note of result.notes ?? []) ctx.print(` ${C.dim(note)}`);
115
+ }
116
+ return 0;
117
+ }
118
+
119
+ throw new UsageError("sprid pinterest boards --account <slug> [--channel <id>] [--bookmark <token>] | create --account <slug> --name <name> [--description <text>] [--privacy public|secret] | set <postId> --board <id> --link <url> | metrics <publishId> --account <slug>");
120
+ }