persona-test-cli 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -38,8 +38,9 @@ authorize page loads decides the scope of the issued token.
38
38
  | `logout` | anyone | Clear the stored token |
39
39
  | `whoami` | anyone | Show current identity + available commands |
40
40
  | `campaigns list/create/edit/link/stats` | anyone | Share links — scoped to your own unless admin |
41
+ | `campaigns create/edit --image <path>` | anyone | Set the entry image from a local file — auto-resized/compressed to match the web dashboard's pipeline |
41
42
  | `personas list/create/edit` | anyone | Ad-copy personas — scoped to your own unless admin |
42
- | `events tail` | anyone | Poll new impression/click events |
43
+ | `events tail` | anyone | Poll new impression/click events (starts from now; `--since <iso>` to replay history first) |
43
44
  | `users list/approve/reject` | **admin only** | Registration approval queue |
44
45
 
45
46
  Run any command with `--help` for its full options and copy-pasteable examples.
@@ -56,6 +57,9 @@ Run any command with `--help` for its full options and copy-pasteable examples.
56
57
  hanging on a prompt when stdin isn't a TTY).
57
58
  - `login --no-open`: print the authorize URL instead of trying to launch a browser (headless/
58
59
  sandboxed environments).
60
+ - Update notice: once a day, checks npm for a newer version and prints a one-line reminder to
61
+ stderr if you're behind. Skipped automatically under `--json`, in CI (`CI` env set), or with
62
+ `NO_UPDATE_NOTIFIER=1`.
59
63
 
60
64
  ## License
61
65
 
@@ -2,14 +2,17 @@ import { apiRequest } from "../http.js";
2
2
  import { requireSession } from "../session.js";
3
3
  import { fail, EXIT_CODES } from "../errors.js";
4
4
  import { action, printJsonOrText, printTable } from "../output.js";
5
+ import { resizeImageToDataUrl } from "../image.js";
5
6
  export const CAMPAIGNS_LIST_USAGE = ` persona-test-cli campaigns list
6
7
  persona-test-cli campaigns list --json`;
7
8
  export const CAMPAIGNS_CREATE_USAGE = ` persona-test-cli campaigns create --name "夏季活动" --slug summer --category MyApp
8
9
  persona-test-cli campaigns create --name "夏季活动" --slug summer --category MyApp --persona-keys A,B1 --poster-headline "..." --poster-body "..."
10
+ persona-test-cli campaigns create --name "夏季活动" --slug summer --category MyApp --image ./poster.jpg --image-alt "夏季活动海报"
9
11
 
10
- 注:入口图(entryImageUrl)无法在终端里裁剪压缩,建好后请到网页后台(/dashboard 或 /admin)补传。`;
12
+ 注:--image 本地路径会被自动缩放(宽300px)并转成 JPEG,和网页后台的处理逻辑一致。`;
11
13
  export const CAMPAIGNS_EDIT_USAGE = ` persona-test-cli campaigns edit summer --poster-caption "扫码入群"
12
- persona-test-cli campaigns edit summer --no-active`;
14
+ persona-test-cli campaigns edit summer --no-active
15
+ persona-test-cli campaigns edit summer --image ./poster.jpg --image-alt "新海报"`;
13
16
  export const CAMPAIGNS_LINK_USAGE = ` persona-test-cli campaigns link summer
14
17
  persona-test-cli campaigns link summer --persona A`;
15
18
  export const CAMPAIGNS_STATS_USAGE = ` persona-test-cli campaigns stats summer`;
