persona-test-cli 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 persona-test-cli contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # persona-test-cli
2
+
3
+ Ops CLI for a [persona-test](https://github.com/) deployment — admin and approved regular
4
+ users can both use it. A regular user only ever sees/edits campaigns and personas they created
5
+ themselves; admin sees and can edit everything.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g persona-test-cli
11
+ ```
12
+
13
+ ## Getting started
14
+
15
+ ```bash
16
+ # New account: register, then wait for an admin to approve it at /admin/users
17
+ persona-test-cli register https://your-app.example.com
18
+
19
+ # Log in — opens a browser to approve the CLI (device-code flow), no password typed into the terminal
20
+ persona-test-cli login https://your-app.example.com
21
+
22
+ # See everything at once
23
+ persona-test-cli examples
24
+ ```
25
+
26
+ `login` works for both admin (approve with the admin password login) and regular users (approve
27
+ with their email/password login) — whichever session is active in the browser when the
28
+ authorize page loads decides the scope of the issued token.
29
+
30
+ ## Commands
31
+
32
+ | Command | Who | What |
33
+ |---|---|---|
34
+ | `register <baseUrl>` | anyone | Self-register (pending admin approval) |
35
+ | `login <baseUrl>` | anyone | Device-code browser login |
36
+ | `logout` | anyone | Clear the stored token |
37
+ | `whoami` | anyone | Show current identity + available commands |
38
+ | `campaigns list/create/edit/link/stats` | anyone | Share links — scoped to your own unless admin |
39
+ | `personas list/create/edit` | anyone | Ad-copy personas — scoped to your own unless admin |
40
+ | `events tail` | anyone | Poll new impression/click events |
41
+ | `users list/approve/reject` | **admin only** | Registration approval queue |
42
+
43
+ Run any command with `--help` for its full options and copy-pasteable examples.
44
+
45
+ ## Scripting / agent use
46
+
47
+ - `--json` (or `PERSONA_TEST_CLI_JSON=1`): every command prints machine-readable JSON instead of
48
+ a table — success to stdout, `{"error": ..., "usage": ...}` to stderr on failure.
49
+ - Exit codes: `0` ok · `1` unclassified · `2` not logged in / invalid token · `3` bad arguments ·
50
+ `4` not found · `5` forbidden (not yours).
51
+ - `PERSONA_TEST_CLI_TOKEN=<token>`: skip login entirely — set once by a human via the normal
52
+ device-code flow, then exported into an automated environment.
53
+ - `register --email <e> --password <p>`: non-interactive registration (fails fast instead of
54
+ hanging on a prompt when stdin isn't a TTY).
55
+ - `login --no-open`: print the authorize URL instead of trying to launch a browser (headless/
56
+ sandboxed environments).
57
+
58
+ ## License
59
+
60
+ MIT
@@ -0,0 +1,8 @@
1
+ import { exec } from "node:child_process";
2
+ /** Best-effort — callers shouldn't treat a failure to open a browser as fatal, just print the URL instead. */
3
+ export function openBrowser(url) {
4
+ return new Promise((resolve) => {
5
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
6
+ exec(`${cmd} "${url}"`, () => resolve());
7
+ });
8
+ }
@@ -0,0 +1,132 @@
1
+ import { apiRequest } from "../http.js";
2
+ import { requireSession } from "../session.js";
3
+ import { fail, EXIT_CODES } from "../errors.js";
4
+ import { action, printJsonOrText, printTable } from "../output.js";
5
+ export const CAMPAIGNS_LIST_USAGE = ` persona-test-cli campaigns list
6
+ persona-test-cli campaigns list --json`;
7
+ export const CAMPAIGNS_CREATE_USAGE = ` persona-test-cli campaigns create --name "夏季活动" --slug summer --category MyApp
8
+ persona-test-cli campaigns create --name "夏季活动" --slug summer --category MyApp --persona-keys A,B1 --poster-headline "..." --poster-body "..."
9
+
10
+ 注:入口图(entryImageUrl)无法在终端里裁剪压缩,建好后请到网页后台(/dashboard 或 /admin)补传。`;
11
+ export const CAMPAIGNS_EDIT_USAGE = ` persona-test-cli campaigns edit summer --poster-caption "扫码入群"
12
+ persona-test-cli campaigns edit summer --no-active`;
13
+ export const CAMPAIGNS_LINK_USAGE = ` persona-test-cli campaigns link summer
14
+ persona-test-cli campaigns link summer --persona A`;
15
+ export const CAMPAIGNS_STATS_USAGE = ` persona-test-cli campaigns stats summer`;
16
+ const LIST_USAGE = CAMPAIGNS_LIST_USAGE;
17
+ const CREATE_USAGE = CAMPAIGNS_CREATE_USAGE;
18
+ const EDIT_USAGE = CAMPAIGNS_EDIT_USAGE;
19
+ const LINK_USAGE = CAMPAIGNS_LINK_USAGE;
20
+ const STATS_USAGE = CAMPAIGNS_STATS_USAGE;
21
+ export function campaignsCommand(program) {
22
+ const cmd = program.command("campaigns").description("管理分享链接(campaigns)——admin 看到全部,普通用户只看到/编辑自己创建的");
23
+ cmd
24
+ .command("list")
25
+ .description("列出分享链接")
26
+ .option("--base-url <url>")
27
+ .addHelpText("after", `\n示例:\n${LIST_USAGE}`)
28
+ .action(action(async (opts) => {
29
+ const { baseUrl, token } = requireSession(opts.baseUrl);
30
+ const data = await apiRequest(baseUrl, token, "GET", "/api/cli/campaigns");
31
+ const campaigns = data.campaigns;
32
+ printJsonOrText(data, () => printTable(campaigns.map((c) => ({
33
+ slug: String(c.slug),
34
+ name: String(c.name),
35
+ category: String(c.category),
36
+ active: c.active ? "是" : "否",
37
+ owner: String(c.owner ?? "内置/admin"),
38
+ 展示: String(c.impressions),
39
+ 点击: String(c.clicks),
40
+ CTR: `${c.ctr.toFixed(1)}%`,
41
+ }))));
42
+ }));
43
+ cmd
44
+ .command("create")
45
+ .description("创建一条分享链接(v1 仅支持文本字段,入口图请到网页后台补传)")
46
+ .requiredOption("--name <name>", "后台辨识用的名称")
47
+ .requiredOption("--slug <slug>", "分享链接 /s/xxx 里的 xxx,只能用字母/数字/短横线")
48
+ .requiredOption("--category <category>", "所属类别/项目")
49
+ .option("--persona-keys <keys>", "逗号分隔的画像 key(必须是你自己拥有、且属于这个 category 的画像)")
50
+ .option("--poster-caption <text>", "海报二维码下方文案", "扫码入群")
51
+ .option("--poster-headline <text>", "通用海报标题")
52
+ .option("--poster-body <text>", "通用海报正文")
53
+ .option("--base-url <url>")
54
+ .addHelpText("after", `\n示例:\n${CREATE_USAGE}`)
55
+ .action(action(async (opts) => {
56
+ const { baseUrl, token } = requireSession(opts.baseUrl);
57
+ const personaKeys = opts.personaKeys ? opts.personaKeys.split(",").map((k) => k.trim()) : [];
58
+ const data = await apiRequest(baseUrl, token, "POST", "/api/cli/campaigns", {
59
+ name: opts.name,
60
+ slug: opts.slug,
61
+ category: opts.category,
62
+ personaKeys,
63
+ posterCaption: opts.posterCaption,
64
+ posterHeadline: opts.posterHeadline,
65
+ posterBody: opts.posterBody,
66
+ });
67
+ printJsonOrText(data, () => console.log(`已创建:${data.slug}`));
68
+ }));
69
+ cmd
70
+ .command("edit <slug>")
71
+ .description("编辑一条分享链接(只能改自己拥有的,admin 可以改任意一条)")
72
+ .option("--name <name>")
73
+ .option("--poster-caption <text>")
74
+ .option("--poster-headline <text>")
75
+ .option("--poster-body <text>")
76
+ .option("--active", "启用")
77
+ .option("--no-active", "停用")
78
+ .option("--base-url <url>")
79
+ .addHelpText("after", `\n示例:\n${EDIT_USAGE}`)
80
+ .action(action(async (slug, opts) => {
81
+ const { baseUrl, token } = requireSession(opts.baseUrl);
82
+ const patch = {};
83
+ if (opts.name !== undefined)
84
+ patch.name = opts.name;
85
+ if (opts.posterCaption !== undefined)
86
+ patch.posterCaption = opts.posterCaption;
87
+ if (opts.posterHeadline !== undefined)
88
+ patch.posterHeadline = opts.posterHeadline;
89
+ if (opts.posterBody !== undefined)
90
+ patch.posterBody = opts.posterBody;
91
+ if (opts.active !== undefined)
92
+ patch.active = opts.active;
93
+ if (Object.keys(patch).length === 0) {
94
+ fail("至少要传一个要改的字段", { usage: EDIT_USAGE, exitCode: EXIT_CODES.validation });
95
+ }
96
+ const data = await apiRequest(baseUrl, token, "PATCH", `/api/cli/campaigns/${slug}`, patch);
97
+ printJsonOrText(data, () => console.log(`已更新:${slug}`));
98
+ }));
99
+ cmd
100
+ .command("link <slug>")
101
+ .description("打印分享链接;加 --persona 打印锁定某个画像的专属链接")
102
+ .option("--persona <key>", "锁定某个画像的 key")
103
+ .option("--base-url <url>")
104
+ .addHelpText("after", `\n示例:\n${LINK_USAGE}`)
105
+ .action(action(async (slug, opts) => {
106
+ const { baseUrl, token } = requireSession(opts.baseUrl);
107
+ const data = await apiRequest(baseUrl, token, "GET", `/api/cli/campaigns/${slug}`);
108
+ const personas = data.personas;
109
+ if (opts.persona && !personas.some((p) => p.key === opts.persona)) {
110
+ fail(`画像 key "${opts.persona}" 不在这个分享链接里,可选:${personas.map((p) => p.key).join(", ") || "(无)"}`, { usage: LINK_USAGE, exitCode: EXIT_CODES.validation });
111
+ }
112
+ const url = opts.persona
113
+ ? `${baseUrl}/s/${slug}?p=${encodeURIComponent(opts.persona)}`
114
+ : `${baseUrl}/s/${slug}`;
115
+ printJsonOrText({ url }, () => console.log(url));
116
+ }));
117
+ cmd
118
+ .command("stats <slug>")
119
+ .description("查看一条分享链接的展示/点击/CTR")
120
+ .option("--base-url <url>")
121
+ .addHelpText("after", `\n示例:\n${STATS_USAGE}`)
122
+ .action(action(async (slug, opts) => {
123
+ const { baseUrl, token } = requireSession(opts.baseUrl);
124
+ const data = await apiRequest(baseUrl, token, "GET", `/api/cli/campaigns/${slug}`);
125
+ printJsonOrText(data, () => {
126
+ console.log(`${data.name}(${data.slug}) · ${data.category}`);
127
+ console.log(`归属:${data.owner ?? "内置/admin"} · ${data.active ? "启用" : "停用"}`);
128
+ console.log(`展示 ${data.impressions} · 点击 ${data.clicks} · CTR ${data.ctr.toFixed(1)}%`);
129
+ console.log(`${data.personas.length} 个关联画像`);
130
+ });
131
+ }));
132
+ }
@@ -0,0 +1,46 @@
1
+ import { apiRequest } from "../http.js";
2
+ import { requireSession } from "../session.js";
3
+ import { jsonMode } from "../output.js";
4
+ import { action } from "../output.js";
5
+ export const EVENTS_TAIL_USAGE = ` persona-test-cli events tail
6
+ persona-test-cli events tail --slug summer --interval 3`;
7
+ const TAIL_USAGE = EVENTS_TAIL_USAGE;
8
+ function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ export function eventsCommand(program) {
12
+ const cmd = program.command("events").description("查看展示/点击事件");
13
+ cmd
14
+ .command("tail")
15
+ .description("轮询打印新的展示/点击事件,Ctrl+C 退出")
16
+ .option("--slug <slug>", "只看某条分享链接的事件")
17
+ .option("--interval <seconds>", "轮询间隔秒数", "5")
18
+ .option("--base-url <url>")
19
+ .addHelpText("after", `\n示例:\n${TAIL_USAGE}`)
20
+ .action(action(async (opts) => {
21
+ const { baseUrl, token } = requireSession(opts.baseUrl);
22
+ const intervalMs = Math.max(1, Number(opts.interval) || 5) * 1000;
23
+ let since = new Date(0).toISOString();
24
+ if (!jsonMode) {
25
+ console.log(`轮询中(每 ${opts.interval} 秒)... Ctrl+C 退出`);
26
+ }
27
+ for (;;) {
28
+ const query = new URLSearchParams({ since, limit: "50" });
29
+ if (opts.slug)
30
+ query.set("slug", opts.slug);
31
+ const data = await apiRequest(baseUrl, token, "GET", `/api/cli/events?${query}`);
32
+ const events = data.events;
33
+ since = data.nextSince;
34
+ for (const e of events) {
35
+ if (jsonMode) {
36
+ console.log(JSON.stringify(e));
37
+ }
38
+ else {
39
+ const label = e.type === "click" ? "点击" : "展示";
40
+ console.log(`[${e.createdAt}] ${label} ${e.campaignSlug} · ${e.personaCategory}/${e.personaKey} · ${e.deviceType ?? "未知"}${e.isWeChat ? " · 微信内" : ""}`);
41
+ }
42
+ }
43
+ await sleep(intervalMs);
44
+ }
45
+ }));
46
+ }
@@ -0,0 +1,39 @@
1
+ import { REGISTER_EXAMPLES } from "./register.js";
2
+ import { LOGIN_EXAMPLES } from "./login.js";
3
+ import { LOGOUT_EXAMPLES } from "./logout.js";
4
+ import { WHOAMI_EXAMPLES } from "./whoami.js";
5
+ import { CAMPAIGNS_LIST_USAGE, CAMPAIGNS_CREATE_USAGE, CAMPAIGNS_EDIT_USAGE, CAMPAIGNS_LINK_USAGE, CAMPAIGNS_STATS_USAGE, } from "./campaigns.js";
6
+ import { PERSONAS_LIST_USAGE, PERSONAS_CREATE_USAGE, PERSONAS_EDIT_USAGE } from "./personas.js";
7
+ import { EVENTS_TAIL_USAGE } from "./events.js";
8
+ import { USERS_LIST_USAGE, USERS_APPROVE_USAGE, USERS_REJECT_USAGE } from "./users.js";
9
+ const SECTIONS = [
10
+ ["register", REGISTER_EXAMPLES],
11
+ ["login", LOGIN_EXAMPLES],
12
+ ["logout", LOGOUT_EXAMPLES],
13
+ ["whoami", WHOAMI_EXAMPLES],
14
+ ["campaigns list", CAMPAIGNS_LIST_USAGE],
15
+ ["campaigns create", CAMPAIGNS_CREATE_USAGE],
16
+ ["campaigns edit", CAMPAIGNS_EDIT_USAGE],
17
+ ["campaigns link", CAMPAIGNS_LINK_USAGE],
18
+ ["campaigns stats", CAMPAIGNS_STATS_USAGE],
19
+ ["personas list", PERSONAS_LIST_USAGE],
20
+ ["personas create", PERSONAS_CREATE_USAGE],
21
+ ["personas edit", PERSONAS_EDIT_USAGE],
22
+ ["events tail", EVENTS_TAIL_USAGE],
23
+ ["users list [仅 admin]", USERS_LIST_USAGE],
24
+ ["users approve [仅 admin]", USERS_APPROVE_USAGE],
25
+ ["users reject [仅 admin]", USERS_REJECT_USAGE],
26
+ ];
27
+ export function examplesCommand(program) {
28
+ program
29
+ .command("examples")
30
+ .description("一次性打印所有子命令的示例(agent 第一次用之前可以先跑这个)")
31
+ .action(() => {
32
+ console.log("退出码约定:0 成功 · 1 未分类错误 · 2 未登录/token 失效 · 3 参数校验失败 · 4 不存在 · 5 无权限\n");
33
+ for (const [name, examples] of SECTIONS) {
34
+ console.log(`# ${name}`);
35
+ console.log(examples);
36
+ console.log("");
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,62 @@
1
+ import { publicRequest, apiRequest } from "../http.js";
2
+ import { openBrowser } from "../browser.js";
3
+ import { fail, EXIT_CODES } from "../errors.js";
4
+ import { action, printJsonOrText } from "../output.js";
5
+ import { normalizeBaseUrl, saveProfile } from "../config.js";
6
+ export const LOGIN_EXAMPLES = ` persona-test-cli login https://your-app.example.com
7
+ persona-test-cli login https://your-app.example.com --no-open
8
+ persona-test-cli login https://your-app.example.com --token <已有的 token,跳过整个设备码流程>`;
9
+ const POLL_INTERVAL_MS = 2000;
10
+ function sleep(ms) {
11
+ return new Promise((resolve) => setTimeout(resolve, ms));
12
+ }
13
+ export function loginCommand(program) {
14
+ program
15
+ .command("login <baseUrl>")
16
+ .description("设备码登录:自动打开浏览器授权,或用 --token 直接注入一个已有 token")
17
+ .option("--no-open", "不自动打开浏览器,只打印授权链接(无 GUI/沙盒环境用)")
18
+ .option("--wait <seconds>", "轮询超时秒数", "300")
19
+ .option("--token <token>", "跳过设备码流程,直接用这个 token(会先校验有效性)")
20
+ .addHelpText("after", `\n示例:\n${LOGIN_EXAMPLES}`)
21
+ .action(action(async (baseUrlArg, opts) => {
22
+ const baseUrl = normalizeBaseUrl(baseUrlArg);
23
+ if (opts.token) {
24
+ const who = await apiRequest(baseUrl, opts.token, "GET", "/api/cli/whoami");
25
+ const scope = who.scope;
26
+ const email = who.email;
27
+ saveProfile(baseUrl, { token: opts.token, scope, email, savedAt: new Date().toISOString() });
28
+ printJsonOrText({ ok: true, scope, email }, () => {
29
+ console.log(`已登录(${scope === "admin" ? "管理员" : email})`);
30
+ });
31
+ return;
32
+ }
33
+ const start = await publicRequest(baseUrl, "POST", "/api/auth/device/start");
34
+ const code = start.code;
35
+ const fullUrl = `${baseUrl}${start.authorizeUrl}`;
36
+ console.error(`请在浏览器里完成授权:${fullUrl}`);
37
+ if (opts.open) {
38
+ await openBrowser(fullUrl);
39
+ }
40
+ const timeoutMs = Math.max(10, Number(opts.wait) || 300) * 1000;
41
+ const deadline = Date.now() + timeoutMs;
42
+ while (Date.now() < deadline) {
43
+ const poll = await publicRequest(baseUrl, "GET", `/api/auth/device/poll?code=${code}`);
44
+ const status = poll.status;
45
+ if (status === "approved") {
46
+ const token = poll.token;
47
+ const scope = poll.scope;
48
+ const email = poll.email;
49
+ saveProfile(baseUrl, { token, scope, email, savedAt: new Date().toISOString() });
50
+ printJsonOrText({ ok: true, scope, email }, () => {
51
+ console.log(`已登录(${scope === "admin" ? "管理员" : email})`);
52
+ });
53
+ return;
54
+ }
55
+ if (status === "denied") {
56
+ fail("授权请求被拒绝", { exitCode: EXIT_CODES.forbidden });
57
+ }
58
+ await sleep(POLL_INTERVAL_MS);
59
+ }
60
+ fail(`等待授权超时(${opts.wait} 秒),请重新运行 login`, { usage: LOGIN_EXAMPLES });
61
+ }));
62
+ }
@@ -0,0 +1,23 @@
1
+ import { fail, EXIT_CODES } from "../errors.js";
2
+ import { action, printJsonOrText } from "../output.js";
3
+ import { normalizeBaseUrl, resolveBaseUrl, clearProfile } from "../config.js";
4
+ export const LOGOUT_EXAMPLES = ` persona-test-cli logout
5
+ persona-test-cli logout --base-url https://your-app.example.com`;
6
+ export function logoutCommand(program) {
7
+ program
8
+ .command("logout")
9
+ .description("清除本地存的登录 token")
10
+ .option("--base-url <url>", "默认清除上次登录的那个 base url")
11
+ .addHelpText("after", `\n示例:\n${LOGOUT_EXAMPLES}`)
12
+ .action(action(async (opts) => {
13
+ const baseUrl = resolveBaseUrl(opts.baseUrl);
14
+ if (!baseUrl) {
15
+ fail("没有已登录的 base url,加 --base-url 指定一个", {
16
+ usage: LOGOUT_EXAMPLES,
17
+ exitCode: EXIT_CODES.validation,
18
+ });
19
+ }
20
+ clearProfile(normalizeBaseUrl(baseUrl));
21
+ printJsonOrText({ ok: true, baseUrl }, () => console.log(`已退出登录:${baseUrl}`));
22
+ }));
23
+ }
@@ -0,0 +1,93 @@
1
+ import { apiRequest } from "../http.js";
2
+ import { requireSession } from "../session.js";
3
+ import { fail, EXIT_CODES } from "../errors.js";
4
+ import { action, printJsonOrText, printTable } from "../output.js";
5
+ export const PERSONAS_LIST_USAGE = ` persona-test-cli personas list
6
+ persona-test-cli personas list --category MyApp --json`;
7
+ export const PERSONAS_CREATE_USAGE = ` persona-test-cli personas create --category MyApp --key A --title "核心契合" --headline "..." --body "..."`;
8
+ export const PERSONAS_EDIT_USAGE = ` persona-test-cli personas edit --category MyApp --key A --title "新标题"
9
+ persona-test-cli personas edit --category MyApp --key A --no-active`;
10
+ const LIST_USAGE = PERSONAS_LIST_USAGE;
11
+ const CREATE_USAGE = PERSONAS_CREATE_USAGE;
12
+ const EDIT_USAGE = PERSONAS_EDIT_USAGE;
13
+ export function personasCommand(program) {
14
+ const cmd = program.command("personas").description("管理画像(personas)——admin 看到全部,普通用户只看到/编辑自己创建的");
15
+ cmd
16
+ .command("list")
17
+ .description("列出画像")
18
+ .option("--category <category>", "只看某个类别")
19
+ .option("--base-url <url>")
20
+ .addHelpText("after", `\n示例:\n${LIST_USAGE}`)
21
+ .action(action(async (opts) => {
22
+ const { baseUrl, token } = requireSession(opts.baseUrl);
23
+ const query = opts.category ? `?category=${encodeURIComponent(opts.category)}` : "";
24
+ const data = await apiRequest(baseUrl, token, "GET", `/api/cli/personas${query}`);
25
+ const personas = data.personas;
26
+ printJsonOrText(data, () => printTable(personas.map((p) => ({
27
+ category: String(p.category),
28
+ key: String(p.key),
29
+ title: String(p.title),
30
+ active: p.active ? "是" : "否",
31
+ weight: String(p.weight),
32
+ owner: String(p.owner ?? "内置/admin"),
33
+ }))));
34
+ }));
35
+ cmd
36
+ .command("create")
37
+ .description("创建一个画像")
38
+ .requiredOption("--category <category>")
39
+ .requiredOption("--key <key>", "同一类别内唯一")
40
+ .requiredOption("--title <title>")
41
+ .requiredOption("--headline <headline>", "文案标题")
42
+ .requiredOption("--body <body>", "文案正文")
43
+ .option("--weight <n>", "抽取权重,默认 1", "1")
44
+ .option("--base-url <url>")
45
+ .addHelpText("after", `\n示例:\n${CREATE_USAGE}`)
46
+ .action(action(async (opts) => {
47
+ const { baseUrl, token } = requireSession(opts.baseUrl);
48
+ const data = await apiRequest(baseUrl, token, "POST", "/api/cli/personas", {
49
+ category: opts.category,
50
+ key: opts.key,
51
+ title: opts.title,
52
+ headline: opts.headline,
53
+ body: opts.body,
54
+ weight: Number(opts.weight) || 1,
55
+ });
56
+ printJsonOrText(data, () => console.log(`已创建:${opts.category}/${opts.key}`));
57
+ }));
58
+ cmd
59
+ .command("edit")
60
+ .description("编辑一个画像(只能改自己拥有的,admin 可以改任意一个)")
61
+ .requiredOption("--category <category>", "定位用")
62
+ .requiredOption("--key <key>", "定位用")
63
+ .option("--title <title>")
64
+ .option("--headline <headline>")
65
+ .option("--body <body>")
66
+ .option("--weight <n>")
67
+ .option("--active")
68
+ .option("--no-active")
69
+ .option("--base-url <url>")
70
+ .addHelpText("after", `\n示例:\n${EDIT_USAGE}`)
71
+ .action(action(async (opts) => {
72
+ const { baseUrl, token } = requireSession(opts.baseUrl);
73
+ const patch = { category: opts.category, key: opts.key };
74
+ if (opts.title !== undefined)
75
+ patch.title = opts.title;
76
+ if (opts.headline !== undefined)
77
+ patch.headline = opts.headline;
78
+ if (opts.body !== undefined)
79
+ patch.body = opts.body;
80
+ if (opts.weight !== undefined)
81
+ patch.weight = Number(opts.weight) || 1;
82
+ if (opts.active !== undefined)
83
+ patch.active = opts.active;
84
+ if (Object.keys(patch).length <= 2) {
85
+ fail("至少要传一个要改的字段(title/headline/body/weight/active)", {
86
+ usage: EDIT_USAGE,
87
+ exitCode: EXIT_CODES.validation,
88
+ });
89
+ }
90
+ const data = await apiRequest(baseUrl, token, "PATCH", "/api/cli/personas", patch);
91
+ printJsonOrText(data, () => console.log(`已更新:${opts.category}/${opts.key}`));
92
+ }));
93
+ }
@@ -0,0 +1,35 @@
1
+ import { publicRequest } from "../http.js";
2
+ import { isInteractive, promptText, promptHidden } from "../prompt.js";
3
+ import { fail, EXIT_CODES } from "../errors.js";
4
+ import { action, printJsonOrText } from "../output.js";
5
+ import { normalizeBaseUrl } from "../config.js";
6
+ export const REGISTER_EXAMPLES = ` persona-test-cli register https://your-app.example.com
7
+ persona-test-cli register https://your-app.example.com --email me@example.com --password ******** --json`;
8
+ export function registerCommand(program) {
9
+ program
10
+ .command("register <baseUrl>")
11
+ .description("自助注册一个普通用户账号(注册后需要 admin 在 /admin/users 审核通过才能登录)")
12
+ .option("--email <email>", "邮箱(作为用户名)")
13
+ .option("--password <password>", "密码(至少 8 位)")
14
+ .addHelpText("after", `\n示例:\n${REGISTER_EXAMPLES}`)
15
+ .action(action(async (baseUrlArg, opts) => {
16
+ const baseUrl = normalizeBaseUrl(baseUrlArg);
17
+ let email = opts.email;
18
+ let password = opts.password;
19
+ if (!email || !password) {
20
+ if (!isInteractive()) {
21
+ fail("非交互式环境需要传 --email 和 --password", {
22
+ usage: REGISTER_EXAMPLES,
23
+ exitCode: EXIT_CODES.validation,
24
+ });
25
+ }
26
+ email ??= await promptText("邮箱: ");
27
+ password ??= await promptHidden("密码(至少 8 位): ");
28
+ }
29
+ await publicRequest(baseUrl, "POST", "/api/auth/register", { email, password });
30
+ printJsonOrText({ ok: true, email, status: "pending" }, () => {
31
+ console.log(`注册成功:${email}`);
32
+ console.log("等待管理员审核通过后即可登录(admin 在 /admin/users 里操作)。");
33
+ });
34
+ }));
35
+ }
@@ -0,0 +1,56 @@
1
+ import { apiRequest } from "../http.js";
2
+ import { requireSession } from "../session.js";
3
+ import { action, printJsonOrText, printTable } from "../output.js";
4
+ export const USERS_LIST_USAGE = ` persona-test-cli users list
5
+ persona-test-cli users list --status pending`;
6
+ export const USERS_APPROVE_USAGE = ` persona-test-cli users approve <userId>`;
7
+ export const USERS_REJECT_USAGE = ` persona-test-cli users reject <userId>`;
8
+ const LIST_USAGE = USERS_LIST_USAGE;
9
+ const APPROVE_USAGE = USERS_APPROVE_USAGE;
10
+ const REJECT_USAGE = USERS_REJECT_USAGE;
11
+ /** [仅 admin] — a non-admin token gets a clear 403 from /api/cli/users*, surfaced as a normal CliError. */
12
+ export function usersCommand(program) {
13
+ const cmd = program.command("users").description("[仅 admin] 审核注册申请");
14
+ cmd
15
+ .command("list")
16
+ .description("[仅 admin] 列出注册用户")
17
+ .option("--status <status>", "pending | active | rejected")
18
+ .option("--base-url <url>")
19
+ .addHelpText("after", `\n示例:\n${LIST_USAGE}`)
20
+ .action(action(async (opts) => {
21
+ const { baseUrl, token } = requireSession(opts.baseUrl);
22
+ const query = opts.status ? `?status=${encodeURIComponent(opts.status)}` : "";
23
+ const data = await apiRequest(baseUrl, token, "GET", `/api/cli/users${query}`);
24
+ const users = data.users;
25
+ printJsonOrText(data, () => printTable(users.map((u) => ({
26
+ id: String(u.id),
27
+ email: String(u.email),
28
+ status: String(u.status),
29
+ 注册于: String(u.createdAt),
30
+ }))));
31
+ }));
32
+ cmd
33
+ .command("approve <userId>")
34
+ .description("[仅 admin] 批准一个待审核用户")
35
+ .option("--base-url <url>")
36
+ .addHelpText("after", `\n示例:\n${APPROVE_USAGE}`)
37
+ .action(action(async (userId, opts) => {
38
+ const { baseUrl, token } = requireSession(opts.baseUrl);
39
+ const data = await apiRequest(baseUrl, token, "PATCH", `/api/cli/users/${userId}`, {
40
+ status: "active",
41
+ });
42
+ printJsonOrText(data, () => console.log(`已批准:${userId}`));
43
+ }));
44
+ cmd
45
+ .command("reject <userId>")
46
+ .description("[仅 admin] 拒绝一个待审核用户")
47
+ .option("--base-url <url>")
48
+ .addHelpText("after", `\n示例:\n${REJECT_USAGE}`)
49
+ .action(action(async (userId, opts) => {
50
+ const { baseUrl, token } = requireSession(opts.baseUrl);
51
+ const data = await apiRequest(baseUrl, token, "PATCH", `/api/cli/users/${userId}`, {
52
+ status: "rejected",
53
+ });
54
+ printJsonOrText(data, () => console.log(`已拒绝:${userId}`));
55
+ }));
56
+ }
@@ -0,0 +1,26 @@
1
+ import { apiRequest } from "../http.js";
2
+ import { requireSession } from "../session.js";
3
+ import { action, printJsonOrText } from "../output.js";
4
+ export const WHOAMI_EXAMPLES = ` persona-test-cli whoami
5
+ persona-test-cli whoami --base-url https://your-app.example.com --json`;
6
+ export function whoamiCommand(program) {
7
+ program
8
+ .command("whoami")
9
+ .description("查看当前登录身份;登录成功时也会自动打印一次角色和可用命令")
10
+ .option("--base-url <url>", "默认用上次登录的 base url")
11
+ .addHelpText("after", `\n示例:\n${WHOAMI_EXAMPLES}`)
12
+ .action(action(async (opts) => {
13
+ const { baseUrl, token } = requireSession(opts.baseUrl);
14
+ const who = await apiRequest(baseUrl, token, "GET", "/api/cli/whoami");
15
+ const scope = who.scope;
16
+ const email = who.email;
17
+ const commands = scope === "admin"
18
+ ? "campaigns, personas, events, users"
19
+ : "campaigns, personas, events";
20
+ printJsonOrText({ baseUrl, scope, email }, () => {
21
+ console.log(`身份: ${scope === "admin" ? "管理员" : email}`);
22
+ console.log(`base url: ${baseUrl}`);
23
+ console.log(`可用命令: ${commands}`);
24
+ });
25
+ }));
26
+ }
package/dist/config.js ADDED
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ function configDir() {
5
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
6
+ return join(base, "persona-test-cli");
7
+ }
8
+ function configPath() {
9
+ return join(configDir(), "config.json");
10
+ }
11
+ function readConfig() {
12
+ try {
13
+ return JSON.parse(readFileSync(configPath(), "utf8"));
14
+ }
15
+ catch {
16
+ return { profiles: {} };
17
+ }
18
+ }
19
+ function writeConfig(config) {
20
+ mkdirSync(configDir(), { recursive: true });
21
+ writeFileSync(configPath(), JSON.stringify(config, null, 2), { mode: 0o600 });
22
+ }
23
+ export function normalizeBaseUrl(url) {
24
+ return url.replace(/\/+$/, "");
25
+ }
26
+ export function saveProfile(baseUrl, profile) {
27
+ const config = readConfig();
28
+ config.profiles[normalizeBaseUrl(baseUrl)] = profile;
29
+ config.lastBaseUrl = normalizeBaseUrl(baseUrl);
30
+ writeConfig(config);
31
+ }
32
+ export function clearProfile(baseUrl) {
33
+ const config = readConfig();
34
+ delete config.profiles[normalizeBaseUrl(baseUrl)];
35
+ writeConfig(config);
36
+ }
37
+ export function getProfile(baseUrl) {
38
+ return readConfig().profiles[normalizeBaseUrl(baseUrl)];
39
+ }
40
+ /** Explicit --base-url wins; then PERSONA_TEST_CLI_URL; then whichever base URL was last logged into. */
41
+ export function resolveBaseUrl(explicit) {
42
+ if (explicit)
43
+ return normalizeBaseUrl(explicit);
44
+ if (process.env.PERSONA_TEST_CLI_URL)
45
+ return normalizeBaseUrl(process.env.PERSONA_TEST_CLI_URL);
46
+ return readConfig().lastBaseUrl;
47
+ }
48
+ /** PERSONA_TEST_CLI_TOKEN always wins (agent-friendly: skip login entirely) — falls back to the stored profile. */
49
+ export function resolveToken(baseUrl) {
50
+ if (process.env.PERSONA_TEST_CLI_TOKEN)
51
+ return process.env.PERSONA_TEST_CLI_TOKEN;
52
+ return getProfile(baseUrl)?.token;
53
+ }
54
+ export function configFileExists() {
55
+ return existsSync(configPath());
56
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,23 @@
1
+ // Exit code convention (documented in `persona-test-cli examples` and each
2
+ // command's --help): agents/scripts can branch on the code alone without
3
+ // parsing text.
4
+ export const EXIT_CODES = {
5
+ ok: 0,
6
+ generic: 1,
7
+ unauthorized: 2,
8
+ validation: 3,
9
+ notFound: 4,
10
+ forbidden: 5,
11
+ };
12
+ export class CliError extends Error {
13
+ exitCode;
14
+ usage;
15
+ constructor(message, opts = {}) {
16
+ super(message);
17
+ this.exitCode = opts.exitCode ?? EXIT_CODES.generic;
18
+ this.usage = opts.usage;
19
+ }
20
+ }
21
+ export function fail(message, opts = {}) {
22
+ throw new CliError(message, opts);
23
+ }
package/dist/http.js ADDED
@@ -0,0 +1,71 @@
1
+ import { CliError, EXIT_CODES } from "./errors.js";
2
+ function exitCodeForStatus(status) {
3
+ if (status === 401)
4
+ return EXIT_CODES.unauthorized;
5
+ if (status === 403)
6
+ return EXIT_CODES.forbidden;
7
+ if (status === 404)
8
+ return EXIT_CODES.notFound;
9
+ if (status === 400 || status === 409 || status === 410)
10
+ return EXIT_CODES.validation;
11
+ return EXIT_CODES.generic;
12
+ }
13
+ export async function apiRequest(baseUrl, token, method, path, body) {
14
+ if (!token) {
15
+ throw new CliError("未登录,请先运行 `persona-test-cli login <baseUrl>`", {
16
+ exitCode: EXIT_CODES.unauthorized,
17
+ });
18
+ }
19
+ let res;
20
+ try {
21
+ res = await fetch(`${baseUrl}${path}`, {
22
+ method,
23
+ headers: {
24
+ Authorization: `Bearer ${token}`,
25
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
26
+ },
27
+ body: body !== undefined ? JSON.stringify(body) : undefined,
28
+ });
29
+ }
30
+ catch (err) {
31
+ throw new CliError(`无法连接到 ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
32
+ }
33
+ let json = null;
34
+ try {
35
+ json = (await res.json());
36
+ }
37
+ catch {
38
+ // Non-JSON response body — fall through with json=null.
39
+ }
40
+ if (!res.ok) {
41
+ const message = json?.error ?? `请求失败(HTTP ${res.status})`;
42
+ throw new CliError(message, { exitCode: exitCodeForStatus(res.status) });
43
+ }
44
+ return json ?? {};
45
+ }
46
+ /** Unauthenticated request — used only by the register/device-flow endpoints under /api/auth/*. */
47
+ export async function publicRequest(baseUrl, method, path, body) {
48
+ let res;
49
+ try {
50
+ res = await fetch(`${baseUrl}${path}`, {
51
+ method,
52
+ headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
53
+ body: body !== undefined ? JSON.stringify(body) : undefined,
54
+ });
55
+ }
56
+ catch (err) {
57
+ throw new CliError(`无法连接到 ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
58
+ }
59
+ let json = null;
60
+ try {
61
+ json = (await res.json());
62
+ }
63
+ catch {
64
+ // ignore
65
+ }
66
+ if (!res.ok) {
67
+ const message = json?.error ?? `请求失败(HTTP ${res.status})`;
68
+ throw new CliError(message, { exitCode: exitCodeForStatus(res.status) });
69
+ }
70
+ return json ?? {};
71
+ }
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { registerCommand } from "./commands/register.js";
4
+ import { loginCommand } from "./commands/login.js";
5
+ import { logoutCommand } from "./commands/logout.js";
6
+ import { whoamiCommand } from "./commands/whoami.js";
7
+ import { campaignsCommand } from "./commands/campaigns.js";
8
+ import { personasCommand } from "./commands/personas.js";
9
+ import { eventsCommand } from "./commands/events.js";
10
+ import { usersCommand } from "./commands/users.js";
11
+ import { examplesCommand } from "./commands/examples.js";
12
+ const program = new Command();
13
+ program
14
+ .name("persona-test-cli")
15
+ .description("persona-test 运维 CLI —— admin 和已审核通过的普通用户都能用,普通用户只能看到/编辑自己创建的 campaigns/personas。\n" +
16
+ "先跑一次 `persona-test-cli examples` 看全部子命令的可复制示例。")
17
+ .option("--json", "所有命令输出纯 JSON(成功到 stdout,失败到 stderr),适合脚本/agent 调用")
18
+ .configureHelp({ sortSubcommands: true });
19
+ registerCommand(program);
20
+ loginCommand(program);
21
+ logoutCommand(program);
22
+ whoamiCommand(program);
23
+ campaignsCommand(program);
24
+ personasCommand(program);
25
+ eventsCommand(program);
26
+ usersCommand(program); // [仅 admin] — see the description on each of its subcommands
27
+ examplesCommand(program);
28
+ program.parseAsync(process.argv);
package/dist/output.js ADDED
@@ -0,0 +1,64 @@
1
+ import { CliError, EXIT_CODES } from "./errors.js";
2
+ // Checked directly against argv/env rather than through commander's option
3
+ // inheritance — simpler to get right, and needs to be readable from any
4
+ // command module without threading commander's Command instance around.
5
+ export const jsonMode = process.argv.includes("--json") || process.env.PERSONA_TEST_CLI_JSON === "1";
6
+ export function printJsonOrText(data, text) {
7
+ if (jsonMode) {
8
+ console.log(JSON.stringify(data));
9
+ }
10
+ else {
11
+ text();
12
+ }
13
+ }
14
+ export function printError(err) {
15
+ if (err instanceof CliError) {
16
+ if (jsonMode) {
17
+ console.error(JSON.stringify({ error: err.message, usage: err.usage }));
18
+ }
19
+ else {
20
+ console.error(`错误: ${err.message}`);
21
+ if (err.usage) {
22
+ console.error(`\n用法示例:\n${err.usage}`);
23
+ }
24
+ else {
25
+ console.error("\n加 --help 查看完整参数说明。");
26
+ }
27
+ }
28
+ process.exitCode = err.exitCode;
29
+ return;
30
+ }
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ if (jsonMode) {
33
+ console.error(JSON.stringify({ error: message }));
34
+ }
35
+ else {
36
+ console.error(`错误: ${message}`);
37
+ }
38
+ process.exitCode = EXIT_CODES.generic;
39
+ }
40
+ /** Wraps a command action so thrown CliErrors (or anything else) are printed uniformly and set the right exit code. */
41
+ export function action(fn) {
42
+ return async (...args) => {
43
+ try {
44
+ await fn(...args);
45
+ }
46
+ catch (err) {
47
+ printError(err);
48
+ }
49
+ };
50
+ }
51
+ export function printTable(rows) {
52
+ if (rows.length === 0) {
53
+ console.log("(无数据)");
54
+ return;
55
+ }
56
+ const columns = Object.keys(rows[0]);
57
+ const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => (r[c] ?? "").length)));
58
+ const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ");
59
+ console.log(line(columns));
60
+ console.log(line(widths.map((w) => "-".repeat(w))));
61
+ for (const row of rows) {
62
+ console.log(line(columns.map((c) => row[c] ?? "")));
63
+ }
64
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,45 @@
1
+ import readline from "node:readline";
2
+ export function isInteractive() {
3
+ return Boolean(process.stdin.isTTY);
4
+ }
5
+ export function promptText(question) {
6
+ return new Promise((resolve) => {
7
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
8
+ rl.question(question, (answer) => {
9
+ rl.close();
10
+ resolve(answer.trim());
11
+ });
12
+ });
13
+ }
14
+ /** Hand-rolled masked input (no readline echo suppression built in) — no extra dependency needed for this. */
15
+ export function promptHidden(question) {
16
+ return new Promise((resolve) => {
17
+ const stdin = process.stdin;
18
+ const stdout = process.stdout;
19
+ stdout.write(question);
20
+ stdin.resume();
21
+ stdin.setRawMode?.(true);
22
+ stdin.setEncoding("utf8");
23
+ let input = "";
24
+ function onData(char) {
25
+ const code = char.charCodeAt(0);
26
+ if (char === "\n" || char === "\r" || code === 4) {
27
+ stdin.setRawMode?.(false);
28
+ stdin.pause();
29
+ stdin.removeListener("data", onData);
30
+ stdout.write("\n");
31
+ resolve(input);
32
+ return;
33
+ }
34
+ if (code === 3) {
35
+ process.exit(130); // Ctrl+C
36
+ }
37
+ if (code === 127 || code === 8) {
38
+ input = input.slice(0, -1);
39
+ return;
40
+ }
41
+ input += char;
42
+ }
43
+ stdin.on("data", onData);
44
+ });
45
+ }
@@ -0,0 +1,18 @@
1
+ import { fail, EXIT_CODES } from "./errors.js";
2
+ import { resolveBaseUrl, resolveToken } from "./config.js";
3
+ /** Shared by every data command (campaigns/personas/events/users/whoami) — resolves --base-url + token, or fails with a clear "please login" message. */
4
+ export function requireSession(baseUrlOpt) {
5
+ const baseUrl = resolveBaseUrl(baseUrlOpt);
6
+ if (!baseUrl) {
7
+ fail("不知道要连哪个地址,加 --base-url <url> 或先运行 `persona-test-cli login <baseUrl>`", {
8
+ exitCode: EXIT_CODES.validation,
9
+ });
10
+ }
11
+ const token = resolveToken(baseUrl);
12
+ if (!token) {
13
+ fail(`未登录 ${baseUrl},请先运行 \`persona-test-cli login ${baseUrl}\``, {
14
+ exitCode: EXIT_CODES.unauthorized,
15
+ });
16
+ }
17
+ return { baseUrl, token };
18
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "persona-test-cli",
3
+ "version": "0.1.0",
4
+ "description": "运维 CLI for persona-test — admin 和已审核通过的普通用户都能用;设备码浏览器授权登录,管理分享链接(campaigns)和画像(personas),普通用户只能看到/编辑自己创建的。",
5
+ "type": "module",
6
+ "bin": {
7
+ "persona-test-cli": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
17
+ "prepublishOnly": "npm run build",
18
+ "dev": "tsx index.ts"
19
+ },
20
+ "dependencies": {
21
+ "commander": "^15.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^20",
25
+ "tsx": "^4.23.12",
26
+ "typescript": "^5"
27
+ },
28
+ "license": "MIT"
29
+ }