persona-test-cli 0.1.4 → 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 +4 -0
- package/dist/commands/campaigns.js +16 -2
- package/dist/config.js +1 -1
- package/dist/image.js +38 -0
- package/dist/index.js +6 -2
- package/dist/updateCheck.js +75 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ 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
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 |
|
|
@@ -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
|
-
|
|
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) {
|
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
|
@@ -14,6 +14,7 @@ import { usersCommand } from "./commands/users.js";
|
|
|
14
14
|
import { examplesCommand } from "./commands/examples.js";
|
|
15
15
|
import { jsonMode, printError } from "./output.js";
|
|
16
16
|
import { CliError, EXIT_CODES } from "./errors.js";
|
|
17
|
+
import { checkForUpdates } from "./updateCheck.js";
|
|
17
18
|
// Reads package.json's version whether running from source (cli/index.ts,
|
|
18
19
|
// package.json next to it) or from the built dist/index.js (package.json one
|
|
19
20
|
// directory up) — avoids a hardcoded version string drifting from package.json.
|
|
@@ -69,7 +70,9 @@ personasCommand(program);
|
|
|
69
70
|
eventsCommand(program);
|
|
70
71
|
usersCommand(program); // [仅 admin] — see the description on each of its subcommands
|
|
71
72
|
examplesCommand(program);
|
|
72
|
-
program
|
|
73
|
+
program
|
|
74
|
+
.parseAsync(process.argv)
|
|
75
|
+
.catch((err) => {
|
|
73
76
|
if (err instanceof CommanderError) {
|
|
74
77
|
// --help / -v travel through the throw path too, but they are normal exits.
|
|
75
78
|
if (err.code === "commander.helpDisplayed" || err.code === "commander.version")
|
|
@@ -81,4 +84,5 @@ program.parseAsync(process.argv).catch((err) => {
|
|
|
81
84
|
return;
|
|
82
85
|
}
|
|
83
86
|
printError(err);
|
|
84
|
-
})
|
|
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
|
+
"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",
|