@sprid/cli 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
3
3
  sprid follows semver: a breaking change to a command's arguments, its output
4
4
  shape or its exit codes is a major release.
5
5
 
6
+ ## 0.1.3 - 2026-09-23
7
+
8
+ - Posts and reviews have refs: `BND-78` is post 78 of the account keyed BND,
9
+ `BND-R4` is review 4 of the app keyed BND. Every command that takes a post id
10
+ takes its ref, and `sprid reviews draft|reply` take a review ref. `sprid
11
+ status` and the review mail print refs, which older versions refuse.
12
+ - `sprid reviews reply` reads the one review it replies to, so a review
13
+ older than the latest hundred can be answered from its draft.
14
+ - `sprid account avatar <account> [file|url]` sets an account's avatar; with
15
+ no source it uses the app's own icon. `sprid init` reports the avatar it set.
16
+
6
17
  ## 0.1.2 - 2026-09-22
7
18
 
8
19
  - Published as `@sprid/cli`. The command is still `sprid`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprid/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "The Sprid command line: connect your app, review results, prepare posts and store screenshots, and release mobile apps.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { extname, resolve } from "node:path";
3
3
  import { UsageError } from "../args.mjs";
4
4
  import { resolveProfile } from "../profiles.mjs";
5
5
 
@@ -88,9 +88,39 @@ export async function account(ctx) {
88
88
  .api()
89
89
  .patch(`/api/accounts/${positiveId(raw)}`, objectFile(ctx)),
90
90
  );
91
+ else if (verb === "avatar") return accountAvatar(ctx, raw, ctx.positionals[2]);
91
92
  else
92
93
  throw new UsageError(
93
- "sprid account create|update [id] --file payload.json",
94
+ "sprid account create|update [id] --file payload.json | sprid account avatar <account> [file|url]",
94
95
  );
95
96
  return 0;
96
97
  }
98
+
99
+ const AVATAR_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
100
+
101
+ // The account's avatar: what Sprid's mails and the account.avatarUrl template
102
+ // binding draw. No source = the app's own icon, looked up by the server.
103
+ async function accountAvatar(ctx, ref, source) {
104
+ if (!ref) throw new UsageError("sprid account avatar <account> [file|url]");
105
+ const account = encodeURIComponent(String(ref));
106
+ if (!source || /^https?:\/\//i.test(source)) {
107
+ const result = await ctx.api().post(`/api/accounts/${account}/avatar/from-url`, source ? { url: source } : {});
108
+ if (ctx.flags.json) ctx.out(result);
109
+ else ctx.print(`Avatar set from ${result.source} (${result.size}): ${result.avatarUrl}`);
110
+ return 0;
111
+ }
112
+ const path = resolve(ctx.cwd, source);
113
+ const ext = extname(path).toLowerCase();
114
+ const contentType = AVATAR_TYPES[ext];
115
+ if (!contentType) throw new UsageError("The avatar must be a PNG, JPEG or WebP file. SVG and ICO do not render in mail clients.");
116
+ const bytes = readFileSync(path);
117
+ if (bytes.length > 5 * 1024 * 1024) throw new UsageError("The avatar file is over 5 MB. Export the icon at 512 or 1024 px.");
118
+ const api = ctx.api();
119
+ const { uploadUrl, key } = await api.post(`/api/accounts/${account}/avatar/upload-url`, { contentType });
120
+ const put = await (ctx.fetch ?? fetch)(uploadUrl, { method: "PUT", headers: { "content-type": contentType }, body: bytes, redirect: "error", signal: AbortSignal.timeout(60000) });
121
+ if (!put.ok) throw new Error(`Uploading the avatar failed (${put.status}).`);
122
+ const result = await api.post(`/api/accounts/${account}/avatar/confirm`, { key });
123
+ if (ctx.flags.json) ctx.out(result);
124
+ else ctx.print(`Avatar set from ${source}: ${result.avatarUrl}`);
125
+ return 0;
126
+ }
@@ -128,6 +128,7 @@ export async function init(ctx) {
128
128
  ctx.print(` ${C.ok(OK)} ${existing ? "Updated" : "Created"} App Profile ${C.bold(profile.slug)} (${profile.name}) from ${source}`);
129
129
  const set = PROFILE_FIELDS.filter((k) => k !== "slug" && k !== "name" && profile[k]).map((k) => [k, profile[k]]);
130
130
  if (set.length) ctx.print(table(set, { indent: " " }));
131
+ if (profile.avatar) ctx.print(` ${C.ok(OK)} Avatar for ${profile.avatar.accounts.join(", ")} from the ${profile.avatar.source} icon (${profile.avatar.size})`);
131
132
  if (missing.length) {
132
133
  ctx.print(" Still missing:");
133
134
  ctx.print(table(missing.map((m) => [`${NONE} ${m.label.split(" · ")[0]}`, C.dim("→"), m.cli]), { indent: " " }));
@@ -70,18 +70,24 @@ export function renderReview(r) {
70
70
  return lines.join("\n");
71
71
  }
72
72
 
73
+ // A review is named by its row id (41) or its ref (BND-R41); the server
74
+ // resolves either, so the CLI only checks the shape.
73
75
  function needId(arg, usage) {
74
- const id = Number(arg);
75
- if (!Number.isInteger(id) || id <= 0) throw new UsageError(usage);
76
- return id;
76
+ const id = String(arg ?? "").trim();
77
+ if (/^[1-9]\d*$/.test(id)) return Number(id); // --json keeps printing a number
78
+ if (/^[A-Za-z][A-Za-z0-9]{1,4}-[Rr]\d+$/.test(id)) return id.toUpperCase();
79
+ throw new UsageError(usage);
77
80
  }
78
81
 
82
+ /** `BND-R41` as is; a bare row id as `#41`. */
83
+ const label = (id) => (/^\d+$/.test(String(id)) ? `#${id}` : String(id));
84
+
79
85
  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`);
86
+ const id = needId(arg, "sprid reviews draft <id|ref>");
87
+ const res = await ctx.api().post(`/api/store-reviews/${encodeURIComponent(id)}/draft`);
82
88
  if (ctx.json) ctx.out(res);
83
89
  else {
84
- ctx.print(` Draft for review #${id}:`);
90
+ ctx.print(` Draft for review ${label(id)}:`);
85
91
  ctx.print(` ${res.draftReply ?? ""}`);
86
92
  ctx.print(C.dim(` Send it: sprid reviews reply ${id} (edit first: --text "…")`));
87
93
  }
@@ -89,15 +95,18 @@ async function draft(ctx, arg) {
89
95
  }
90
96
 
91
97
  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);
