@sprid/cli 0.1.2 → 0.1.4

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,31 @@
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.4 - 2026-09-23
7
+
8
+ - `sprid whoami` warns when the credentials file is readable by other users
9
+ on the machine, with the `chmod 600` that fixes it, and reports
10
+ `credentialsFileSecure` in `--json`.
11
+ - `sprid docs` carries the tightened connect guides and the new distribution,
12
+ first-seconds and store-listing guides.
13
+ - `sprid post create --file` saves an idea when the file says `"status":
14
+ "idea"`: notes, a `sourceUrl` and any gathered assets, with no assets
15
+ required. The same `requestId` makes a retry safe, so a repo's hook bank
16
+ can be pushed in and pushed again.
17
+ - `sprid family` is removed with market editions, which the server no longer
18
+ has. `sprid apps`, `sprid insights` and `sprid account` are unchanged.
19
+
20
+ ## 0.1.3 - 2026-09-23
21
+
22
+ - Posts and reviews have refs: `BND-78` is post 78 of the account keyed BND,
23
+ `BND-R4` is review 4 of the app keyed BND. Every command that takes a post id
24
+ takes its ref, and `sprid reviews draft|reply` take a review ref. `sprid
25
+ status` and the review mail print refs, which older versions refuse.
26
+ - `sprid reviews reply` reads the one review it replies to, so a review
27
+ older than the latest hundred can be answered from its draft.
28
+ - `sprid account avatar <account> [file|url]` sets an account's avatar; with
29
+ no source it uses the app's own icon. `sprid init` reports the avatar it set.
30
+
6
31
  ## 0.1.2 - 2026-09-22
7
32
 
8
33
  - Published as `@sprid/cli`. The command is still `sprid`.
