@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,77 @@
1
+ import { COMMAND_GROUPS, CLI_NOTES, COMMAND_EXAMPLES } from './commands.mjs';
2
+ import { UsageError } from '../args.mjs';
3
+ import { wrap } from '../format.mjs';
4
+
5
+ export const HELP_TOPICS = {
6
+ environment: [
7
+ 'SPRID_PAT: personal access token; overrides ~/.sprid/credentials.json.',
8
+ 'SPRID_URL: API address; overrides the saved address. HTTPS except on loopback.',
9
+ 'SPRID_APP_URL: browser app address; otherwise derived from the API address.',
10
+ 'Workspace: --workspace/-w > .sprid/app.json workspaceId > saved choice > sole workspace > local workspace slug.',
11
+ 'Local media can pin tokenEnv in sprid.config; that token is required when configured.',
12
+ 'Never paste credentials into a command example or an agent conversation. Connect with --key <file>.',
13
+ ],
14
+ 'exit-codes': [
15
+ '0: success.',
16
+ '1: runtime, authentication or API failure, or a declined review reply.',
17
+ '2: invalid usage or a required explicit choice (including public replies without --yes in scripts).',
18
+ 'Local media, screenshot and release tools may propagate their own nonzero exit codes.',
19
+ '--json: one result on stdout; diagnostics on stderr. Failures carry ok: false and error. Local tools may return exitCode and output instead.',
20
+ 'A failed mutation may have reached the server. Read its saved state before retrying; retain upload request IDs.',
21
+ 'sprid mcp is a streaming protocol transport, not a one-document JSON command.',
22
+ ],
23
+ };
24
+
25
+ export const commandNames = () => [...new Set(COMMAND_GROUPS.flatMap(g => g.entries.map(e => e.command)))];
26
+
27
+ export function commandEntries(command) {
28
+ const entries = COMMAND_GROUPS.flatMap(g => g.entries).filter(e => e.command === command);
29
+ // The local media namespace aliases the existing local post commands.
30
+ if (command === 'media') {
31
+ const local = COMMAND_GROUPS.find(g => g.title === 'Build your own media');
32
+ return [...entries.filter(e => !e.usage.includes('init|build')), ...local.entries.map(e => ({ ...e, command, usage: e.usage.replace('sprid post ', 'sprid media ') }))];
33
+ }
34
+ return entries;
35
+ }
36
+
37
+ export function subcommands(entry) {
38
+ const token = entry.usage.split(/\s+/)[2];
39
+ return token && /^[a-z][a-z0-9-]*(\|[a-z][a-z0-9-]*)*$/.test(token) ? token.split('|') : [];
40
+ }
41
+
42
+ // Suggestions never execute a corrected command, particularly a mutation.
43
+ export function suggest(input, candidates) {
44
+ const distance = (a, b) => {
45
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i);
46
+ for (let i = 1; i <= a.length; i++) {
47
+ const next = [i];
48
+ for (let j = 1; j <= b.length; j++) next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
49
+ row = next;
50
+ }
51
+ return row[b.length];
52
+ };
53
+ return candidates.map(name => ({ name, distance: distance(input, name) }))
54
+ .filter(x => x.distance <= Math.min(2, Math.floor(input.length / 2)))
55
+ .sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name))[0]?.name;
56
+ }
57
+
58
+ export function getHelp(path = []) {
59
+ const [command, subcommand, ...extra] = path;
60
+ if (!command) return { commands: commandNames(), groups: COMMAND_GROUPS, examples: COMMAND_EXAMPLES, notes: CLI_NOTES, url: 'https://sprid.studio/docs/cli' };
61
+ if (Object.hasOwn(HELP_TOPICS, command) && !subcommand) return { topic: command, notes: HELP_TOPICS[command] };
62
+ let entries = commandEntries(command);
63
+ if (!entries.length) {
64
+ const match = suggest(command, commandNames());
65
+ throw new UsageError(`Unknown command "${command}".${match ? ` Did you mean "${match}"?` : ''} Run sprid help.`);
66
+ }
67
+ if (subcommand) entries = entries.filter(e => subcommands(e).includes(subcommand) || /^\[?</.test(e.usage.split(/\s+/)[2] ?? ''));
68
+ if (!entries.length || extra.length) throw new UsageError(`Unknown help topic "${path.join(' ')}". Run sprid help ${command}.`);
69
+ const examples = (COMMAND_EXAMPLES[command] ?? []).filter(example => !subcommand || example.startsWith(`sprid ${command} ${subcommand} `));
70
+ return { command, ...(subcommand ? { subcommand } : {}), entries, examples, notes: ['--help, -h: show help without running the command.', '--json: emit a machine-readable result.', '--workspace, -w <slug|id>: select a workspace for connected commands.'], url: 'https://sprid.studio/docs/cli' };
71
+ }
72
+
73
+ export function renderHelp(help, version) {
74
+ if (help.topic) return `sprid help ${help.topic}\n\n${help.notes.join('\n\n')}`;
75
+ if (help.entries) return `${help.entries.map(e => `${e.usage}\n ${e.summary}`).join('\n\n')}${help.examples.length ? '\n\nExamples:\n ' + help.examples.join('\n ') : ''}\n\n${help.notes.join('\n')}\n\n${help.url}`;
76
+ return `sprid ${version} · the Sprid command line\n\nUsage: sprid <command> [options]\n\nStart here:\n sprid login Sign in through your browser\n sprid status See what needs you\n sprid docs Browse guides and references\n\nCommands:\n ${wrap(help.commands.join(' '), 76).join('\n ')}\n\nLearn more:\n sprid help <command> [<subcommand>]\n sprid help environment\n sprid help exit-codes\n sprid docs cli Full installed command reference\n sprid help --json Machine-readable catalog\n\n${help.url}`;
77
+ }
@@ -0,0 +1,18 @@
1
+ import type { QueryOperation } from './queries.mjs';
2
+ export interface QueryReference { topic: 'queries'; title: string; operations: QueryOperation[]; markdown: string; url: string; }
3
+ export interface MetricReference { topic: 'metrics'; title: string; markdown: string; url: string; }
4
+ export interface GuideSection { id: string; title: string; kind: 'requirements' | 'identifiers' | 'steps' | 'command' | 'verify' | 'troubleshooting' | 'sources' | 'detail'; markdown: string; }
5
+ export interface Guide { topic: 'connect'; id: string; title: string; summary: string; command: string; url: string; sections: GuideSection[]; markdown: string; revision: string; }
6
+ export interface ContentGuide { topic: 'content'; id: string; title: string; summary: string; url: string; markdown: string; revision: string; }
7
+ export interface CommandGroup { title: string; entries: { command: string; usage: string; summary: string }[]; }
8
+ export interface CommandReference { topic: 'cli'; title: string; groups: CommandGroup[]; notes: string[]; markdown: string; url: string; }
9
+ export interface GuideIndex { topic: 'index'; title: string; guides: (Pick<Guide, 'id' | 'title' | 'summary' | 'url'> & { revision?: string })[]; }
10
+ export function searchDocumentation(query: string): { id: string; title: string; url: string; read: string; excerpt: string; score: number }[];
11
+ export const GUIDE_GROUPS: { title: string; slugs: string[] }[];
12
+ export const COMMAND_GROUPS: CommandGroup[];
13
+ export const CLI_NOTES: string[];
14
+ export function getDocumentation(topic: 'cli'): CommandReference;
15
+ export function getDocumentation(topic: undefined): GuideIndex;
16
+ export function getDocumentation(topic: 'metrics'): MetricReference;
17
+ export function getDocumentation(topic: string): Guide | ContentGuide | QueryReference | CommandReference | MetricReference;
18
+ export function getDocumentation(topic?: string): Guide | ContentGuide | QueryReference | CommandReference | MetricReference | GuideIndex;
@@ -0,0 +1,45 @@
1
+ import { QUERY_OPERATIONS, renderQueryReference } from './queries.mjs';
2
+ import { GUIDES, CONTENT_GUIDES, GUIDE_ALIASES, METRIC_EVIDENCE } from './guides.generated.mjs';
3
+ import { COMMAND_GROUPS, CLI_NOTES, renderCommandReference } from './commands.mjs';
4
+ export { GUIDE_GROUPS } from './guides.generated.mjs';
5
+ export { COMMAND_GROUPS, CLI_NOTES } from './commands.mjs';
6
+
7
+ /** Pure content lookup: shared by the website, REST, MCP and CLI. */
8
+ export function getDocumentation(topic) {
9
+ if (topic === 'metrics') return { topic: 'metrics', title: 'Metric evidence', markdown: METRIC_EVIDENCE, url: 'https://sprid.studio/docs/metrics' };
10
+ if (!topic) return { topic: 'index', title: 'Sprid guides', guides: [
11
+ { id: 'cli', title: 'CLI reference', summary: 'Installed command syntax, options and shell completion.', url: 'https://sprid.studio/docs/cli' },
12
+ { id: 'queries', title: 'Query connected data', summary: 'Read-operation schemas and examples.', url: 'https://sprid.studio/docs/queries' },
13
+ { id: 'metrics', title: 'Metric evidence', summary: 'Metric definitions, units and coverage.', url: 'https://sprid.studio/docs/metrics' },
14
+ ...[...GUIDES, ...CONTENT_GUIDES].map(({ id, title, summary, url, revision }) => ({ id, title, summary, url, revision })),
15
+ ] };
16
+ if (topic === 'queries') return { topic: 'queries', title: 'Query connected data', operations: QUERY_OPERATIONS, markdown: renderQueryReference(), url: 'https://sprid.studio/docs/queries' };
17
+ if (topic === 'cli') return { topic: 'cli', title: 'CLI reference', groups: COMMAND_GROUPS, notes: CLI_NOTES, markdown: renderCommandReference(), url: 'https://sprid.studio/docs/cli' };
18
+ const id = Object.hasOwn(GUIDE_ALIASES, topic) ? GUIDE_ALIASES[topic] : topic;
19
+ const guide = GUIDES.find(guide => guide.id === id);
20
+ if (guide) return { topic: 'connect', ...guide };
21
+ const contentGuide = CONTENT_GUIDES.find(guide => guide.id === id);
22
+ if (contentGuide) return { topic: 'content', ...contentGuide };
23
+ throw new RangeError(`Unknown guide "${topic}". Run sprid docs to list guides.`);
24
+ }
25
+
26
+ /** Search the shipped content, never credentials, repositories or a remote index. */
27
+ export function searchDocumentation(query) {
28
+ const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
29
+ if (!terms.length) return [];
30
+ const documents = getDocumentation().guides.map(guide => ({ ...guide, text: getDocumentation(guide.id).markdown, read: `sprid docs ${guide.id}` }));
31
+ documents.push(...COMMAND_GROUPS.flatMap(group => group.entries.map(entry => ({
32
+ id: `cli:${entry.usage}`, title: entry.usage, summary: entry.summary, text: entry.summary,
33
+ url: 'https://sprid.studio/docs/cli', read: `sprid help ${entry.command}`,
34
+ }))));
35
+ return documents.flatMap(document => {
36
+ const title = `${document.id} ${document.title}`.toLowerCase();
37
+ const text = `${document.summary} ${document.text}`.replace(/\s+/g, ' ');
38
+ const haystack = `${title} ${text}`.toLowerCase();
39
+ if (!terms.every(term => haystack.includes(term))) return [];
40
+ const score = terms.reduce((n, term) => n + (title.includes(term) ? 10 : 1), 0);
41
+ const at = Math.max(0, text.toLowerCase().indexOf(terms[0]) - 60);
42
+ return [{ id: document.id, title: document.title, url: document.url, read: document.read,
43
+ excerpt: `${at ? '…' : ''}${text.slice(at, at + 240)}${text.length > at + 240 ? '…' : ''}`, score }];
44
+ }).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
45
+ }
@@ -0,0 +1,11 @@
1
+ export interface QueryField {
2
+ type: string; description?: string; maxLength?: number; minimum?: number; maximum?: number;
3
+ enum?: string[]; pattern?: string; format?: string; items?: QueryField; maxItems?: number;
4
+ properties?: Record<string, QueryField>; required?: string[]; additionalProperties?: boolean | QueryField; maxProperties?: number;
5
+ }
6
+ export interface QueryOperation { source: string; operation: string; description: string; inputSchema: QueryField; example: Record<string, unknown>; identifiers?: string[]; }
7
+ export interface QuerySource { source: string; title: string; identifiers: string[]; secrets: string[]; coverage: string; docs: string; stored?: boolean; }
8
+ export const QUERY_SOURCES: Record<string, QuerySource>;
9
+ export const QUERY_OPERATIONS: QueryOperation[];
10
+
11
+ export function renderQueryReference(): string;
@@ -0,0 +1,77 @@
1
+ // One operation catalogue for MCP discovery, CLI help, validation and the web.
2
+ const str = (description, extra = {}) => ({ type: 'string', maxLength: 500, description, ...extra });
3
+ const id = description => str(description, { pattern: '^[A-Za-z0-9_-]+$' });
4
+ const integer = (description, maximum = 100, minimum = 1) => ({ type: 'integer', minimum, maximum, description });
5
+ const choice = (description, values) => str(description, { enum: values });
6
+ const array = (items, maxItems = 20) => ({ type: 'array', items, maxItems });
7
+ const object = (properties, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
8
+ const date = description => str(description, { format: 'date', pattern: '^\\d{4}-\\d{2}-\\d{2}$' });
9
+ const dates = { start: date('First calendar date, inclusive.'), end: date('Last boundary, exclusive. Provider date conventions are retained in the result.') };
10
+ const limit = integer('Rows per page; continue with the returned next parameters.');
11
+ const page = integer('One-based page.', 10000);
12
+ const cursor = str('Opaque cursor from the previous result. Never a URL.', { maxLength: 2000 });
13
+ const scalarMap = { type: 'object', additionalProperties: str('Provider-specific value.'), maxProperties: 20 };
14
+ const chart = id('Chart name from RevenueCat documentation, such as revenue, mrr, trials or churn.');
15
+ const filter = object({ name: id('Filter name from chart_options.'), values: array(str('Allowed filter value.')) }, ['name', 'values']);
16
+ const sources = {
17
+ posthog: ['PostHog', ['posthogProjectId'], ['posthogApiKey'], 'Project-pinned analytics. SQL is HogQL; inspect instrumentation before interpreting events.', 'https://posthog.com/docs/api'],
18
+ gsc: ['Search Console', ['gscProperty'], ['gscServiceAccountJson'], 'Pinned Search Console property. Dates use Pacific time; anonymized queries and top-row limits affect coverage.', 'https://developers.google.com/webmaster-tools/v1/searchanalytics/query'],
19
+ cloudflare: ['Cloudflare', ['cloudflareAccountTag', 'websiteUrl'], ['cloudflareApiToken'], 'RUM is pinned to the saved account and website hostname. Beacon traffic is sampled and is not verified human traffic.', 'https://developers.cloudflare.com/analytics/graphql-api/'],
20
+ asc: ['App Store Connect', ['appStoreId'], ['ascIssuerId', 'ascKeyId', 'ascPrivateKey'], 'Pinned app. Reviews and existing analytics report definitions are read-only. No report request is created.', 'https://developer.apple.com/documentation/appstoreconnectapi/analytics'],
21
+ play: ['Google Play', ['playPackageName'], ['playServiceAccountJson'], 'Pinned package. Live reviews have provider history limits; exported acquisition rows require the saved Play export bucket.', 'https://developers.google.com/android-publisher/api-ref/rest/v3/reviews/list'],
22
+ revenuecat: ['RevenueCat', ['revenuecatProjectId'], ['revenuecatApiKey'], 'Pinned project. Discover chart options before selecting dimensions, filters or resolution; preserve returned measure units.', 'https://www.revenuecat.com/docs/api-v2/charts-and-metrics'],
23
+ stripe: ['Stripe', [], ['stripeApiKey'], 'Credential-scoped merchant account. Filter by price/customer as appropriate when several products share the account. Amounts retain currency and minor units; SQL/Stripe Analytics is not exposed.', 'https://docs.stripe.com/api'],
24
+ polar: ['Polar', ['polarOrganizationId'], ['polarApiKey'], 'Pinned organization. Product filters distinguish apps sold through the same organization.', 'https://polar.sh/docs/api-reference/metrics/get'],
25
+ lemonsqueezy: ['Lemon Squeezy', ['lemonSqueezyStoreId'], ['lemonSqueezyApiKey'], 'Pinned store. Product/variant filters distinguish apps in the same store. Preserve provider amounts and currencies.', 'https://docs.lemonsqueezy.com/api'],
26
+ paddle: ['Paddle', [], ['paddleApiKey'], 'Credential-scoped merchant account, with sandbox/live from the profile. Dates filter billed or created time as described; monetary values are minor-unit strings.', 'https://developer.paddle.com/api-reference/overview'],
27
+ };
28
+ export const QUERY_SOURCES = Object.fromEntries(Object.entries(sources).map(([source, [title, identifiers, secrets, coverage, docs]]) => [source, { source, title, identifiers, secrets, coverage, docs }]));
29
+ for (const source of ['instagram', 'tiktok', 'youtube', 'facebook', 'linkedin', 'x']) QUERY_SOURCES[source] = {
30
+ source, title: ({ instagram: 'Instagram', tiktok: 'TikTok', youtube: 'YouTube', facebook: 'Facebook', linkedin: 'LinkedIn', x: 'X' })[source],
31
+ identifiers: [], secrets: [], stored: true,
32
+ coverage: 'Queries Sprid’s stored publishes, metric snapshots and inbox for the linked content account. No live platform sync or paid API read. Missing metrics are unmeasured; this does not expose the platform’s entire API.',
33
+ docs: 'https://sprid.studio/docs/queries',
34
+ };
35
+ export const QUERY_OPERATIONS = [];
36
+ const op = (source, operation, description, properties, required, example, extra = {}) => QUERY_OPERATIONS.push({ source, operation, description, inputSchema: object(properties, required), example, ...extra });
37
+ op('posthog', 'query', 'Run a custom read-only HogQL query for cohorts, ordered funnels, activation or retention.', { query: str('SELECT/WITH HogQL. Use explicit dates, identity and exclusions.', { maxLength: 20000 }) }, ['query'], { query: "SELECT event, count() FROM events WHERE timestamp >= '2026-08-01' AND timestamp < '2026-09-01' GROUP BY event LIMIT 100" });
38
+ for (const operation of ['events', 'properties']) op('posthog', operation, `Discover ${operation === 'events' ? 'event' : 'property'} definitions before constructing a query.`, { ...(operation === 'events' ? { names: array(str('Exact event name.')) } : { search: str('Property name search.') }), limit, offset: integer('Zero-based offset.', 100000, 0) }, [], { limit: 50 });
39
+ op('gsc', 'search', 'Query search performance by dimensions and filters, including page/market/keyword comparisons.', {
40
+ ...dates, dimensions: array(choice('Grouping dimension.', ['date', 'query', 'page', 'country', 'device', 'searchAppearance'])),
41
+ filters: array(object({ dimension: choice('Filtered dimension.', ['query', 'page', 'country', 'device', 'searchAppearance']), operator: choice('Match operator.', ['equals', 'notEquals', 'contains', 'notContains', 'includingRegex', 'excludingRegex']), expression: str('Filter expression.', { maxLength: 2000 }) }, ['dimension', 'operator', 'expression'])),
42
+ type: choice('Search type.', ['web', 'image', 'video', 'news', 'discover', 'googleNews']), dataState: choice('Final or preliminary data.', ['final', 'all']), limit: integer('Rows per page.', 25000), startRow: integer('Zero-based pagination offset.', 1000000, 0),
43
+ }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', dimensions: ['page', 'country'], limit: 1000 });
44
+ op('cloudflare', 'rum', 'Group website beacon traffic by date, page, referrer or country.', { ...dates, dimensions: array(choice('RUM dimension.', ['date', 'requestPath', 'refererHost', 'countryName']), 4), path: str('Exact request path.'), country: str('Exact countryName.'), referrer: str('Exact refererHost.'), limit: integer('Maximum groups.', 1000) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', dimensions: ['requestPath', 'countryName'], limit: 100 });
45
+ op('cloudflare', 'http', 'Read daily zone requests, page views and country totals. Includes bots; no silent RUM fallback.', dates, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01' }, { identifiers: ['cloudflareZoneId'] });
46
+ op('asc', 'reviews', 'Read app reviews by rating, territory and response status.', { rating: integer('Exact star rating.', 5), territory: id('Three-letter App Store territory.'), existsPublishedResponse: { type: 'boolean', description: 'Whether a published developer response exists.' }, sort: choice('Ordering.', ['createdDate', '-createdDate', 'rating', '-rating']), limit: integer('Reviews per page.', 200), cursor }, [], { rating: 2, limit: 100 });
47
+ op('asc', 'versions', 'Inspect App Store version and release-state records for this app.', { limit: integer('Versions per page.', 200), cursor }, [], { limit: 50 });
48
+ op('asc', 'reports', 'List existing analytics requests and their report names. Does not enable report generation.', {}, [], {});
49
+ op('asc', 'report_rows', 'Read an existing named daily report with its original columns, date window and exact-value filters.', { ...dates, report: str('Exact report name returned by reports.'), filters: scalarMap, limit: integer('Maximum matching rows.', 1000), offset: integer('Matching-row offset.', 100000, 0) }, ['start', 'end', 'report'], { start: '2026-08-01', end: '2026-09-01', report: 'App Downloads Standard', limit: 500 });
50
+ op('play', 'reviews', 'Read the app’s live review page; use stored reviews for older collected history.', { translationLanguage: str('Optional translation language, such as en.'), limit, cursor }, [], { limit: 100 });
51
+ op('play', 'report_rows', 'Read package-scoped monthly acquisition exports with original column names and exact-value filters.', { ...dates, dimension: choice('Install export breakdown.', ['overview', 'country', 'device', 'app_version', 'android_version', 'language', 'carrier']), filters: scalarMap, limit: integer('Maximum matching rows.', 1000), offset: integer('Matching-row offset.', 100000, 0) }, ['start', 'end', 'dimension'], { start: '2026-08-01', end: '2026-09-01', dimension: 'country', limit: 500 }, { identifiers: ['playPackageName', 'playExportBucket'] });
52
+ op('revenuecat', 'chart_options', 'Discover available resolutions, segments, filters and selectors for a chart.', { chart }, ['chart'], { chart: 'revenue' });
53
+ op('revenuecat', 'revenue', 'Read the authoritative RevenueCat project revenue total for an explicit period.', { ...dates, currency: str('Requested reporting currency.', { pattern: '^[A-Z]{3}$' }), revenue_type: choice('Revenue basis.', ['revenue', 'revenue_net_of_taxes', 'proceeds']) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', currency: 'USD', revenue_type: 'revenue' });
54
+ op('revenuecat', 'chart', 'Query a chart with provider-supported segmentation and filters; returns original measures.', { chart, ...dates, resolution: str('Resolution ID from chart_options, not a guessed day/week label.'), segment: id('Segment from chart_options.'), filters: array(filter), selectors: scalarMap, currency: str('Currency code.', { pattern: '^[A-Z]{3}$' }), limit_num_segments: integer('Top segments; provider may combine the remainder as Other.', 100) }, ['chart', 'start', 'end', 'resolution'], { chart: 'revenue', start: '2026-08-01', end: '2026-09-01', resolution: '0' });
55
+ op('revenuecat', 'subscriptions', 'Inspect a customer’s subscriptions within the bound RevenueCat project.', { customer: str('RevenueCat customer ID; preserve the app’s verified identity mapping.', { maxLength: 150 }), environment: choice('Defaults to production. Keep sandbox separate from paid customer evidence.', ['production', 'sandbox']), limit, cursor }, ['customer'], { customer: 'example-customer', limit: 100 });
56
+ const stripeFields = { ...dates, customer: id('Stripe customer ID.'), limit, cursor };
57
+ op('stripe', 'subscriptions', 'Read subscriptions, status and lifecycle dates, optionally filtered by price/customer. Dates filter creation, not cancellation.', { ...stripeFields, status: choice('Subscription status.', ['all', 'active', 'trialing', 'past_due', 'unpaid', 'canceled', 'incomplete', 'incomplete_expired', 'paused']), price: id('Stripe price ID for the app or offer.') }, [], { status: 'trialing', limit: 100 });
58
+ op('stripe', 'charges', 'Read charge outcomes, refunds and amounts by creation window or customer.', stripeFields, [], { start: '2026-08-01', end: '2026-09-01', limit: 100 });
59
+ op('polar', 'metrics', 'Query metric history filtered by product, customer or billing type.', { ...dates, interval: choice('Bucket interval.', ['hour', 'day', 'week', 'month', 'year']), product_id: id('Polar product ID.'), customer_id: id('Polar customer ID.'), billing_type: choice('Billing type.', ['one_time', 'recurring']), metrics: array(id('Metric slug from the response metadata.')) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', interval: 'day', metrics: ['revenue', 'active_subscriptions'] });
60
+ for (const operation of ['orders', 'subscriptions']) op('polar', operation, `Read ${operation} for the bound organization with product/customer filters.`, { product_id: id('Polar product ID.'), customer_id: id('Polar customer ID.'), limit, page }, [], { limit: 100, page: 1 });
61
+ for (const operation of ['orders', 'subscriptions']) op('lemonsqueezy', operation, `Read ${operation} from the saved store with supported product filters.`, { ...(operation === 'subscriptions' ? { product_id: id('Product ID.'), variant_id: id('Variant ID.'), status: str('Subscription status.') } : { order_number: integer('Order number.', 1000000000) }), limit, page }, [], { limit: 100, page: 1 });
62
+ op('lemonsqueezy', 'subscription-invoices', 'Read initial, renewal and update invoices from the saved store. Initial invoices duplicate initial orders; preserve billing_reason when calculating revenue.', { subscription_id: id('Subscription ID.'), status: str('Invoice status.'), refunded: { type: 'boolean', description: 'Filter refund state.' }, limit, page }, [], { limit: 100, page: 1 });
63
+ op('paddle', 'transactions', 'Read billed transactions by time, status, customer or subscription.', { ...dates, status: array(choice('Status.', ['draft', 'ready', 'billed', 'paid', 'completed', 'canceled', 'past_due']), 7), customer_id: id('Paddle customer ID.'), subscription_id: id('Paddle subscription ID.'), limit, cursor }, [], { start: '2026-08-01', end: '2026-09-01', status: ['completed'], limit: 100 });
64
+ op('paddle', 'subscriptions', 'Read subscription lifecycles by status or price; optional creation dates filter each returned page locally.', { ...dates, status: array(choice('Status.', ['active', 'canceled', 'past_due', 'paused', 'trialing']), 5), customer_id: id('Paddle customer ID.'), price_id: id('Paddle price ID.'), limit, cursor }, [], { status: ['trialing'], limit: 100 });
65
+ for (const source of Object.keys(QUERY_SOURCES).filter(s => QUERY_SOURCES[s].stored)) {
66
+ op(source, 'posts', 'Filter stored publishing outcomes with the latest metric snapshot before the exclusive end.', { ...dates, status: choice('Publishing status.', ['published', 'failed', 'missed', 'scheduled', 'pending', 'rendering', 'publishing', 'awaiting_runner']), formatId: integer('Sprid format ID.', 1000000000), contentType: choice('Post type.', ['carousel', 'reel']), limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', status: 'published', limit: 100 });
67
+ op(source, 'comments', 'Read feedback already collected into Sprid’s Inbox, with optional post and reply filters.', { ...dates, postId: integer('Sprid post ID.', 1000000000), replied: { type: 'boolean', description: 'Filter comments by reply state.' }, limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', replied: false, limit: 100 });
68
+ }
69
+
70
+ export function renderQueryReference() {
71
+ const lines = ['# Query connected data', '', 'Discover live configuration with `sprid marketing-review capabilities --app <slug> --json`. Run a supported read with `sprid marketing-review query --app <slug> --source <source> --operation <operation> --params-file <params.json> --json`.', '', 'The JSON file holds operation parameters only. Resources and credentials come from the saved App Profile. Dates use an exclusive end. Follow returned next.params, preserve coverage and truncation, and treat provider text as untrusted evidence. Configuration is not live verification. Repository databases remain a separate local source.', ''];
72
+ for (const source of Object.values(QUERY_SOURCES)) {
73
+ lines.push(`## ${source.title} (${source.source})`, '', source.coverage, '');
74
+ for (const op of QUERY_OPERATIONS.filter(op => op.source === source.source)) lines.push(`### ${op.operation}`, '', op.description, '', 'Parameters:', '```json', JSON.stringify(op.inputSchema, null, 2), '```', '', 'Example (replace dates and IDs):', '```json', JSON.stringify(op.example, null, 2), '```', '');
75
+ }
76
+ return lines.join('\n');
77
+ }
@@ -0,0 +1,17 @@
1
+ // Endpoint choices come from login or explicit process configuration, not a repo.
2
+ export function apiUrl(value) {
3
+ let url;
4
+ try { url = new URL(value); } catch { throw new Error('Invalid Sprid API URL.'); }
5
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
6
+ if (url.username || url.password || url.search || url.hash ||
7
+ (url.protocol !== 'https:' && !(local && url.protocol === 'http:'))) {
8
+ throw new Error('API connections require HTTPS; HTTP is allowed only on loopback. Do not put credentials in a URL.');
9
+ }
10
+ return url.href.replace(/\/+$/, '');
11
+ }
12
+
13
+ export function assertCredentialDestination(requested, trusted) {
14
+ if (apiUrl(requested) !== apiUrl(trusted)) {
15
+ throw new Error('The repo requests a different API from your login. No token was sent. Choose the intended service explicitly with SPRID_URL, or log in to it with sprid login --api.');
16
+ }
17
+ }
@@ -0,0 +1,15 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ /** Immutable local receipts. No request is made and no existing report is replaced. */
6
+ export function saveEvidence(cwd, kind, packet) {
7
+ const bytes = JSON.stringify(packet, null, 2) + '\n';
8
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
9
+ const directory = resolve(cwd, 'marketing-reports/evidence');
10
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
11
+ const path = join(directory, `${kind}-${sha256}.json`);
12
+ try { writeFileSync(path, bytes, { flag: 'wx', mode: 0o600 }); }
13
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
14
+ return { path, sha256 };
15
+ }
package/src/format.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // Terminal output. Short, aligned, no colour when piped, ✓ ✗ – as the only
2
+ // symbols.
3
+
4
+ export const isTTY = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
5
+
6
+ export const C = {
7
+ dim: (s) => (isTTY() ? `\x1b[2m${s}\x1b[0m` : s),
8
+ bold: (s) => (isTTY() ? `\x1b[1m${s}\x1b[0m` : s),
9
+ ok: (s) => (isTTY() ? `\x1b[32m${s}\x1b[0m` : s),
10
+ bad: (s) => (isTTY() ? `\x1b[31m${s}\x1b[0m` : s),
11
+ warn: (s) => (isTTY() ? `\x1b[33m${s}\x1b[0m` : s),
12
+ };
13
+
14
+ export const OK = "✓";
15
+ export const BAD = "✗";
16
+ export const NONE = "–";
17
+ export const EXPIRING = "~";
18
+
19
+ export function pad(s, n) {
20
+ s = String(s ?? "");
21
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
22
+ }
23
+
24
+ /** Rows of cells → aligned lines. */
25
+ export function table(rows, { indent = " ", gap = 2 } = {}) {
26
+ const widths = [];
27
+ for (const r of rows) r.forEach((c, i) => (widths[i] = Math.max(widths[i] ?? 0, String(c ?? "").length)));
28
+ return rows
29
+ .map((r) => indent + r.map((c, i) => (i === r.length - 1 ? String(c ?? "") : pad(c, widths[i] + gap))).join("").trimEnd())
30
+ .join("\n");
31
+ }
32
+
33
+ export function healthMark(health) {
34
+ if (health === "ok") return OK;
35
+ if (health === "expiring") return EXPIRING;
36
+ if (health === "revoked" || health === "error") return BAD;
37
+ return NONE;
38
+ }
39
+
40
+ export function hhmm(iso) {
41
+ if (!iso) return "";
42
+ const d = new Date(iso);
43
+ if (Number.isNaN(d.getTime())) return "";
44
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
45
+ }
46
+
47
+ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
48
+ export function dayTime(iso) {
49
+ if (!iso) return "";
50
+ const d = new Date(iso);
51
+ if (Number.isNaN(d.getTime())) return "";
52
+ return `${DAYS[d.getDay()]} ${hhmm(iso)}`;
53
+ }
54
+
55
+ /** Word-wrap at ~width, never breaking a token, so a URL in an error stays clickable. */
56
+ export function wrap(text, width = 100) {
57
+ const out = [];
58
+ let line = "";
59
+ for (const word of String(text ?? "").split(/\s+/).filter(Boolean)) {
60
+ if (line && line.length + 1 + word.length > width) {
61
+ out.push(line);
62
+ line = word;
63
+ } else line = line ? `${line} ${word}` : word;
64
+ }
65
+ if (line) out.push(line);
66
+ return out;
67
+ }
68
+
69
+ export function capOf(n) {
70
+ return n == null ? "∞" : String(n);
71
+ }
package/src/http.mjs ADDED
@@ -0,0 +1,117 @@
1
+ // The one HTTP door. Every command talks to the same REST contract the web
2
+ // app and the MCP server use; this wraps fetch with the bearer header, JSON
3
+ // bodies, and an error that carries the server's own message.
4
+
5
+ import { apiUrl as validateApiUrl } from './endpoint.mjs';
6
+ import { compareVersions, stableVersion } from './updates.mjs';
7
+
8
+ export class ApiError extends Error {
9
+ constructor(status, message, body) {
10
+ super(message);
11
+ this.name = "ApiError";
12
+ this.status = status;
13
+ this.body = body;
14
+ this.exitCode = 1;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * The server advertises the CLI floor it enforces on every response:
20
+ * `Sprid-Client-Supported` is advisory, `Sprid-Client-Minimum` is refused 426.
21
+ * Below the advisory floor we say so once and carry on - the work still runs.
22
+ */
23
+ export function clientVersionNotice(headers, current) {
24
+ const supported = headers?.get?.("Sprid-Client-Supported");
25
+ if (!stableVersion(current) || !stableVersion(supported ?? "")) return null;
26
+ if (compareVersions(supported, current) <= 0) return null;
27
+ return `This Sprid CLI is ${current}; the server supports ${supported} and newer. Run \`sprid update\`.`;
28
+ }
29
+
30
+ /**
31
+ * The edge limiter's refusal (`error: "rate_limited"`) is rejected before the
32
+ * handler runs, so retrying it - a POST included - can never apply anything
33
+ * twice. Every other 429 (the publish cap, the AI spend
34
+ * cap) is a business refusal that waiting will not clear, and is not retried.
35
+ */
36
+ export const RATE_LIMIT_RETRIES = 2;
37
+ const MAX_RETRY_WAIT_MS = 60_000;
38
+
39
+ export function rateLimitWaitMs(status, headers, data) {
40
+ if (status !== 429 || !data || typeof data !== "object" || data.error !== "rate_limited") return null;
41
+ // Refused for failed auth: the token is bad, and waiting will not fix it.
42
+ if (data.scope === "failed") return null;
43
+ const seconds = Number(headers?.get?.("Retry-After") ?? data.retryAfter);
44
+ const ms = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : MAX_RETRY_WAIT_MS;
45
+ return Math.min(ms, MAX_RETRY_WAIT_MS);
46
+ }
47
+
48
+ export function makeClient({
49
+ apiUrl,
50
+ token,
51
+ fetchImpl = globalThis.fetch,
52
+ userAgent = "sprid",
53
+ version = null,
54
+ onNotice = null,
55
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
56
+ }) {
57
+ apiUrl = validateApiUrl(apiUrl);
58
+ async function request(method, path, body, options = {}, attempt = 0) {
59
+ const { raw = false, timeoutMs = 30000 } = options;
60
+ const headers = { Accept: "application/json", "User-Agent": userAgent };
61
+ if (token) headers.Authorization = `Bearer ${token}`;
62
+ if (body !== undefined) headers["Content-Type"] = "application/json";
63
+ let res;
64
+ try {
65
+ res = await fetchImpl(`${apiUrl}${path}`, {
66
+ method,
67
+ redirect: 'error',
68
+ signal: AbortSignal.timeout(timeoutMs),
69
+ headers,
70
+ body: body === undefined ? undefined : JSON.stringify(body),
71
+ });
72
+ } catch (e) {
73
+ throw new ApiError(0, `Could not reach ${apiUrl}: ${e.message}`);
74
+ }
75
+ if (onNotice) {
76
+ const notice = clientVersionNotice(res.headers, version);
77
+ if (notice) onNotice(notice);
78
+ }
79
+ const text = await res.text();
80
+ let data = null;
81
+ if (text) {
82
+ try {
83
+ data = JSON.parse(text);
84
+ } catch {
85
+ data = text;
86
+ }
87
+ }
88
+ const waitMs = rateLimitWaitMs(res.status, res.headers, data);
89
+ if (waitMs !== null && attempt < RATE_LIMIT_RETRIES) {
90
+ if (onNotice) onNotice(`Sprid is rate limiting these requests. Waiting ${Math.ceil(waitMs / 1000)}s and retrying.`);
91
+ await sleep(waitMs);
92
+ return request(method, path, body, options, attempt + 1);
93
+ }
94
+ if (raw) return { status: res.status, data };
95
+ if (!res.ok) {
96
+ const msg =
97
+ (data && typeof data === "object" && (data.message || data.error)) ||
98
+ (typeof data === "string" && data.slice(0, 200)) ||
99
+ `${res.status} ${res.statusText}`;
100
+ throw new ApiError(res.status, describe(res.status, String(msg)), data);
101
+ }
102
+ return data;
103
+ }
104
+ return {
105
+ get: (path, options) => request("GET", path, undefined, options),
106
+ post: (path, body) => request("POST", path, body ?? {}),
107
+ patch: (path, body) => request("PATCH", path, body ?? {}),
108
+ del: (path) => request("DELETE", path),
109
+ raw: (method, path, body) => request(method, path, body, { raw: true }),
110
+ };
111
+ }
112
+
113
+ function describe(status, msg) {
114
+ if (status === 401) return `${msg}. The token was refused; run \`sprid login\` again.`;
115
+ if (status === 403) return `${msg}. This token cannot do that here.`;
116
+ return msg;
117
+ }
@@ -0,0 +1,27 @@
1
+ // Browser grants are private CLI state, never repo files or agent output.
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { credentialsPath } from "./creds.mjs";
6
+
7
+ export function pendingGrant(ctx, identity) {
8
+ const key = createHash("sha256").update(identity).digest("hex");
9
+ const path = join(dirname(credentialsPath(ctx.env)), `pending-${key}.json`);
10
+ return {
11
+ read() {
12
+ try {
13
+ const data = JSON.parse(readFileSync(path, "utf8"));
14
+ if (data.expiresAt > Date.now()) return data;
15
+ rmSync(path, { force: true });
16
+ } catch (error) { if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; }
17
+ return null;
18
+ },
19
+ write(data) {
20
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
21
+ const tmp = `${path}.${process.pid}.tmp`;
22
+ writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
23
+ renameSync(tmp, path);
24
+ },
25
+ clear() { rmSync(path, { force: true }); },
26
+ };
27
+ }