@@ -50,11 +53,14 @@ export function campaignsCommand(program) {
50
53
  .option("--poster-caption <text>", "海报二维码下方文案", "扫码入群")
51
54
  .option("--poster-headline <text>", "通用海报标题")
52
55
  .option("--poster-body <text>", "通用海报正文")
56
+ .option("--image <path>", "本地图片路径,自动缩放压缩为入口图(entryImageUrl)")
57
+ .option("--image-alt <text>", "入口图的 alt 文本")
53
58
  .option("--base-url <url>")
54
59
  .addHelpText("after", `\n示例:\n${CREATE_USAGE}`)
55
60
  .action(action(async (opts) => {
56
61
  const { baseUrl, token } = requireSession(opts.baseUrl);
57
62
  const personaKeys = opts.personaKeys ? opts.personaKeys.split(",").map((k) => k.trim()) : [];
63
+ const entryImageUrl = opts.image ? await resizeImageToDataUrl(opts.image) : undefined;
58
64
  const data = await apiRequest(baseUrl, token, "POST", "/api/cli/campaigns", {
59
65
  name: opts.name,
60
66
  slug: opts.slug,
@@ -63,6 +69,8 @@ export function campaignsCommand(program) {
63
69
  posterCaption: opts.posterCaption,
64
70
  posterHeadline: opts.posterHeadline,
65
71
  posterBody: opts.posterBody,
72
+ ...(entryImageUrl !== undefined ? { entryImageUrl } : {}),
73
+ ...(opts.imageAlt !== undefined ? { entryImageAlt: opts.imageAlt } : {}),
66
74
  });
67
75
  printJsonOrText(data, () => console.log(`已创建:${data.slug}`));
68
76
  }));
@@ -73,6 +81,8 @@ export function campaignsCommand(program) {
73
81
  .option("--poster-caption <text>")
74
82
  .option("--poster-headline <text>")
75
83
  .option("--poster-body <text>")
84
+ .option("--image <path>", "本地图片路径,自动缩放压缩后替换入口图(entryImageUrl)")
85
+ .option("--image-alt <text>", "入口图的 alt 文本")
76
86
  .option("--active", "启用")
77
87
  .option("--no-active", "停用")
78
88
  .option("--base-url <url>")
@@ -88,6 +98,10 @@ export function campaignsCommand(program) {
88
98
  patch.posterHeadline = opts.posterHeadline;
89
99
  if (opts.posterBody !== undefined)
90
100
  patch.posterBody = opts.posterBody;
101
+ if (opts.image !== undefined)
102
+ patch.entryImageUrl = await resizeImageToDataUrl(opts.image);
103
+ if (opts.imageAlt !== undefined)
104
+ patch.entryImageAlt = opts.imageAlt;
91
105
  if (opts.active !== undefined)
92
106
  patch.active = opts.active;
93
107
  if (Object.keys(patch).length === 0) {
@@ -2,8 +2,10 @@ import { apiRequest } from "../http.js";
2
2
  import { requireSession } from "../session.js";
3
3
  import { jsonMode } from "../output.js";
4
4
  import { action } from "../output.js";
5
+ import { fail, EXIT_CODES } from "../errors.js";
5
6
  export const EVENTS_TAIL_USAGE = ` persona-test-cli events tail
6
- persona-test-cli events tail --slug summer --interval 3`;
7
+ persona-test-cli events tail --slug summer --interval 3
8
+ persona-test-cli events tail --since 2026-08-30T00:00:00Z # 回看历史,再接实时`;
7
9
  const TAIL_USAGE = EVENTS_TAIL_USAGE;
8
10
  function sleep(ms) {
9
11
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -12,17 +14,31 @@ export function eventsCommand(program) {
12
14
  const cmd = program.command("events").description("查看展示/点击事件");
13
15
  cmd
14
16
  .command("tail")
15
- .description("轮询打印新的展示/点击事件,Ctrl+C 退出")
17
+ .description("从现在开始轮询打印新的展示/点击事件,Ctrl+C 退出(要回看历史用 --since)")
16
18
  .option("--slug <slug>", "只看某条分享链接的事件")
17
19
  .option("--interval <seconds>", "轮询间隔秒数", "5")
20
+ .option("--since <iso>", "从这个 ISO 时间点开始回放,再接实时(默认从启动时刻开始)")
18
21
  .option("--base-url <url>")
19
22
  .addHelpText("after", `\n示例:\n${TAIL_USAGE}`)
20
23
  .action(action(async (opts) => {
21
24
  const { baseUrl, token } = requireSession(opts.baseUrl);
22
25
  const intervalMs = Math.max(1, Number(opts.interval) || 5) * 1000;
23
- let since = new Date(0).toISOString();
26
+ // Default to "now", not the epoch: `tail` promises *new* events, and
27
+ // starting at 1970 made the first poll replay up to 50 historical
28
+ // events before showing anything live.
29
+ let since = new Date().toISOString();
30
+ if (opts.since) {
31
+ const parsed = new Date(opts.since);
32
+ if (Number.isNaN(parsed.getTime())) {
33
+ fail(`--since 不是合法的时间: ${opts.since}(用 ISO 格式,如 2026-08-30T00:00:00Z)`, {
34
+ exitCode: EXIT_CODES.validation,
35
+ usage: TAIL_USAGE,
36
+ });
37
+ }
38
+ since = parsed.toISOString();
39
+ }
24
40
  if (!jsonMode) {
25
- console.log(`轮询中(每 ${opts.interval} 秒)... Ctrl+C 退出`);
41
+ console.log(`轮询中(每 ${opts.interval} 秒)... Ctrl+C 退出${opts.since ? ` — 回放起点 ${since}` : ""}`);
26
42
  }
27
43
  for (;;) {
28
44
  const query = new URLSearchParams({ since, limit: "50" });
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- function configDir() {
4
+ export function configDir() {
5
5
  const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
6
6
  return join(base, "persona-test-cli");
7
7
  }
package/dist/image.js ADDED
@@ -0,0 +1,38 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { CliError, EXIT_CODES } from "./errors.js";
3
+ /**
4
+ * Mirrors src/lib/resizeImage.ts (the web dashboard's client-side pipeline):
5
+ * downscale to a fixed width, preserve aspect ratio, re-encode as JPEG, and
6
+ * return a data URL so the server can store it inline (no object storage).
7
+ */
8
+ export async function resizeImageToDataUrl(filePath, targetWidth = 300, quality = 82) {
9
+ let sharp;
10
+ try {
11
+ sharp = (await import("sharp")).default;
12
+ }
13
+ catch {
14
+ throw new CliError("缺少图片处理依赖 `sharp`,请先运行 `npm install` (persona-test-cli 依赖已声明,重新安装即可)", { exitCode: EXIT_CODES.generic });
15
+ }
16
+ let input;
17
+ try {
18
+ input = await readFile(filePath);
19
+ }
20
+ catch (err) {
21
+ throw new CliError(`无法读取图片文件 "${filePath}": ${err instanceof Error ? err.message : String(err)}`, {
22
+ exitCode: EXIT_CODES.validation,
23
+ });
24
+ }
25
+ let jpeg;
26
+ try {
27
+ jpeg = await sharp(input)
28
+ .resize({ width: targetWidth, withoutEnlargement: false })
29
+ .jpeg({ quality })
30
+ .toBuffer();
31
+ }
32
+ catch (err) {
33
+ throw new CliError(`图片处理失败,请确认文件是有效的图片格式: ${err instanceof Error ? err.message : String(err)}`, {
34
+ exitCode: EXIT_CODES.validation,
35
+ });
36
+ }
37
+ return `data:image/jpeg;base64,${jpeg.toString("base64")}`;
38
+ }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
- import { Command } from "commander";
5
+ import { Command, CommanderError } from "commander";
6
6
  import { registerCommand } from "./commands/register.js";
7
7
  import { loginCommand } from "./commands/login.js";
8
8
  import { logoutCommand } from "./commands/logout.js";
@@ -12,7 +12,9 @@ import { personasCommand } from "./commands/personas.js";
12
12
  import { eventsCommand } from "./commands/events.js";
13
13
  import { usersCommand } from "./commands/users.js";
14
14
  import { examplesCommand } from "./commands/examples.js";
15
- import { jsonMode } from "./output.js";
15
+ import { jsonMode, printError } from "./output.js";
16
+ import { CliError, EXIT_CODES } from "./errors.js";
17
+ import { checkForUpdates } from "./updateCheck.js";
16
18
  // Reads package.json's version whether running from source (cli/index.ts,
17
19
  // package.json next to it) or from the built dist/index.js (package.json one
18
20
  // directory up) — avoids a hardcoded version string drifting from package.json.
@@ -30,6 +32,17 @@ function readVersion() {
30
32
  }
31
33
  const version = readVersion();
32
34
  const program = new Command();
35
+ // Commander hardcodes exit code 1 for every usage/argument problem (missing
36
+ // option, unknown flag, bad value) and writes the message to stderr itself.
37
+ // `examples` documents 3 = 参数校验失败, so take over both halves: exitOverride
38
+ // stops commander calling process.exit(1), and configureOutput silences its
39
+ // stderr write so every error flows through printError() — which honours --json
40
+ // and applies the documented exit code.
41
+ //
42
+ // MUST come before the subcommands are registered: commander copies
43
+ // _exitCallback and _outputConfiguration onto a child at addCommand() time,
44
+ // so declaring this at the bottom leaves every subcommand untouched.
45
+ program.exitOverride().configureOutput({ writeErr: () => { } });
33
46
  program
34
47
  .name("persona-test-cli")
35
48
  .description("persona-test 运维 CLI —— admin 和已审核通过的普通用户都能用,普通用户只能看到/编辑自己创建的 campaigns/personas。\n" +
@@ -57,4 +70,19 @@ personasCommand(program);
57
70
  eventsCommand(program);
58
71
  usersCommand(program); // [仅 admin] — see the description on each of its subcommands
59
72
  examplesCommand(program);
60
- program.parseAsync(process.argv);
73
+ program
74
+ .parseAsync(process.argv)
75
+ .catch((err) => {
76
+ if (err instanceof CommanderError) {
77
+ // --help / -v travel through the throw path too, but they are normal exits.
78
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.version")
79
+ return;
80
+ // Commander bakes its own "error: " prefix into some messages; printError
81
+ // adds ours, which would otherwise read "错误: error: unknown command ...".
82
+ const message = err.message.replace(/^error:\s*/i, "");
83
+ printError(new CliError(message, { exitCode: EXIT_CODES.validation }));
84
+ return;
85
+ }
86
+ printError(err);
87
+ })
88
+ .finally(() => checkForUpdates(version, { jsonMode }));
@@ -0,0 +1,75 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configDir } from "./config.js";
4
+ const REGISTRY_URL = "https://registry.npmjs.org/persona-test-cli/latest";
5
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
6
+ const FETCH_TIMEOUT_MS = 2000;
7
+ function cachePath() {
8
+ return join(configDir(), "update-check.json");
9
+ }
10
+ function readCache() {
11
+ try {
12
+ return JSON.parse(readFileSync(cachePath(), "utf8"));
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ function writeCache(cache) {
19
+ try {
20
+ mkdirSync(configDir(), { recursive: true });
21
+ writeFileSync(cachePath(), JSON.stringify(cache));
22
+ }
23
+ catch {
24
+ // best-effort — a stale/missing cache just means the next run re-checks
25
+ }
26
+ }
27
+ /** True if `a` is strictly newer than `b` (both "x.y.z", missing/non-numeric parts treated as 0). */
28
+ function isNewer(a, b) {
29
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
30
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
31
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
32
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
33
+ if (diff !== 0)
34
+ return diff > 0;
35
+ }
36
+ return false;
37
+ }
38
+ async function fetchLatestVersion() {
39
+ const controller = new AbortController();
40
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
41
+ try {
42
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal });
43
+ if (!res.ok)
44
+ return undefined;
45
+ const json = (await res.json());
46
+ return typeof json.version === "string" ? json.version : undefined;
47
+ }
48
+ catch {
49
+ return undefined; // offline / registry unreachable / timed out — silently skip
50
+ }
51
+ finally {
52
+ clearTimeout(timer);
53
+ }
54
+ }
55
+ /**
56
+ * Best-effort "a new version is available" notice, in the spirit of npm's own
57
+ * update-notifier: never throws, never blocks scripting/agent use (skipped
58
+ * under --json, CI, or NO_UPDATE_NOTIFIER), and only hits the network once
59
+ * per CHECK_INTERVAL_MS — everything else reuses the cached result.
60
+ */
61
+ export async function checkForUpdates(currentVersion, opts) {
62
+ if (opts.jsonMode || process.env.CI || process.env.NO_UPDATE_NOTIFIER)
63
+ return;
64
+ const cache = readCache();
65
+ const isStale = !cache || Date.now() - Date.parse(cache.lastCheckedAt) > CHECK_INTERVAL_MS;
66
+ let latestVersion = cache?.latestVersion;
67
+ if (isStale) {
68
+ latestVersion = await fetchLatestVersion();
69
+ writeCache({ lastCheckedAt: new Date().toISOString(), latestVersion });
70
+ }
71
+ if (latestVersion && isNewer(latestVersion, currentVersion)) {
72
+ console.error(`\n有新版本可用: ${currentVersion} -> ${latestVersion}\n` +
73
+ `运行 \`npm install -g persona-test-cli@latest\` 升级(设置 NO_UPDATE_NOTIFIER=1 可关闭此提醒)。`);
74
+ }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persona-test-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "运维 CLI for persona-test — admin 和已审核通过的普通用户都能用;设备码浏览器授权登录,管理分享链接(campaigns)和画像(personas),普通用户只能看到/编辑自己创建的。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,8 @@
18
18
  "dev": "tsx index.ts"
19
19
  },
20
20
  "dependencies": {
21
- "commander": "^15.0.0"
21
+ "commander": "^15.0.0",
22
+ "sharp": "^0.33.0"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/node": "^20",