package/README-post.md CHANGED
@@ -269,7 +269,6 @@ the *copy* here anyway:
269
269
  carousels: {
270
270
  kind: "composed",
271
271
  spec: "social/specs/<slug>.json",
272
- // format: <format id>, // choose from this account’s formats
273
272
  // template: <template id>, // choose from this account’s templates
274
273
  expect: { slides: [5, 9], words: [0, 30] },
275
274
  neverSay: ["the phrase this account decided it does not say"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprid/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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/cli.mjs CHANGED
@@ -29,7 +29,7 @@ import * as mcp from "./commands/mcp.mjs";
29
29
  import * as reviews from "./commands/reviews.mjs";
30
30
  import { marketingReview } from "./commands/marketing-review.mjs";
31
31
  import { plan, research } from './commands/plan.mjs';
32
- import { apps, family, insights, account } from "./commands/family.mjs";
32
+ import { apps, insights, account } from "./commands/family.mjs";
33
33
  import * as post from "./commands/post.mjs";
34
34
  import { docs } from "./commands/docs.mjs";
35
35
  import { screenshots, release } from './commands/local-tools.mjs';
@@ -55,7 +55,7 @@ export const COMMANDS = {
55
55
  plan, research,
56
56
  media, capabilities, completion,
57
57
  screenshots, release, doctor, update,
58
- apps, family, insights, account,
58
+ apps, insights, account,
59
59
  docs,
60
60
  login: auth.login,
61
61
  logout: auth.logout,
@@ -2,7 +2,7 @@
2
2
  // a long secret, the person types a short code into a signed-in browser, and
3
3
  // the server hands over a personal access token exactly once.
4
4
 
5
- import { DEFAULT_API_URL, deleteCredentials, credentialsPath, readCredentials, stripSlash, writeCredentials } from "../creds.mjs";
5
+ import { DEFAULT_API_URL, deleteCredentials, credentialsModeOk, credentialsPath, readCredentials, stripSlash, writeCredentials } from "../creds.mjs";
6
6
  import { ApiError } from "../http.mjs";
7
7
  import { C, OK } from "../format.mjs";
8
8
  import { pickWorkspace } from "../cli.mjs";
@@ -124,6 +124,8 @@ export async function whoami(ctx) {
124
124
  // The current one, if it can be known without asking; never exit 2 from whoami.
125
125
  const current = await ctx.workspace().catch(() => null);
126
126
  const source = a.source === "env" ? "SPRID_PAT" : credentialsPath(ctx.env);
127
+ // Written 0600, but a later copy, restore or chmod can loosen it silently.
128
+ const fileSecure = a.source === "env" ? null : credentialsModeOk(ctx.env);
127
129
  if (ctx.json) {
128
130
  ctx.out({
129
131
  email: user.email ?? a.email ?? null,
@@ -133,6 +135,7 @@ export async function whoami(ctx) {
133
135
  apiUrl: a.apiUrl,
134
136
  tokenSource: source,
135
137
  tokenId: a.tokenId,
138
+ credentialsFileSecure: fileSecure,
136
139
  });
137
140
  return 0;
138
141
  }
@@ -143,6 +146,9 @@ export async function whoami(ctx) {
143
146
  : "no workspace";
144
147
  ctx.print(` ${user.email ?? a.email ?? "(unknown)"} · workspace ${wsLine}`);
145
148
  ctx.print(C.dim(` ${a.apiUrl} · token from ${source}`));
149
+ if (fileSecure === false) {
150
+ ctx.print(` Warning: ${source} is readable by other users on this machine. Fix: chmod 600 "${source}"`);
151
+ }
146
152
  if (workspaces.length > 1) {
147
153
  ctx.print(workspaceList(workspaces, current));
148
154
  if (!current) ctx.print(C.dim(" Pick one: sprid use <slug>"));
@@ -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
 
@@ -18,53 +18,13 @@ function objectFile(ctx) {
18
18
  function positiveId(raw) {
19
19
  const id = Number(raw);
20
20
  if (!Number.isInteger(id) || id <= 0)
21
- throw new UsageError("Pass a positive family or edition ID.");
21
+ throw new UsageError("Pass a positive account ID.");
22
22
  return id;
23
23
  }
24
24
  export async function apps(ctx) {
25
25
  ctx.out(await ctx.api().get("/api/app-profiles/hierarchy"));
26
26
  return 0;
27
27
  }
28
- export async function family(ctx) {
29
- const [verb = "list", raw] = ctx.positionals;
30
- let result;
31
- if (verb === "list" || verb === "compare") {
32
- const workspaceId = await ctx.workspaceId();
33
- const app = await resolveProfile(ctx, workspaceId);
34
- result = await ctx
35
- .api()
36
- .get(
37
- `/api/content-families${verb === "compare" ? "/performance" : ""}?workspaceId=${workspaceId}&app=${app.id}&ageDays=${encodeURIComponent(ctx.flags.days ?? 7)}`,
38
- );
39
- } else if (verb === "create")
40
- result = await ctx.api().post("/api/content-families", objectFile(ctx));
41
- else {
42
- const id = positiveId(raw);
43
- const edition = ["get", "preview", "review", "localize"].includes(verb);
44
- const path = edition
45
- ? `/api/content-families/editions/${id}`
46
- : `/api/content-families/${id}`;
47
- if (verb === "get") result = await ctx.api().get(path);
48
- else if (verb === "preview")
49
- result = await ctx.api().post(`${path}/preview`, {});
50
- else if (
51
- ["edition", "review", "localize", "plan", "schedule"].includes(verb)
52
- )
53
- result = await ctx
54
- .api()
55
- .post(
56
- `${path}/${verb === "edition" ? "editions" : verb}`,
57
- objectFile(ctx),
58
- );
59
- else
60
- throw new UsageError(
61
- "sprid family list|create|edition|get|localize|preview|review|plan|schedule|compare [id] [--file payload.json]",
62
- );
63
- }
64
- // Always print exact IDs, preview URLs, blockers and revision fingerprints.
65
- ctx.out(result);
66
- return 0;
67
- }
68
28
  export async function insights(ctx) {
69
29
  const workspaceId = await ctx.workspaceId();
70
30
  const app = await resolveProfile(ctx, workspaceId);
@@ -88,9 +48,39 @@ export async function account(ctx) {
88
48
  .api()
89
49
  .patch(`/api/accounts/${positiveId(raw)}`, objectFile(ctx)),
90
50
  );
51
+ else if (verb === "avatar") return accountAvatar(ctx, raw, ctx.positionals[2]);
91
52
  else
92
53
  throw new UsageError(
93
- "sprid account create|update [id] --file payload.json",
54
+ "sprid account create|update [id] --file payload.json | sprid account avatar <account> [file|url]",
94
55
  );
95
56
  return 0;
96
57
  }
58
+
59
+ const AVATAR_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
60
+
61
+ // The account's avatar: what Sprid's mails and the account.avatarUrl template
62
+ // binding draw. No source = the app's own icon, looked up by the server.
63
+ async function accountAvatar(ctx, ref, source) {
64
+ if (!ref) throw new UsageError("sprid account avatar <account> [file|url]");
65
+ const account = encodeURIComponent(String(ref));
66
+ if (!source || /^https?:\/\//i.test(source)) {
67
+ const result = await ctx.api().post(`/api/accounts/${account}/avatar/from-url`, source ? { url: source } : {});
68
+ if (ctx.flags.json) ctx.out(result);
69
+ else ctx.print(`Avatar set from ${result.source} (${result.size}): ${result.avatarUrl}`);
70
+ return 0;
71
+ }
72
+ const path = resolve(ctx.cwd, source);
73
+ const ext = extname(path).toLowerCase();
74
+ const contentType = AVATAR_TYPES[ext];
75
+ if (!contentType) throw new UsageError("The avatar must be a PNG, JPEG or WebP file. SVG and ICO do not render in mail clients.");
76
+ const bytes = readFileSync(path);
77
+ if (bytes.length > 5 * 1024 * 1024) throw new UsageError("The avatar file is over 5 MB. Export the icon at 512 or 1024 px.");
78
+ const api = ctx.api();
79
+ const { uploadUrl, key } = await api.post(`/api/accounts/${account}/avatar/upload-url`, { contentType });
80
+ const put = await (ctx.fetch ?? fetch)(uploadUrl, { method: "PUT", headers: { "content-type": contentType }, body: bytes, redirect: "error", signal: AbortSignal.timeout(60000) });
81
+ if (!put.ok) throw new Error(`Uploading the avatar failed (${put.status}).`);
82
+ const result = await api.post(`/api/accounts/${account}/avatar/confirm`, { key });
83
+ if (ctx.flags.json) ctx.out(result);
84
+ else ctx.print(`Avatar set from ${source}: ${result.avatarUrl}`);
85
+ return 0;
86
+ }
@@ -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`)
@@ -28,8 +28,8 @@ export const COMMAND_GROUPS = [
28
28
  ['media', 'sprid media reel --file <recipe.json>', 'Create a metered hosted photos/text reel after explicit user authorization. Returns a durable job ID.'],
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
- ['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.'],
31
+ ['post', 'sprid post create --file <draft.json>', 'Create a shared draft from ordered asset references and a stable requestId. With "status": "idea" it saves an idea instead: notes, a sourceUrl and any gathered assets, before layout. Never publishes.'],
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.'],
@@ -45,18 +45,11 @@ export const COMMAND_GROUPS = [
45
45
  ['release', 'sprid release metadata|screenshots|graphics [--config release.config.ts] [--execute]', 'Preview store listing changes locally; --execute sends the reviewed changes. Requires Bun.'],
46
46
  ['release', 'sprid release ship [--execute] [--ios-only|--android-only]', 'Build and submit your app using its release configuration. Dry run by default; use --help for all options.'],
47
47
  ] },
48
- { title: 'Apps and market editions', entries: [
48
+ { title: 'Apps and accounts', 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
- ['family', 'sprid family compare --app <slug> [--days N]', 'Compare themes at matched post age with sample counts and channel baselines.'],
53
- ['family', 'sprid family list --app <slug>', 'List content families, themes and market editions.'],
54
- ['family', 'sprid family create --file payload.json', 'Create a family: sourcePostId, title, theme and sourceReference.'],
55
- ['family', 'sprid family edition <familyId> --file payload.json', 'Copy an independent draft: accountId, locale and market. Translate it in the editor or through the API.'],
56
- ['family', 'sprid family localize <postId> --file payload.json', 'Save translated captions and slide text or reel beats atomically, clearing old approval.'],
57
- ['family', 'sprid family get|preview <postId>', 'Read the edition or render its current content for review.'],
58
- ['family', 'sprid family review <postId> --file payload.json', 'Approve a rendered revision with fingerprint, sourceReference and confirmed: true.'],
59
- ['family', 'sprid family plan|schedule <familyId> --file payload.json', 'Plan exact postId/channelId/scheduledFor destinations. Scheduling also requires the returned planFingerprint.'],
60
53
  ] },
61
54
  { title: 'Sign in', entries: [
62
55
  ['setup', 'sprid setup begin --file <artifact> --task publish|review|repo [--source <source>] [--entry website|installed-skill|browser|paid]', 'Save the first useful result locally before signup. Repeating begin preserves the same task and retry key.'],