98
+ const id = needId(arg, "sprid reviews reply <id|ref> [--text …] [--yes]");
99
+ // The one review, by id or ref. Any age, not only the latest hundred.
100
+ let row = null;
101
+ try {
102
+ row = (await ctx.api().get(`/api/store-reviews/${encodeURIComponent(id)}`)).review ?? null;
103
+ } catch (err) {
104
+ if (!(err instanceof ApiError) || err.status !== 404) throw err;
105
+ }
97
106
  let text = ctx.flags.text && ctx.flags.text !== true ? String(ctx.flags.text) : null;
98
107
  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.`);
108
+ if (!row) throw new ApiError(404, `Review ${label(id)} was not found; pass --text to reply anyway.`);
109
+ if (!row.draftReply) throw new UsageError(`Review ${label(id)} has no draft. Run \`sprid reviews draft ${id}\` or pass --text.`);
101
110
  text = row.draftReply;
102
111
  }
103
112
  const source = row?.source ?? null;
@@ -113,13 +122,13 @@ async function reply(ctx, arg) {
113
122
  if (ctx.json) {
114
123
  ctx.out({ ok: false, error: msg, id, text, store: source, limit });
115
124
  } else {
116
- ctx.print(` Will send to ${STORE_LABEL[source] ?? "the store"}${limit ? ` (${text.length}/${limit} chars)` : ""} for review #${id}:`);
125
+ ctx.print(` Will send to ${STORE_LABEL[source] ?? "the store"}${limit ? ` (${text.length}/${limit} chars)` : ""} for review ${label(row?.ref ?? id)}:`);
117
126
  ctx.print(` ${text}`);
118
127
  ctx.warn(` ✗ ${msg}`);
119
128
  }
120
129
  return 2;
121
130
  }
122
- ctx.print(` Will send to ${STORE_LABEL[source] ?? "the store"}${limit ? ` (${text.length}/${limit} chars)` : ""} for review #${id}:`);
131
+ ctx.print(` Will send to ${STORE_LABEL[source] ?? "the store"}${limit ? ` (${text.length}/${limit} chars)` : ""} for review ${label(row?.ref ?? id)}:`);
123
132
  if (row) ctx.print(C.dim(` re: ${stars(row.rating)} ${truncate([row.title, row.body].filter(Boolean).join(" · "), 120)}`));
