@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 +11 -0
- package/package.json +1 -1
- package/src/commands/family.mjs +32 -2
- package/src/commands/init.mjs +1 -0
- package/src/commands/reviews.mjs +26 -17
- package/src/commands/studio.mjs +4 -2
- package/src/docs/commands.mjs +2 -1
- package/src/docs/guides.generated.mjs +243 -255
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.
|
|
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",
|
package/src/commands/family.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -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: " " }));
|
package/src/commands/reviews.mjs
CHANGED
|
@@ -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 =
|
|
75
|
-
if (
|
|
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
|
|
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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
100
|
-
if (!row.draftReply) throw new UsageError(`Review
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/src/commands/studio.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`)
|
package/src/docs/commands.mjs
CHANGED
|
@@ -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.'],
|