@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,230 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
+ import { UsageError } from '../args.mjs';
4
+ import { resolveProfile } from '../profiles.mjs';
5
+ import { createHash } from 'node:crypto';
6
+
7
+ export function linkLocalTask(cwd, plan, actionId, apiUrl, workspaceId) {
8
+ const dir = resolve(realpathSync(cwd), '.sprid');
9
+ if (realpathSync(dir) !== dir) throw new UsageError('.sprid must remain inside this repository.');
10
+ const lock = resolve(dir, 'setup.lock');
11
+ try { writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 }); }
12
+ catch (error) { if (error.code === 'EEXIST') throw new UsageError('Setup is already locked. Finish or resume sprid setup before linking this task.'); throw error; }
13
+ try { return linkLocalTaskUnlocked(cwd, plan, actionId, apiUrl, workspaceId); }
14
+ finally { unlinkSync(lock); }
15
+ }
16
+
17
+ function linkLocalTaskUnlocked(cwd, plan, actionId, apiUrl, workspaceId) {
18
+ const state = readPlanFile(cwd, '.sprid/setup.json');
19
+ if (state.version !== 1 || !state.key || !state.artifact) throw new UsageError('Keep the existing version 1 setup task before linking it.');
20
+ const action = plan.actions?.find(item => item.id === actionId);
21
+ if (!action) throw new UsageError('That action does not belong to the selected app.');
22
+ const app = plan.appProfile;
23
+ if (!app?.id || !workspaceId) throw new UsageError('The plan did not return a scoped app identity.');
24
+ if ((state.appId && state.appId !== app.id) || (state.workspaceId && state.workspaceId !== workspaceId) || (state.apiUrl && state.apiUrl !== apiUrl)) throw new UsageError('The local task belongs to a different app, workspace or server.');
25
+ if (state.marketingAction && state.marketingAction.id !== actionId) throw new UsageError('This task already continues a different action. Preserve that link.');
26
+ const root = realpathSync(cwd), path = realpathSync(resolve(root, state.artifact)), rel = relative(root, path);
27
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../')) throw new UsageError('The prepared artifact must remain inside this repository.');
28
+ const dir = resolve(root, '.sprid');
29
+ if (realpathSync(dir) !== dir) throw new UsageError('.sprid must remain inside this repository.');
30
+ const artifactHash = createHash('sha256').update(readFileSync(path)).digest('hex');
31
+ state.marketingAction = { id: actionId, artifactVersion: action.artifactVersion, localTaskId: state.key, artifactHash };
32
+ state.appId = app.id; state.workspaceId = workspaceId; state.apiUrl = apiUrl;
33
+ const target = resolve(dir, 'setup.json'), tmp = `${target}.${process.pid}.tmp`;
34
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
35
+ renameSync(tmp, target);
36
+ return { status: 'linked', actionId, localTaskId: state.key, artifact: state.artifact, assurance: 'Local reference only. No artifact was uploaded, replaced or approved.' };
37
+ }
38
+
39
+ // Only explicitly selected files leave the repository. No automatic folder upload.
40
+ export function readPlanFile(cwd, file) {
41
+ if (!file) throw new UsageError('Use --file <JSON proposal>. Read the current revision before changing accepted work.');
42
+ const root = realpathSync(cwd), path = realpathSync(resolve(root, file));
43
+ const rel = relative(root, path);
44
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../')) throw new UsageError('Select a file inside the authorized repository.');
45
+ const text = readFileSync(path, 'utf8');
46
+ if (Buffer.byteLength(text) > 4 * 1024 * 1024) throw new UsageError('The selected bundle exceeds 4 MiB. Split it before importing.');
47
+ try { return JSON.parse(text); } catch { throw new UsageError('The selected file must contain valid JSON.'); }
48
+ }
49
+
50
+ function saveProjection(cwd, value) {
51
+ const root = realpathSync(cwd), dir = resolve(root, '.sprid');
52
+ mkdirSync(dir, { recursive: true });
53
+ if (realpathSync(dir) !== dir) throw new UsageError('.sprid must be inside this repository, not a symlink.');
54
+ ignorePrivateExports(dir);
55
+ const path = resolve(dir, 'marketing.json');
56
+ // A downloaded projection never overwrites an unsynchronized local proposal.
57
+ if (existsSync(path)) {
58
+ const current = JSON.parse(readFileSync(path, 'utf8'));
59
+ if (current.pendingChanges) throw new UsageError('Local marketing changes are pending. Export to --file instead; reconcile both versions before replacing them.');
60
+ }
61
+ const tmp = `${path}.${process.pid}.tmp`;
62
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
63
+ renameSync(tmp, path);
64
+ return '.sprid/marketing.json';
65
+ }
66
+
67
+ function ignorePrivateExports(dir) {
68
+ const file = resolve(dir, '.gitignore');
69
+ if (existsSync(file) && realpathSync(file) !== file) throw new UsageError('Do not write private-export rules through a symlink.');
70
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
71
+ const missing = ['research/', 'documents.json', 'marketing.json'].filter(line => !current.split('\n').includes(line));
72
+ if (missing.length) appendFileSync(file, `${current && !current.endsWith('\n') ? '\n' : ''}${missing.join('\n')}\n`, { mode: 0o600 });
73
+ }
74
+
75
+ function exportPath(cwd, value) {
76
+ const root = realpathSync(cwd), path = resolve(root, value), rel = relative(root, path);
77
+ if (!rel || isAbsolute(rel) || rel === '..' || rel.startsWith('../')) throw new UsageError('Export inside the authorized repository.');
78
+ let parent = dirname(path);
79
+ while (!existsSync(parent)) parent = dirname(parent);
80
+ const resolved = relative(root, realpathSync(parent));
81
+ if (isAbsolute(resolved) || resolved === '..' || resolved.startsWith('../')) throw new UsageError('Export parent escapes the authorized repository.');
82
+ return path;
83
+ }
84
+
85
+ async function scope(ctx) {
86
+ const workspaceId = await ctx.workspaceId();
87
+ const profile = await resolveProfile(ctx, workspaceId);
88
+ return new URLSearchParams({ workspaceId: String(workspaceId), app: profile.slug }).toString();
89
+ }
90
+ function id(ctx) {
91
+ const value = ctx.flags.id ?? ctx.positionals[1];
92
+ if (!value || !/^[a-zA-Z0-9_-]{1,100}$/.test(value)) throw new UsageError('Supply the saved identifier with --id.');
93
+ return encodeURIComponent(value);
94
+ }
95
+ async function mutate(ctx, path, input) {
96
+ const response = await ctx.api().raw('POST', path, input);
97
+ ctx.out(response.data);
98
+ return response.status >= 200 && response.status < 300 ? 0 : 1;
99
+ }
100
+
101
+ export async function plan(ctx) {
102
+ const [verb = 'get'] = ctx.positionals;
103
+ if (ctx.flags.offline) {
104
+ if (verb === 'import') {
105
+ const bundle = readPlanFile(ctx.cwd, ctx.flags.file);
106
+ if (bundle.schemaVersion !== 2 || !Number.isInteger(bundle.baseRevision) || bundle.baseRevision < 0 || !bundle.app?.slug) throw new UsageError('Local plan requires schemaVersion 2, baseRevision and app.slug.');
107
+ const path = resolve(ctx.cwd, '.sprid/marketing.json');
108
+ if (existsSync(path)) throw new UsageError('A local plan already exists. Preserve it and reconcile the proposed file explicitly.');
109
+ ctx.out({ savedTo: saveProjection(ctx.cwd, bundle), status: 'local', message: 'Local work saved. No account or import to Sprid was required.' });
110
+ return 0;
111
+ }
112
+ if (verb !== 'get') throw new UsageError('Offline: use sprid plan get or import --file <bundle>. Local proposals remain available without login.');
113
+ ctx.out(readPlanFile(ctx.cwd, '.sprid/marketing.json'));
114
+ return 0;
115
+ }
116
+ const paths = { discover: '/discover', propose: '/propose', prepare: '/actions/prepare', import: '/import', guidance: '/guidance', research: '/research', 'review-prepare': '/reviews', 'execute-prepare': '/execution/prepare' };
117
+ const scoped = await scope(ctx), base = '/api/marketing-plan';
118
+ if (verb === 'screenshot-capabilities') {
119
+ ctx.out(await ctx.api().get(`/api/marketing-screenshots/capabilities?${scoped}`));
120
+ return 0;
121
+ }
122
+ if (verb === 'screenshot-uploads' || verb === 'screenshot-compose') {
123
+ return mutate(ctx, `/api/marketing-screenshots/${verb === 'screenshot-uploads' ? 'uploads' : 'compose'}?${scoped}`, readPlanFile(ctx.cwd, ctx.flags.file));
124
+ }
125
+ if (verb === 'capabilities') {
126
+ ctx.out(await ctx.api().get(`${base}/capabilities?${scoped}`));
127
+ return 0;
128
+ }
129
+ if (verb === 'job-cancel') return mutate(ctx, `${base}/research/${id(ctx)}/cancel?${scoped}`, {});
130
+ if (verb === 'prepare-local') {
131
+ const state = readPlanFile(ctx.cwd, '.sprid/setup.json');
132
+ if (state.marketingAction) throw new UsageError('This local task already has a shared action. Continue its saved link.');
133
+ const input = readPlanFile(ctx.cwd, ctx.flags.file);
134
+ const root = realpathSync(ctx.cwd), file = realpathSync(resolve(root, state.artifact ?? ''));
135
+ const rel = relative(root, file);
136
+ if (!state.key || isAbsolute(rel) || rel === '..' || rel.startsWith('../')) throw new UsageError('Select the original local task and artifact inside this repository.');
137
+ const bytes = readFileSync(file);
138
+ if (bytes.length > 262144 || bytes.includes(0)) throw new UsageError('Import text artifacts up to 256 KiB here. Keep media in its existing upload workflow.');
139
+ const original = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
140
+ if (input.artifact?.body !== original) throw new UsageError('The proposed artifact must contain the original saved text exactly. Revise the shared artifact after importing.');
141
+ const current = await ctx.api().get(`${base}?${scoped}`);
142
+ const wid = Number(new URLSearchParams(scoped).get('workspaceId'));
143
+ if ((state.appId && state.appId !== current.appProfile.id) || (state.workspaceId && state.workspaceId !== wid) || (state.apiUrl && state.apiUrl !== ctx.auth().apiUrl)) throw new UsageError('The local task belongs to another app, workspace or server.');
144
+ const response = await ctx.api().raw('POST', `${base}/actions/prepare?${scoped}`, { ...input, idempotencyKey: `local-task:${state.key}` });
145
+ if (response.status < 200 || response.status >= 300) { ctx.out(response.data); return 1; }
146
+ const action = response.data.action;
147
+ ctx.out({ ...response.data, local: linkLocalTask(ctx.cwd, { ...current, actions: [action] }, action.id, ctx.auth().apiUrl, wid) });
148
+ return 0;
149
+ }
150
+ if (verb === 'link-local') {
151
+ const result = await ctx.api().get(`${base}?${scoped}`);
152
+ ctx.out(linkLocalTask(ctx.cwd, result, decodeURIComponent(id(ctx)), ctx.auth().apiUrl, Number(new URLSearchParams(scoped).get('workspaceId'))));
153
+ return 0;
154
+ }
155
+ if (verb === 'get' || verb === 'export') {
156
+ const query = new URLSearchParams(scoped);
157
+ if (verb === 'get') for (const [flag, key] of [['history-before', 'historyBefore'], ['history-before-id', 'historyBeforeId'], ['history-limit', 'historyLimit']]) {
158
+ if (ctx.flags[flag]) query.set(key, ctx.flags[flag]);
159
+ }
160
+ const result = await ctx.api().get(`${base}${verb === 'export' ? '/export' : ''}?${query}`);
161
+ if (verb === 'export') {
162
+ if (ctx.flags.file) {
163
+ const path = exportPath(ctx.cwd, ctx.flags.file);
164
+ if (existsSync(path)) throw new UsageError('Export target already exists. Choose a new file to preserve both versions.');
165
+ writeFileSync(path, JSON.stringify(result, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
166
+ ctx.out({ ...result, savedTo: ctx.flags.file });
167
+ } else ctx.out({ ...result, savedTo: saveProjection(ctx.cwd, result) });
168
+ } else ctx.out(result);
169
+ return 0;
170
+ }
171
+ if (verb === 'review' || verb === 'job') {
172
+ ctx.out(await ctx.api().get(`${base}/${verb === 'review' ? 'reviews' : 'research'}/${id(ctx)}?${scoped}`));
173
+ return 0;
174
+ }
175
+ const actionPaths = { accept: `/decisions/${idFor(verb, ctx)}/accept`, continue: `/actions/${idFor(verb, ctx)}/continue`, verify: `/actions/${idFor(verb, ctx)}/verify`, defer: `/actions/${idFor(verb, ctx)}/defer`, 'review-update': `/reviews/${idFor(verb, ctx)}` };
176
+ const suffix = paths[verb] ?? actionPaths[verb];
177
+ if (!suffix) throw new UsageError('Use sprid plan get|capabilities|discover|propose|accept|prepare|prepare-local|link-local|continue|verify|defer|guidance|export|import|research|job|job-cancel|review|review-prepare|review-update|execute-prepare.');
178
+ return mutate(ctx, `${base}${suffix}?${scoped}`, readPlanFile(ctx.cwd, ctx.flags.file));
179
+ }
180
+ function idFor(verb, ctx) { return ['accept', 'continue', 'verify', 'defer', 'review-update'].includes(verb) ? id(ctx) : ''; }
181
+
182
+ export async function research(ctx) {
183
+ const [verb = 'list'] = ctx.positionals, scoped = await scope(ctx);
184
+ const base = '/api/marketing-plan/documents';
185
+ if (verb === 'export') {
186
+ const query = new URLSearchParams(scoped);
187
+ query.set('includeArchived', 'true'); query.set('limit', '200');
188
+ const summaries = [];
189
+ let page;
190
+ for (let count = 0; count < 100; count++) {
191
+ page = await ctx.api().get(`${base}?${query}`);
192
+ summaries.push(...(page.documents ?? []));
193
+ if (!page.pagination?.truncated) break;
194
+ if (!page.pagination.nextBefore || !page.pagination.nextBeforeId) throw new UsageError('Server returned an incomplete export cursor. Nothing was written.');
195
+ query.set('before', page.pagination.nextBefore); query.set('beforeId', page.pagination.nextBeforeId);
196
+ }
197
+ if (page?.pagination?.truncated) throw new UsageError('Export exceeds the bounded snapshot size. Export selected documents instead; nothing was written.');
198
+ const dir = exportPath(ctx.cwd, ctx.flags.directory ?? '.sprid/research');
199
+ if (existsSync(dir)) throw new UsageError('Research export directory already exists. Choose a new --directory to preserve local edits.');
200
+ const manifestPath = ctx.flags.directory ? resolve(dir, 'documents.json') : exportPath(ctx.cwd, '.sprid/documents.json');
201
+ if (existsSync(manifestPath)) throw new UsageError('Document manifest already exists. Export a separate snapshot with --directory and reconcile both versions.');
202
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
203
+ if (!ctx.flags.directory) ignorePrivateExports(dirname(dir));
204
+ const manifest = { schemaVersion: 2, scope: Object.fromEntries(new URLSearchParams(scoped)), exportedAt: new Date().toISOString(), documents: [] };
205
+ for (const summary of summaries) {
206
+ const document = await ctx.api().get(`${base}/${encodeURIComponent(summary.id)}?${scoped}&revision=${summary.revision}`);
207
+ if (!/^[a-f0-9-]{36}$/i.test(document.id)) throw new UsageError('Server returned an invalid document identity.');
208
+ const filename = `${document.id}.md`;
209
+ const frontmatter = JSON.stringify({ id: document.id, revision: document.revision, scope: document.scope, sources: document.sources, origin: document.origin });
210
+ writeFileSync(resolve(dir, filename), `---\n${frontmatter}\n---\n\n${document.bodyMarkdown}`, { flag: 'wx', mode: 0o600 });
211
+ manifest.documents.push({ id: document.id, revision: document.revision, lastSyncedRevision: document.revision, contentHash: document.contentHash, source: document.origin, path: relative(dirname(manifestPath), resolve(dir, filename)), sections: document.sections });
212
+ }
213
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
214
+ ctx.out({ directory: dir, ...manifest });
215
+ return 0;
216
+ }
217
+ if (verb === 'list' || verb === 'get') {
218
+ const query = new URLSearchParams(scoped);
219
+ if (ctx.flags.revision) query.set('revision', ctx.flags.revision);
220
+ if (ctx.flags.search) query.set('search', ctx.flags.search);
221
+ for (const [flag, key] of [['before', 'before'], ['before-id', 'beforeId'], ['limit', 'limit']]) {
222
+ if (ctx.flags[flag]) query.set(key, ctx.flags[flag]);
223
+ }
224
+ ctx.out(await ctx.api().get(`${base}${verb === 'get' ? `/${id(ctx)}` : ''}?${query}`));
225
+ return 0;
226
+ }
227
+ if (verb === 'save' || verb === 'import') return mutate(ctx, `${base}?${scoped}`, readPlanFile(ctx.cwd, ctx.flags.file));
228
+ if (verb === 'archive') return mutate(ctx, `${base}/${id(ctx)}/archive?${scoped}`, readPlanFile(ctx.cwd, ctx.flags.file));
229
+ throw new UsageError('Use sprid research list|get|save|import|archive|export.');
230
+ }
@@ -0,0 +1,41 @@
1
+ // Preserve local command arguments, including flag values and the -- separator.
2
+ import { fork } from "node:child_process";
3
+ import { run as runPost } from "../post/cli.mjs";
4
+ import { remotePost } from './studio.mjs';
5
+
6
+ export async function post(ctx) {
7
+ if (await remotePost(ctx)) return 0;
8
+ return localPost(ctx);
9
+ }
10
+ export async function localPost(ctx) {
11
+ let literal = false;
12
+ const argv = (ctx.commandArgv ?? []).filter(arg => {
13
+ if (arg === "--") literal = true;
14
+ return literal || arg !== "--json";
15
+ });
16
+ if (!ctx.json) {
17
+ const result = await runPost(argv, { cwd: ctx.cwd });
18
+ return result?.exitCode ?? 0;
19
+ }
20
+ // Isolate legacy renderer logs and inherited builder stdout from the JSON
21
+ // document. The worker sends data over IPC; all human output goes to stderr.
22
+ return new Promise((resolve, reject) => {
23
+ const child = fork(new URL("../post/json-worker.mjs", import.meta.url), argv, {
24
+ cwd: ctx.cwd, env: ctx.env, stdio: ["inherit", "pipe", "pipe", "ipc"],
25
+ });
26
+ child.stdout.on("data", chunk => ctx.warn(chunk.toString()));
27
+ child.stderr.on("data", chunk => ctx.warn(chunk.toString()));
28
+ let delivered = false;
29
+ child.on("message", message => {
30
+ if (delivered) return;
31
+ delivered = true;
32
+ ctx.out(message);
33
+ if (argv.includes("--serve") && message.ok) resolve(message.exitCode ?? 0);
34
+ });
35
+ child.on("error", reject);
36
+ child.on("exit", code => {
37
+ if (!delivered) ctx.out({ ok: false, error: "Local post process ended without a result.", exitCode: code || 1 });
38
+ resolve(code ?? 1);
39
+ });
40
+ });
41
+ }
@@ -0,0 +1,149 @@
1
+ // reviews: what users wrote in the stores, the reply the workspace's model
2
+ // drafted, and the one call that sends it. Sending is public and cannot be
3
+ // undone on Play, so `reply` is the single interactive prompt in the CLI:
4
+ // it shows the exact text and asks y/N, unless --yes.
5
+
6
+ import readline from "node:readline";
7
+ import { UsageError } from "../args.mjs";
8
+ import { ApiError } from "../http.mjs";
9
+ import { C, OK } from "../format.mjs";
10
+
11
+ /** Apple caps a developer response at 5970 characters; Google at 350. */
12
+ export const REPLY_LIMITS = { app_store: 5970, google_play: 350 };
13
+ const STORE_LABEL = { app_store: "App Store", google_play: "Google Play" };
14
+
15
+ export async function reviews(ctx) {
16
+ const [sub, arg] = ctx.positionals;
17
+ if (sub === "draft") return draft(ctx, arg);
18
+ if (sub === "reply") return reply(ctx, arg);
19
+ if (sub) throw new UsageError("sprid reviews [--app <slug>] [--unanswered] [--max-rating N] [--limit N] | draft <id> | reply <id> [--text …] [--yes]");
20
+ return list(ctx);
21
+ }
22
+
23
+ async function fetchReviews(ctx, { limit } = {}) {
24
+ const wid = await ctx.workspaceId();
25
+ const params = new URLSearchParams({ workspaceId: String(wid) });
26
+ if (ctx.flags.app) params.set("profile", String(ctx.flags.app));
27
+ if (ctx.flags.unanswered) params.set("unanswered", "1");
28
+ if (ctx.flags["max-rating"] !== undefined) params.set("maxRating", String(ctx.flags["max-rating"]));
29
+ if (limit ?? ctx.flags.limit) params.set("limit", String(limit ?? ctx.flags.limit));
30
+ const res = await ctx.api().get(`/api/store-reviews?${params}`);
31
+ return res?.reviews ?? [];
32
+ }
33
+
34
+ async function list(ctx) {
35
+ const rows = await fetchReviews(ctx);
36
+ if (ctx.json) {
37
+ ctx.out({ reviews: rows });
38
+ return 0;
39
+ }
40
+ if (rows.length === 0) {
41
+ ctx.print(" No reviews match.");
42
+ return 0;
43
+ }
44
+ ctx.print(rows.map(renderReview).join("\n\n"));
45
+ return 0;
46
+ }
47
+
48
+ export function stars(n) {
49
+ const r = Math.max(0, Math.min(5, Number(n) || 0));
50
+ return "★".repeat(r) + "☆".repeat(5 - r);
51
+ }
52
+
53
+ export function truncate(s, n = 200) {
54
+ s = String(s ?? "").replace(/\s+/g, " ").trim();
55
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
56
+ }
57
+
58
+ export function renderReview(r) {
59
+ const date = r.reviewedAt ? String(r.reviewedAt).slice(0, 10) : "";
60
+ const head = ` ${stars(r.rating)} ${r.app?.slug ?? r.appProfileId} ${STORE_LABEL[r.source] ?? r.source} ${date} ${C.dim(`#${r.id}`)}`;
61
+ const lines = [head];
62
+ const text = [r.title, r.body].filter(Boolean).join(" · ");
63
+ if (text) lines.push(` ${truncate(text)}`);
64
+ if (r.repliedAt || r.developerResponse) {
65
+ lines.push(C.dim(` replied${r.repliedAt ? ` ${String(r.repliedAt).slice(0, 10)}` : ""}: ${truncate(r.replyText ?? r.developerResponse, 120)}`));
66
+ } else if (r.draftReply) {
67
+ lines.push(` ${C.dim("draft →")} ${truncate(r.draftReply, 200)}`);
68
+ lines.push(C.dim(` sprid reviews reply ${r.id}`));
69
+ }
70
+ return lines.join("\n");
71
+ }
72
+
73
+ function needId(arg, usage) {
74
+ const id = Number(arg);
75
+ if (!Number.isInteger(id) || id <= 0) throw new UsageError(usage);
76
+ return id;
77
+ }
78
+
79
+ async function draft(ctx, arg) {
80
+ const id = needId(arg, "sprid reviews draft <id>");
81
+ const res = await ctx.api().post(`/api/store-reviews/${id}/draft`);
82
+ if (ctx.json) ctx.out(res);
83
+ else {
84
+ ctx.print(` Draft for review #${id}:`);
85
+ ctx.print(` ${res.draftReply ?? ""}`);
86
+ ctx.print(C.dim(` Send it: sprid reviews reply ${id} (edit first: --text "…")`));
87
+ }
88
+ return 0;
89
+ }
90
+
91
+ async function reply(ctx, arg) {
92
+ const id = needId(arg, "sprid reviews reply <id> [--text …] [--yes]");
93
+ // No GET /api/store-reviews/:id exists; the list (max 100, newest first) is
94
+ // the lookup. Good enough for a reply, which is about a recent review.
95
+ const rows = await fetchReviews(ctx, { limit: 100 });
96
+ const row = rows.find((r) => r.id === id);
97
+ let text = ctx.flags.text && ctx.flags.text !== true ? String(ctx.flags.text) : null;
98
+ if (!text) {
99
+ if (!row) throw new ApiError(404, `Review #${id} is not among the latest 100${ctx.flags.app ? ` for ${ctx.flags.app}` : ""}; pass --text to reply anyway.`);
100
+ if (!row.draftReply) throw new UsageError(`Review #${id} has no draft. Run \`sprid reviews draft ${id}\` or pass --text.`);
101
+ text = row.draftReply;
102
+ }
103
+ const source = row?.source ?? null;
104
+ const limit = source ? REPLY_LIMITS[source] : null;
105
+ if (limit && text.length > limit) {
106
+ throw new UsageError(`${text.length} characters; ${STORE_LABEL[source]} allows ${limit}. Shorten it with --text.`);
107
+ }
108
+
109
+ if (!ctx.flags.yes) {
110
+ const interactive = Boolean(ctx.stdin?.isTTY);
111
+ if (ctx.json || !interactive) {
112
+ const msg = "Replying is public. Re-run with --yes to send without the prompt.";
113
+ if (ctx.json) {
114
+ ctx.out({ ok: false, error: msg, id, text, store: source, limit });
115
+ } else {
116
+ ctx.print(` Will send to ${STORE_LABEL[source] ?? "the store"}${limit ? ` (${text.length}/${limit} chars)` : ""} for review #${id}:`);
117
+ ctx.print(` ${text}`);
118
+ ctx.warn(` ✗ ${msg}`);
119
+ }
120
+ return 2;
121
+ }
122
+ ctx.print(` Will send to ${STORE_LABEL[source] ?? "the store"}${limit ? ` (${text.length}/${limit} chars)` : ""} for review #${id}:`);
123
+ if (row) ctx.print(C.dim(` re: ${stars(row.rating)} ${truncate([row.title, row.body].filter(Boolean).join(" · "), 120)}`));
124
+ ctx.print("");
125
+ ctx.print(` ${text}`);
126
+ ctx.print("");
127
+ const ok = await confirm(ctx, " Send it? [y/N] ");
128
+ if (!ok) {
129
+ ctx.print(" Not sent.");
130
+ return 1;
131
+ }
132
+ }
133
+
134
+ const res = await ctx.api().post(`/api/store-reviews/${id}/reply`, { text });
135
+ if (ctx.json) ctx.out({ ok: true, id, text, review: res.review ?? null });
136
+ else ctx.print(` ${C.ok(OK)} Reply sent for review #${id}${source ? ` on ${STORE_LABEL[source]}` : ""}.`);
137
+ return 0;
138
+ }
139
+
140
+ export function confirm(ctx, prompt) {
141
+ return new Promise((resolve) => {
142
+ ctx.rawWrite(prompt);
143
+ const rl = readline.createInterface({ input: ctx.stdin });
144
+ rl.question("", (answer) => {
145
+ rl.close();
146
+ resolve(/^y(es)?$/i.test(answer.trim()));
147
+ });
148
+ });
149
+ }
@@ -0,0 +1,220 @@
1
+ // The local task survives every browser boundary. The server remains the
2
+ // authority for identity, connected reads and publishing outcomes.
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { UsageError } from "../args.mjs";
7
+ import { NotLoggedIn } from "../creds.mjs";
8
+ import { readLocalApp, listProfiles } from "../profiles.mjs";
9
+ import { init } from "./init.mjs";
10
+ import { login } from "./auth.mjs";
11
+ import { connect } from "./connect.mjs";
12
+ import { marketingReview } from "./marketing-review.mjs";
13
+
14
+ const TASKS = ["publish", "review", "repo"];
15
+ const PLATFORMS = ["instagram", "tiktok", "youtube", "facebook", "linkedin", "pinterest", "x"];
16
+ const hash = data => createHash("sha256").update(data).digest("hex");
17
+ const quote = s => /^[a-zA-Z0-9_./:-]+$/.test(String(s)) ? String(s) : "'" + String(s).replaceAll("'", "'\\''") + "'";
18
+ function command(...args) { return { argv: args, command: args.map(quote).join(" ") }; }
19
+ function localFile(ctx, value) {
20
+ const path = realpathSync(resolve(ctx.cwd, String(value)));
21
+ const rel = relative(realpathSync(ctx.cwd), path);
22
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith("../")) throw new UsageError("Keep the prepared artifact inside this repo.");
23
+ return rel;
24
+ }
25
+ function readState(ctx) {
26
+ const path = resolve(ctx.cwd, ".sprid/setup.json");
27
+ if (!existsSync(path)) return null;
28
+ const state = JSON.parse(readFileSync(path, "utf8"));
29
+ if (state.version !== 1 || !TASKS.includes(state.task) || typeof state.key !== "string") throw new UsageError("Unsupported .sprid/setup.json; restore the original task file.");
30
+ return state;
31
+ }
32
+ function save(ctx, state, stage) {
33
+ state.stage = stage;
34
+ if (state.history.at(-1)?.stage !== stage) state.history.push({ stage, at: new Date().toISOString() });
35
+ const path = resolve(ctx.cwd, ".sprid/setup.json");
36
+ mkdirSync(dirname(path), { recursive: true });
37
+ const tmp = `${path}.${process.pid}.tmp`;
38
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
39
+ renameSync(tmp, path);
40
+ }
41
+ function report(ctx, state, stage, message, extra = {}) {
42
+ save(ctx, state, stage);
43
+ const result = { ok: true, stage, message, task: state.task, artifact: state.artifact,
44
+ workspaceId: state.workspaceId ?? null, appId: state.appId ?? null, postId: state.postId ?? null,
45
+ resume: command("sprid", "setup", "continue"), ...extra };
46
+ if (ctx.json) ctx.out(result);
47
+ else {
48
+ ctx.print(message);
49
+ ctx.print(`Prepared work: ${state.artifact}`);
50
+ if (extra.app) {
51
+ ctx.print(`App: ${extra.app.name} (${extra.app.slug}), workspace ${state.workspaceId}`);
52
+ for (const field of ["appStoreId", "playPackageName", "websiteUrl"]) {
53
+ if (extra.app[field]) ctx.print(`${field}: ${extra.app[field]}`);
54
+ }
55
+ }
56
+ for (const account of extra.accounts ?? []) ctx.print(`${account.slug}: ${account.name} (app ${account.appId ?? "unlinked"})`);
57
+ for (const destination of extra.destinations ?? []) ctx.print(`Destination: ${destination.platform}, ${destination.name ?? destination.id}`);
58
+ for (const delivery of extra.publishes ?? []) {
59
+ const status = delivery.status === "published" && !(delivery.platformPostId && delivery.publishedAt)
60
+ ? "published status, awaiting confirmation" : delivery.status;
61
+ ctx.print(`${delivery.platform}: ${status}${delivery.publishedAt ? ` at ${delivery.publishedAt}` : delivery.scheduledFor ? ` for ${delivery.scheduledFor}` : ""}`);
62
+ }
63
+ if (extra.evidence) ctx.print(`Evidence: ${JSON.stringify(extra.evidence)}`);
64
+ if (extra.next) ctx.print(extra.next.command);
65
+ if (extra.verify) ctx.print(extra.verify.command);
66
+ if (extra.href) ctx.print(extra.href);
67
+ }
68
+ return 0;
69
+ }
70
+ async function capture(ctx, fn, overrides = {}) {
71
+ let result;
72
+ const code = await fn({ ...ctx, ...overrides, json: true, out: data => { result = data; }, print: ctx.warn });
73
+ if (code) throw new UsageError(result?.error || "This step is unfinished. Run sprid setup continue to resume.");
74
+ return result;
75
+ }
76
+
77
+ export async function setup(ctx) {
78
+ const [verb = "status"] = ctx.positionals;
79
+ if (!["begin", "status", "continue", "verify", "results"].includes(verb)) throw new UsageError("sprid setup begin|status|continue|verify|results [--file artifact] [--task publish|review|repo]");
80
+ const dir = resolve(ctx.cwd, ".sprid");
81
+ mkdirSync(dir, { recursive: true });
82
+ const lock = resolve(dir, "setup.lock");
83
+ if (existsSync(lock)) {
84
+ const pid = Number(readFileSync(lock, "utf8"));
85
+ let alive = true;
86
+ try { process.kill(pid, 0); } catch (e) { if (e.code === "ESRCH") alive = false; }
87
+ if (alive) throw new UsageError("Setup is already running in this repo. Continue in that terminal.");
88
+ rmSync(lock);
89
+ }
90
+ writeFileSync(lock, String(process.pid), { flag: "wx", mode: 0o600 });
91
+ try { return await runSetup(ctx, verb); } finally { rmSync(lock, { force: true }); }
92
+ }
93
+
94
+ async function runSetup(ctx, verb) {
95
+ let state = readState(ctx);
96
+ if (verb === "results") {
97
+ const account = ctx.flags.account ?? state?.accountId;
98
+ if (!account) throw new UsageError("Use --account <slug> or resume a publishing task first.");
99
+ ctx.out(await ctx.api().get(`/api/posts/setup-results?account=${encodeURIComponent(account)}`));
100
+ return 0;
101
+ }
102
+ if (verb === "begin") {
103
+ if (!ctx.flags.file) throw new UsageError("Use --file <prepared artifact> to keep the work you already made.");
104
+ const artifact = localFile(ctx, ctx.flags.file);
105
+ const task = ctx.flags.task ?? "publish";
106
+ if (!TASKS.includes(task)) throw new UsageError(`Use --task ${TASKS.join("|")}`);
107
+ if (state && (state.artifact !== artifact || state.task !== task)) throw new UsageError(`An unfinished task already points to ${state.artifact}. Resume it; do not replace the first result during setup.`);
108
+ state ??= { version: 1, key: randomUUID(), task, artifact, entry: ctx.flags.entry ?? "installed-skill", source: ctx.flags.source ?? null, history: [] };
109
+ return report(ctx, state, state.stage ?? "local-result", "Your prepared work is saved. Connecting Sprid will continue this task.", { next: command("sprid", "setup", "continue") });
110
+ }
111
+ if (!state) throw new UsageError("Ask your agent to prepare the first result, then run sprid setup begin --file <artifact> --task publish|review|repo.");
112
+ localFile(ctx, state.artifact); // Never replace a missing artifact with a new draft.
113
+ if (verb === "status") return report(ctx, state, state.stage, "Resume the saved task from this repo.", { history: state.history, next: command("sprid", "setup", "continue") });
114
+ if (state.marketingAction) return report(ctx, state, state.stage, "Continue the linked marketing action. The original local artifact is unchanged; review its current shared version before approving it.", {
115
+ actionId: state.marketingAction.id,
116
+ next: command("sprid", "plan", "get", "--app", String(state.appId), "--workspace", String(state.workspaceId)),
117
+ });
118
+ if (state.task === "repo") return report(ctx, state, "review-local-work", "Return to the prepared repo change. Review the diff and verify the release after approval; Sprid setup is optional.");
119
+ const local = readLocalApp(ctx.cwd);
120
+ if (!local?.data?.name) throw new UsageError("The app identity is missing. Ask your agent to prepare .sprid/app.json from this repo before continuing.");
121
+ try { await ctx.me(); } catch (e) {
122
+ if (!(e instanceof NotLoggedIn) && e.status !== 401) throw e;
123
+ if (ctx.env.SPRID_PAT) throw new UsageError("The supplied SPRID_PAT was refused. Restore that connection before resuming.");
124
+ save(ctx, state, "login");
125
+ await capture(ctx, login);
126
+ ctx._auth = null; ctx._me = null; ctx._workspace = undefined;
127
+ await ctx.me();
128
+ }
129
+ const wid = await ctx.workspaceId();
130
+ const apiUrl = ctx.auth().apiUrl;
131
+ if (state.apiUrl && state.apiUrl !== apiUrl) throw new UsageError("This task belongs to a different Sprid server. Restore its API address before resuming.");
132
+ state.apiUrl = apiUrl;
133
+ if ((state.workspaceId && state.workspaceId !== wid) || (local.data.workspaceId && local.data.workspaceId !== wid))
134
+ throw new UsageError(`This task belongs to workspace ${state.workspaceId ?? local.data.workspaceId}. Resume there.`);
135
+ state.workspaceId = wid;
136
+ const profiles = await listProfiles(ctx, wid);
137
+ const pinned = state.appId ?? local.data.appId;
138
+ let profile = profiles.find(p => pinned ? p.id === pinned : p.slug === local.data.slug);
139
+ if (pinned && !profile) throw new UsageError("The saved app is inaccessible. Restore access; do not create another app.");
140
+ if (!pinned && !ctx.flags.yes) return report(ctx, state, "confirm-app", "Confirm the app inferred from this repo before registering or linking it.", {
141
+ app: { id: profile?.id ?? null, name: local.data.name, slug: local.data.slug, appStoreId: local.data.appStoreId, playPackageName: local.data.playPackageName, websiteUrl: local.data.websiteUrl },
142
+ next: command("sprid", "setup", "continue", "--yes"),
143
+ });
144
+ if (!profile || !local.data.appId) {
145
+ save(ctx, state, "register-app");
146
+ profile = (await capture(ctx, init, { flags: { ...ctx.flags, workspace: String(wid) } })).profile;
147
+ }
148
+ if (!state.appId) {
149
+ state.appId = profile.id;
150
+ save(ctx, state, "app-confirmed");
151
+ }
152
+ if (state.task === "review") {
153
+ if (state.evidence && verb !== "verify") return report(ctx, state, "review-evidence", "Return to the original question with the saved evidence. Run setup verify to refresh it explicitly.", { evidence: state.evidence });
154
+ if (verb !== "verify") return report(ctx, state, "verify-source", "Use the source that answers the original question. A saved key alone is not verified access.", {
155
+ source: state.source, next: command("sprid", "marketing-review", "capabilities", "--app", String(profile.id), "--workspace", String(wid)),
156
+ verify: command("sprid", "setup", "verify", "--source", state.source ?? "<source>", "--operation", "<operation>", "--params-file", "<params.json>"),
157
+ });
158
+ if (state.source && ctx.flags.source !== state.source) throw new UsageError(`This task needs ${state.source}; verify that source before continuing.`);
159
+ state.evidence = null;
160
+ save(ctx, state, "verify-source");
161
+ const result = await capture(ctx, marketingReview, { positionals: ["query"], flags: { ...ctx.flags, app: String(profile.id), workspace: String(wid) } });
162
+ if (!["ready", "partial"].includes(result.status) || result.measurement?.scope !== `workspace:${wid}:app:${profile.id}`) throw new UsageError("The source did not return verified evidence for this app. Keep the task open and fix its connection.");
163
+ state.evidence = result.evidence;
164
+ return report(ctx, state, "review-evidence", "The connected read succeeded for this app. Return to the original question with this evidence; review its coverage before drawing conclusions.", { evidence: result.evidence, resource: result.resource, coverage: result.measurement.coverage });
165
+ }
166
+ const all = await ctx.api().get("/api/accounts");
167
+ const rows = (Array.isArray(all) ? all : all.accounts ?? []).filter(a => a.workspaceId === wid);
168
+ const linked = rows.filter(a => a.appProfileId === profile.id);
169
+ const wanted = state.accountId ?? ctx.flags.account;
170
+ let account = wanted ? rows.find(a => String(a.id) === String(wanted) || a.slug === wanted) : linked.length === 1 ? linked[0] : null;
171
+ if (wanted && !account) throw new UsageError("The saved publishing account is unavailable in this workspace.");
172
+ if (account && account.appProfileId !== profile.id) throw new UsageError("Link this publishing account to the confirmed app in Settings before continuing.");
173
+ if (!account && (linked.length > 1 || rows.some(a => a.appProfileId == null))) return report(ctx, state, "choose-account", "Choose the publishing identity for this app. Existing unlinked accounts can be linked in Settings.", { accounts: rows.map(a => ({ id: a.id, name: a.name, slug: a.slug, appId: a.appProfileId })), next: command("sprid", "setup", "continue", "--account", "<slug>") });
174
+ if (!account) {
175
+ save(ctx, state, "create-account");
176
+ account = await ctx.api().post("/api/accounts", { workspaceId: wid, appProfileId: profile.id, name: profile.name, slug: `${profile.slug}-${wid}` });
177
+ }
178
+ state.accountId = account.id;
179
+ if (ctx.flags.payload) {
180
+ const path = localFile(ctx, ctx.flags.payload);
181
+ const data = readFileSync(resolve(ctx.cwd, path), "utf8");
182
+ if (state.payloadHash && state.payloadHash !== hash(data)) throw new UsageError("This task already has a draft payload. Edit its existing post instead of replacing it during setup.");
183
+ state.payload = path; state.payloadHash = hash(data);
184
+ }
185
+ if (!state.postId) {
186
+ if (!state.payload) return report(ctx, state, "prepare-draft", "Return to the saved draft. Have your agent convert that same copy to the setup payload, then continue.", { next: command("sprid", "setup", "continue", "--payload", "<draft.json>") });
187
+ const data = readFileSync(resolve(ctx.cwd, localFile(ctx, state.payload)), "utf8");
188
+ if (hash(data) !== state.payloadHash) throw new UsageError("The saved payload changed. Restore it before retrying; edit the hosted draft after recovery.");
189
+ save(ctx, state, "prepare-draft");
190
+ const prepared = await ctx.api().post(`/api/posts/setup-draft?account=${encodeURIComponent(account.slug)}`, { ...JSON.parse(data), key: state.key });
191
+ state.postId = prepared.post.id;
192
+ save(ctx, state, "draft-prepared");
193
+ }
194
+ const post = await ctx.api().get(`/api/posts/${state.postId}`);
195
+ if (post.accountId !== account.id) throw new UsageError("The prepared post no longer belongs to this task's account.");
196
+ const deliveries = await ctx.api().get(`/api/posts/${state.postId}/publishes`);
197
+ const publishes = Array.isArray(deliveries) ? deliveries : deliveries.publishes ?? [];
198
+ const href = `${ctx.appUrl()}/p/${state.postId}`;
199
+ if (publishes.length) return report(ctx, state, publishes.every(p => p.status === "published" && p.platformPostId && p.publishedAt) ? "published" : "delivery-pending", "Read each destination's result separately. A scheduled post is still waiting to publish.", {
200
+ href, publishes: publishes.map(p => ({ id: p.id, platform: p.platform, status: p.status, scheduledFor: p.scheduledFor, publishedAt: p.publishedAt, platformPostId: p.platformPostId })),
201
+ });
202
+ let channels = await ctx.api().get(`/api/channels?account=${encodeURIComponent(account.slug)}`);
203
+ if (ctx.flags.platform) {
204
+ if (!PLATFORMS.includes(ctx.flags.platform)) throw new UsageError(`Use --platform ${PLATFORMS.join("|")}`);
205
+ if (state.platform && state.platform !== ctx.flags.platform) throw new UsageError(`This task targets ${state.platform}. Review a destination change in the post editor.`);
206
+ state.platform = ctx.flags.platform;
207
+ save(ctx, state, "connect-channel");
208
+ if (!channels.some(c => c.platform === state.platform && c.status === "active")) {
209
+ await capture(ctx, connect, { positionals: [state.platform], flags: { ...ctx.flags, account: account.slug } });
210
+ channels = await ctx.api().get(`/api/channels?account=${encodeURIComponent(account.slug)}`);
211
+ }
212
+ }
213
+ const active = channels.filter(c => c.status === "active" && (!state.platform || c.platform === state.platform));
214
+ if (!active.length) return report(ctx, state, "connect-channel", "The draft is ready to edit. Connect its publishing destination, then return to the same preview.", { href, next: command("sprid", "setup", "continue", "--platform", state.platform ?? "<platform>"), readiness: { draft: true, publish: false, review: "not-required" } });
215
+ return report(ctx, state, "review-draft", "Return to the original draft in Sprid. Review its rendered slides and exact destination, then approve in the publish or schedule screen.", {
216
+ href, next: command("sprid", "open", "post", String(state.postId)),
217
+ destinations: active.map(c => ({ id: c.id, platform: c.platform, name: c.displayName })),
218
+ readiness: { draft: true, publish: "channel-connected", review: "not-required" },
219
+ });
220
+ }