124
133
  ctx.print("");
125
134
  ctx.print(` ${text}`);
@@ -131,9 +140,9 @@ async function reply(ctx, arg) {
131
140
  }
132
141
  }
133
142
 
134
- const res = await ctx.api().post(`/api/store-reviews/${id}/reply`, { text });
143
+ const res = await ctx.api().post(`/api/store-reviews/${encodeURIComponent(id)}/reply`, { text });
135
144
  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]}` : ""}.`);
145
+ else ctx.print(` ${C.ok(OK)} Reply sent for review ${label(row?.ref ?? id)}${source ? ` on ${STORE_LABEL[source]}` : ""}.`);
137
146
  return 0;
138
147
  }
139
148
 
@@ -11,10 +11,12 @@ async function payload(ctx) {
11
11
  }
12
12
  export async function remotePost(ctx) {
13
13
  const [verb, ref] = ctx.positionals;
14
- const id = ctx.flags.id ?? ref;
14
+ let id = ctx.flags.id ?? ref;
15
15
  if (!['create', 'get', 'update', 'deliveries', 'handoff'].includes(verb) && !(verb === 'preview' && ctx.flags.id)) return false;
16
- if (verb !== 'create' && !/^[1-9]\d*$/.test(String(id))) throw new UsageError('Supply the positive server post ID. Local preview slugs remain unchanged.');
16
+ // The server post id (271) or its ref (BND-78); the server resolves either.
17
+ if (verb !== 'create' && !/^([1-9]\d*|[A-Za-z][A-Za-z0-9]{1,4}-\d+)$/.test(String(id))) throw new UsageError('Supply the server post ID or its ref, such as 271 or BND-78. Local preview slugs remain unchanged.');
17
18
  const api = ctx.api();
19
+ if (verb !== 'create') id = encodeURIComponent(String(id).toUpperCase());
18
20
  const result = verb === 'create' ? await api.post('/api/studio/posts', await payload(ctx))
19
21
  : verb === 'update' ? await api.patch(`/api/posts/${id}`, await payload(ctx))
20
22
  : verb === 'preview' ? await api.get(`/api/studio/posts/${id}/preview`)
@@ -29,7 +29,7 @@ export const COMMAND_GROUPS = [
29
29
  ['media', 'sprid media job <UUID>', 'Read saved upload results or creation progress without repeating the operation.'],
30
30
  ['media', 'sprid media init|build|register|check|push|preview …', 'Run the existing local media pipeline. Historical sprid post invocations remain supported.'],
31
31
  ['post', 'sprid post create --file <draft.json>', 'Create a shared draft from ordered asset references and a stable requestId. Never publishes.'],
32
- ['post', 'sprid post get <postId>', 'Read the shared post and its slides.'],
32
+ ['post', 'sprid post get <postId>', 'Read the shared post and its slides. Every <postId> here takes the number or the post\'s ref, such as BND-142.'],
33
33
  ['post', 'sprid post update <postId> --file <changes.json>', 'Apply the existing post-update contract to a shared draft.'],
34
34
  ['post', 'sprid post preview --id <postId>', 'Preview a shared post. Without --id, historical local preview arguments are unchanged.'],
35
35
  ['post', 'sprid post deliveries <postId>', 'Read scheduled, failed and delivered publications with platform receipts.'],
@@ -47,6 +47,7 @@ export const COMMAND_GROUPS = [
47
47
  ] },
48
48
  { title: 'Apps and market editions', entries: [
49
49
  ['account', 'sprid account create|update [id] --file payload.json', 'Create an app content account or update its identity. Creation requires workspaceId, appProfileId, name and slug; market, locale, persona and timezone are explicit.'],
50
+ ['account', 'sprid account avatar <account> [file|url]', 'Set the avatar Sprid’s mails and templates draw. No source uses the app’s icon: App Store artwork, else the website’s manifest or touch icon. A file must be PNG, JPEG or WebP; a 1024 px app icon from the repo is the best source.'],
50
51
  ['apps', 'sprid apps', 'List apps with their content accounts and workspace IDs.'],
51
52
  ['insights', 'sprid insights --app <slug> [--account <slug>] [--days N]', 'Read app results once, with social content optionally narrowed to one account.'],
52
53
  ['family', 'sprid family compare --app <slug> [--days N]', 'Compare themes at matched post age with sample counts and channel baselines.'],