echopai 2.1.0 → 2.3.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/README.md +13 -1
- package/dist/_generated/commands.js +98 -42
- package/dist/_generated/help.js +118 -53
- package/dist/_generated/operations.js +1056 -356
- package/dist/bin.js +67 -13
- package/dist/runtime/invoker.js +4 -0
- package/dist/runtime/update_check.js +120 -0
- package/dist/runtime/update_worker.js +63 -0
- package/dist/runtime/verb_cmd.js +2 -0
- package/dist/tools/upgrade.js +103 -0
- package/dist/tools/welcome.js +225 -0
- package/dist/verbs/announcements.js +195 -0
- package/dist/verbs/concepts.js +393 -0
- package/dist/verbs/digest.js +24 -15
- package/dist/verbs/financials.js +212 -0
- package/dist/verbs/index.js +43 -7
- package/dist/verbs/limit_up.js +156 -0
- package/dist/verbs/lookup.js +1 -1
- package/dist/verbs/market.js +185 -0
- package/dist/verbs/news.js +17 -3
- package/dist/verbs/quote.js +1 -1
- package/dist/verbs/sentiment.js +191 -6
- package/dist/verbs/views.js +5 -3
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/verbs/research.js +0 -44
package/dist/bin.js
CHANGED
|
@@ -14,26 +14,57 @@
|
|
|
14
14
|
*
|
|
15
15
|
* See docs/PLAN_CLI_V2_REWRITE.md for design details.
|
|
16
16
|
*/
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import path from "node:path";
|
|
17
20
|
import { Command, Option } from "commander";
|
|
18
21
|
import { buildCommandTree } from "./_generated/commands.js";
|
|
19
22
|
import { invoke } from "./runtime/invoker.js";
|
|
20
23
|
import { buildApiCommand } from "./tools/api.js";
|
|
21
24
|
import { buildMcpCommand } from "./tools/mcp.js";
|
|
22
25
|
import { buildRawCallCommand } from "./tools/raw.js";
|
|
23
|
-
import { buildBarsBatchCommand, buildChartCommand, buildDigestCommand, buildHotCommand, buildLookupCommand, buildNewsCommand, buildQuoteCommand,
|
|
26
|
+
import { buildAnnouncementsCommand, buildBarsBatchCommand, buildChartCommand, buildConceptsCommand, buildDigestCommand, buildFinancialsCommand, buildHotCommand, buildLimitUpCommand, buildLookupCommand, buildMarketCommand, buildNewsCommand, buildQuoteCommand, buildScanCommand, buildSearchCommand, buildSentimentCommand, buildViewsCommand, } from "./verbs/index.js";
|
|
24
27
|
import { buildCompletionCommand } from "./tools/completion.js";
|
|
25
28
|
import { buildConfigCommand } from "./tools/config.js";
|
|
26
29
|
import { buildLoginCommand, buildLogoutCommand, buildStatusCommand } from "./tools/login.js";
|
|
27
30
|
import { buildDoctorCommand } from "./tools/doctor.js";
|
|
28
31
|
import { buildSchemaCommand } from "./tools/schema.js";
|
|
29
32
|
import { buildTraceCommand } from "./tools/trace.js";
|
|
33
|
+
import { buildUpgradeCommand } from "./tools/upgrade.js";
|
|
34
|
+
import { buildWelcomeCommand, printWelcome, shouldShowWelcome } from "./tools/welcome.js";
|
|
30
35
|
import { buildWhoamiCommand } from "./tools/whoami.js";
|
|
31
36
|
import { CLI_VERSION } from "./version.js";
|
|
37
|
+
// ─── Background update check ────────────────────────────────────────────────
|
|
38
|
+
// Spawn a detached child that hits npm registry and writes a 24h cache file.
|
|
39
|
+
// Parent exits immediately (child.unref). Banner is emitted on next CLI run
|
|
40
|
+
// from the cache (zero hot-path network cost). Skipped in CI / non-TTY /
|
|
41
|
+
// opt-out env so AI agents and scripts pay nothing.
|
|
42
|
+
try {
|
|
43
|
+
const optedOut = process.env.CI ||
|
|
44
|
+
process.env.ECHOPAI_DISABLE_UPDATE_CHECK ||
|
|
45
|
+
!process.stderr.isTTY;
|
|
46
|
+
// Don't recurse: the worker entry sets this env so we skip spawning again
|
|
47
|
+
// from inside the worker process itself.
|
|
48
|
+
if (!optedOut && !process.env._ECHOPAI_UPDATE_WORKER) {
|
|
49
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const workerPath = path.join(here, "runtime", "update_worker.js");
|
|
51
|
+
const child = spawn(process.execPath, [workerPath, CLI_VERSION], {
|
|
52
|
+
detached: true,
|
|
53
|
+
stdio: "ignore",
|
|
54
|
+
windowsHide: true,
|
|
55
|
+
env: { ...process.env, _ECHOPAI_UPDATE_WORKER: "1" },
|
|
56
|
+
});
|
|
57
|
+
child.unref();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Worker spawn is best-effort; never let it break the CLI.
|
|
62
|
+
}
|
|
32
63
|
const program = new Command();
|
|
33
64
|
program
|
|
34
65
|
.name("echopai")
|
|
35
66
|
.description("EchoPai CLI v1 — partner-grade access to stock-market data, news, sentiment,\n" +
|
|
36
|
-
"
|
|
67
|
+
"and analyst views.\n\n" +
|
|
37
68
|
"AI-First: JSON envelope on stdout, JSON errors on stderr, three-state exit\n" +
|
|
38
69
|
"codes (0 success / 1 user error / 2 service error). Set ECHOPAI_KEY env\n" +
|
|
39
70
|
"var or run `echopai login`.")
|
|
@@ -50,12 +81,19 @@ program
|
|
|
50
81
|
.addOption(new Option("--max-bytes <n>", "Truncate serialized envelope to N bytes (sets meta.truncated)"))
|
|
51
82
|
.addOption(new Option("--yes", "Confirm a write op in non-TTY mode (required for sideEffect=write in agents / CI)"))
|
|
52
83
|
.addOption(new Option("--dry-run", "Send X-Dry-Run:1 on write ops where the server advertises dry-run support"))
|
|
53
|
-
.addHelpText("after", "\nAuthentication:
|
|
54
|
-
"Raw mirror:
|
|
55
|
-
"Curated verbs:
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
84
|
+
.addHelpText("after", "\nAuthentication: ECHOPAI_KEY=<eps_live_<lookup>_<secret>> echopai <cmd>\n" +
|
|
85
|
+
"Raw mirror: echopai raw <noun> <verb> # all OpenAPI ops, e.g. raw news search\n" +
|
|
86
|
+
"Curated verbs: echopai <verb> # task-level entry (Phase 3.6+)\n" +
|
|
87
|
+
"Market session: echopai market status # pre-open/open/lunch/closed + next trading day\n" +
|
|
88
|
+
"Market movers: echopai market movers --sort pct --top 20 # /market 行情榜(pct / pct_asc / speed / amount / turnover / total_mv)\n" +
|
|
89
|
+
"Announcements: echopai announcements stock --code SSE:600519 # cninfo A-share disclosures\n" +
|
|
90
|
+
"Concepts: echopai concepts alerts # 当前激活的概念异动(big_move / limit_up_cluster)\n" +
|
|
91
|
+
"Limit-up: echopai limit-up summary # 涨停数 / 炸板数 / 连板梯队\n" +
|
|
92
|
+
"Fundamentals: echopai financials quote-snapshot --code SSE:600519 # PE/PB/PS/换手率/股息率/量比 14-field snapshot\n" +
|
|
93
|
+
"Capability: echopai whoami | echopai doctor\n" +
|
|
94
|
+
"Schema dump: echopai schema export # AI agents pipe this for command surface\n" +
|
|
95
|
+
"Profile: echopai config profile add prod --base-url https://api.echopai.com\n" +
|
|
96
|
+
"Self-update: echopai upgrade [--check] [--exec] # check npm registry; --exec runs `npm i -g echopai@latest`\n");
|
|
59
97
|
const dispatch = async (op, args) => {
|
|
60
98
|
// commander stores global flags on the *root* command's opts in camelCase
|
|
61
99
|
// (e.g. --max-bytes → maxBytes). Subcommand-level opts go through
|
|
@@ -100,7 +138,7 @@ function applyAiFirstHooks(cmd) {
|
|
|
100
138
|
//
|
|
101
139
|
// Phase 3.7+ — `raw` is now the only place to find spec-driven operations.
|
|
102
140
|
// Top-level previously held spec mirrors but starting this PR the curated
|
|
103
|
-
// verbs (`views` / `news` / `quote` / `sentiment` / `
|
|
141
|
+
// verbs (`views` / `news` / `quote` / `sentiment` / `hot` /
|
|
104
142
|
// `lookup` / `digest` etc.) take precedence at the top level. Use `echopai raw <noun>
|
|
105
143
|
// <verb>` for the raw mirror (e.g. `raw views feed`, `raw quote`, etc.).
|
|
106
144
|
const rawCmd = new Command("raw").description("Raw 1:1 mirror of OpenAPI operations (escape hatch for power users / scripts)");
|
|
@@ -108,7 +146,7 @@ buildCommandTree(rawCmd, dispatch);
|
|
|
108
146
|
rawCmd.addCommand(buildRawCallCommand());
|
|
109
147
|
program.addCommand(rawCmd);
|
|
110
148
|
// Top-level spec commands kept ONLY for nouns that don't conflict with a
|
|
111
|
-
// curated verb. Curated-verb nouns (views/news/quote/sentiment/
|
|
149
|
+
// curated verb. Curated-verb nouns (views/news/quote/sentiment/hot
|
|
112
150
|
// /lookup/digest) are NOT generated at top level — they go below in their canonical
|
|
113
151
|
// curated form. Use `echopai raw <noun>` for the spec mirror.
|
|
114
152
|
const CURATED_NOUNS = new Set([
|
|
@@ -116,11 +154,15 @@ const CURATED_NOUNS = new Set([
|
|
|
116
154
|
"news",
|
|
117
155
|
"quote",
|
|
118
156
|
"sentiment",
|
|
119
|
-
"research",
|
|
120
157
|
"hot",
|
|
121
158
|
"lookup",
|
|
122
159
|
"digest",
|
|
123
160
|
"search",
|
|
161
|
+
"financials",
|
|
162
|
+
"announcements",
|
|
163
|
+
"market",
|
|
164
|
+
"concepts",
|
|
165
|
+
"limit-up",
|
|
124
166
|
]);
|
|
125
167
|
const topLevelSpecDispatch = async (op, args) => {
|
|
126
168
|
await dispatch(op, args);
|
|
@@ -135,19 +177,23 @@ for (const sub of topLevelStub.commands) {
|
|
|
135
177
|
// Curated agent verbs (Phase 3.6+): task-level entries that wrap one or
|
|
136
178
|
// more raw operations + map output for AI agents. Listed before tools so
|
|
137
179
|
// `--help` shows them first.
|
|
138
|
-
// Product priority (see feedback_views_over_news.md): views >
|
|
180
|
+
// Product priority (see feedback_views_over_news.md): views > news
|
|
139
181
|
program.addCommand(buildLookupCommand());
|
|
140
182
|
program.addCommand(buildDigestCommand()); // one-shot fan-out (Phase 5.1)
|
|
141
183
|
program.addCommand(buildSearchCommand()); // hybrid semantic discovery (PLAN_SEARCH_SEMANTIC_UPGRADE)
|
|
142
184
|
program.addCommand(buildQuoteCommand());
|
|
185
|
+
program.addCommand(buildMarketCommand()); // session state (pre-open / open / lunch / closed)
|
|
143
186
|
program.addCommand(buildViewsCommand()); // PRIMARY research source
|
|
144
|
-
program.addCommand(buildResearchCommand()); // views quality signal layer
|
|
145
187
|
program.addCommand(buildNewsCommand()); // supplementary breadth
|
|
188
|
+
program.addCommand(buildAnnouncementsCommand()); // cninfo A-share disclosures (feed/stock/detail)
|
|
146
189
|
program.addCommand(buildSentimentCommand());
|
|
147
190
|
program.addCommand(buildHotCommand());
|
|
191
|
+
program.addCommand(buildConceptsCommand()); // concept indices + alerts + bars + news/views
|
|
192
|
+
program.addCommand(buildLimitUpCommand()); // 涨停股池 / 涨停统计 / 涨停趋势
|
|
148
193
|
program.addCommand(buildChartCommand()); // single-code K-line
|
|
149
194
|
program.addCommand(buildBarsBatchCommand()); // multi-code batch K-line (PR 3.2/3.3 server)
|
|
150
195
|
program.addCommand(buildScanCommand()); // full-market scan (PR 3.4 server)
|
|
196
|
+
program.addCommand(buildFinancialsCommand()); // fundamentals: quote-snapshot / pit / reports / series
|
|
151
197
|
// hand-curated tools
|
|
152
198
|
program.addCommand(buildLoginCommand());
|
|
153
199
|
program.addCommand(buildLogoutCommand());
|
|
@@ -160,7 +206,15 @@ program.addCommand(buildWhoamiCommand());
|
|
|
160
206
|
program.addCommand(buildDoctorCommand());
|
|
161
207
|
program.addCommand(buildMcpCommand());
|
|
162
208
|
program.addCommand(buildCompletionCommand());
|
|
209
|
+
program.addCommand(buildWelcomeCommand());
|
|
210
|
+
program.addCommand(buildUpgradeCommand());
|
|
163
211
|
applyAiFirstHooks(program);
|
|
212
|
+
// Bare `echopai` in a TTY → onboarding screen. Non-TTY / CI / piped output
|
|
213
|
+
// falls through to commander's plain help (preserves AI-first invariant).
|
|
214
|
+
if (shouldShowWelcome(process.argv)) {
|
|
215
|
+
printWelcome();
|
|
216
|
+
process.exit(0);
|
|
217
|
+
}
|
|
164
218
|
program.parseAsync(process.argv).catch((e) => {
|
|
165
219
|
process.stderr.write(JSON.stringify({
|
|
166
220
|
error: {
|
package/dist/runtime/invoker.js
CHANGED
|
@@ -23,6 +23,7 @@ import { generateIdempotencyKey, announceKey } from "./idempotency.js";
|
|
|
23
23
|
import { writeStdout, writeStderr } from "./io.js";
|
|
24
24
|
import { paginate, exitCodeForPaginateError } from "./paginator.js";
|
|
25
25
|
import { renderError, isTtyHuman } from "./tty.js";
|
|
26
|
+
import { maybeEmitUpdateBanner } from "./update_check.js";
|
|
26
27
|
const Ajv = (AjvPkg.default ??
|
|
27
28
|
AjvPkg);
|
|
28
29
|
const addFormats = (addFormatsPkg.default ??
|
|
@@ -180,6 +181,7 @@ export async function invoke(op, args, ctx) {
|
|
|
180
181
|
});
|
|
181
182
|
await writeStderr(`[paginate] ${result.pages} pages, ${result.items} items\n`);
|
|
182
183
|
trace(op, { exit_code: 0 });
|
|
184
|
+
maybeEmitUpdateBanner();
|
|
183
185
|
process.exit(0);
|
|
184
186
|
}
|
|
185
187
|
catch (e) {
|
|
@@ -293,6 +295,7 @@ export async function invoke(op, args, ctx) {
|
|
|
293
295
|
// 8. success path — render
|
|
294
296
|
if (raw) {
|
|
295
297
|
await writeStdout(bodyText + "\n");
|
|
298
|
+
maybeEmitUpdateBanner();
|
|
296
299
|
process.exit(0);
|
|
297
300
|
}
|
|
298
301
|
const rawEnvelope = buildResponseEnvelope(bodyJson ?? bodyText, {
|
|
@@ -337,6 +340,7 @@ export async function invoke(op, args, ctx) {
|
|
|
337
340
|
exit_code: 0,
|
|
338
341
|
truncated: envelope.meta.truncated === true,
|
|
339
342
|
});
|
|
343
|
+
maybeEmitUpdateBanner();
|
|
340
344
|
process.exit(0);
|
|
341
345
|
}
|
|
342
346
|
function buildQueryString(params) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update-availability check — passive, AI-safe.
|
|
3
|
+
*
|
|
4
|
+
* 设计:
|
|
5
|
+
* - 真正发网络的活儿在 detached child (`update_worker.ts`) 干,由 bin.ts
|
|
6
|
+
* 启动时 spawn + unref,父进程立刻继续。Worker 写 ~/.config/echopai/
|
|
7
|
+
* update_cache.json,下次 CLI 启动时 sync 读 cache → 决定是否打 banner。
|
|
8
|
+
* - 这里只做 sync reader + 比较 + emit banner。任何 IO / 解析失败都吞掉
|
|
9
|
+
* 当作"没有可用更新"——CLI 自更新链路任何环节都不该让正常命令失败。
|
|
10
|
+
*
|
|
11
|
+
* AI-safe / agent-safe 规则:
|
|
12
|
+
* - 默认只在 stderr.isTTY 时 emit banner(agent 走 pipe 不 TTY,无污染)
|
|
13
|
+
* - 环境变量 `ECHOPAI_DISABLE_UPDATE_CHECK=1` / `CI=1` 一律静默
|
|
14
|
+
* - banner 是单行 dim ANSI,不破坏 NDJSON / JSON envelope(stdout 不动)
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { CLI_VERSION } from "../version.js";
|
|
20
|
+
export const UPDATE_CACHE_PATH = path.join(os.homedir(), ".config", "echopai", "update_cache.json");
|
|
21
|
+
/** Pure semver-lite comparator. Pre-release suffixes are best-effort sorted
|
|
22
|
+
* lexicographically AFTER the numeric tuple, so `2.3.0-beta.1 < 2.3.0`. */
|
|
23
|
+
export function compareSemver(a, b) {
|
|
24
|
+
const parse = (s) => {
|
|
25
|
+
const [core, pre = ""] = s.replace(/^v/, "").split("-", 2);
|
|
26
|
+
const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
27
|
+
return { nums, pre };
|
|
28
|
+
};
|
|
29
|
+
const pa = parse(a);
|
|
30
|
+
const pb = parse(b);
|
|
31
|
+
const len = Math.max(pa.nums.length, pb.nums.length);
|
|
32
|
+
for (let i = 0; i < len; i++) {
|
|
33
|
+
const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
|
|
34
|
+
if (d !== 0)
|
|
35
|
+
return d > 0 ? 1 : -1;
|
|
36
|
+
}
|
|
37
|
+
// pre-release: empty > non-empty (release > prerelease per semver)
|
|
38
|
+
if (pa.pre === pb.pre)
|
|
39
|
+
return 0;
|
|
40
|
+
if (pa.pre === "")
|
|
41
|
+
return 1;
|
|
42
|
+
if (pb.pre === "")
|
|
43
|
+
return -1;
|
|
44
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
export function isNewer(latest, current) {
|
|
47
|
+
try {
|
|
48
|
+
return compareSemver(latest, current) > 0;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Sync read; returns null on any error (missing file / parse fail / etc.). */
|
|
55
|
+
export function readCachedUpdate() {
|
|
56
|
+
try {
|
|
57
|
+
const raw = readFileSync(UPDATE_CACHE_PATH, "utf-8");
|
|
58
|
+
const obj = JSON.parse(raw);
|
|
59
|
+
if (typeof obj.checked_at === "string" &&
|
|
60
|
+
typeof obj.current === "string" &&
|
|
61
|
+
typeof obj.latest === "string") {
|
|
62
|
+
return obj;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Best-effort cache write (atomic via tmp + rename). Returns true on success. */
|
|
71
|
+
export function writeCachedUpdate(info) {
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true });
|
|
74
|
+
const tmp = UPDATE_CACHE_PATH + "." + process.pid + ".tmp";
|
|
75
|
+
writeFileSync(tmp, JSON.stringify(info) + "\n", { encoding: "utf-8" });
|
|
76
|
+
// Atomic rename within same dir
|
|
77
|
+
renameSync(tmp, UPDATE_CACHE_PATH);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Returns whether banner emission is currently allowed (TTY + no opt-out). */
|
|
85
|
+
export function isUpdateBannerAllowed() {
|
|
86
|
+
if (process.env.ECHOPAI_DISABLE_UPDATE_CHECK)
|
|
87
|
+
return false;
|
|
88
|
+
if (process.env.CI)
|
|
89
|
+
return false;
|
|
90
|
+
// banner goes to stderr; only emit if user is at an interactive terminal
|
|
91
|
+
if (!process.stderr.isTTY)
|
|
92
|
+
return false;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Emit a single-line banner to stderr if cache shows a newer version.
|
|
97
|
+
* Idempotent within a single process: only emits once even if called many
|
|
98
|
+
* times (e.g. from both executeVerb and welcome screen).
|
|
99
|
+
*/
|
|
100
|
+
let bannerEmittedThisProcess = false;
|
|
101
|
+
export function maybeEmitUpdateBanner(stream = process.stderr) {
|
|
102
|
+
if (bannerEmittedThisProcess)
|
|
103
|
+
return;
|
|
104
|
+
if (!isUpdateBannerAllowed())
|
|
105
|
+
return;
|
|
106
|
+
const info = readCachedUpdate();
|
|
107
|
+
if (!info)
|
|
108
|
+
return;
|
|
109
|
+
if (!isNewer(info.latest, CLI_VERSION))
|
|
110
|
+
return;
|
|
111
|
+
bannerEmittedThisProcess = true;
|
|
112
|
+
// dim cyan, single line, prefixed with sparkles
|
|
113
|
+
const dim = "\x1b[2m";
|
|
114
|
+
const reset = "\x1b[0m";
|
|
115
|
+
stream.write(`${dim}✨ echopai v${info.latest} available (you have v${CLI_VERSION}) — run \`echopai upgrade\`${reset}\n`);
|
|
116
|
+
}
|
|
117
|
+
/** Test hook: reset the once-per-process gate. */
|
|
118
|
+
export function _resetBannerGateForTests() {
|
|
119
|
+
bannerEmittedThisProcess = false;
|
|
120
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Detached update-check worker. Spawned by `bin.ts` at startup
|
|
4
|
+
* with `detached: true, stdio: 'ignore'` + `child.unref()` so the parent
|
|
5
|
+
* exits immediately while this process finishes the registry fetch in the
|
|
6
|
+
* background.
|
|
7
|
+
*
|
|
8
|
+
* Contract:
|
|
9
|
+
* - argv[2] = current CLI version (informational, stored in cache)
|
|
10
|
+
* - reads cache mtime; if < 24h old, exits 0 without network
|
|
11
|
+
* - else GET https://registry.npmjs.org/echopai/latest with 5s timeout,
|
|
12
|
+
* parses { version }, writes ~/.config/echopai/update_cache.json
|
|
13
|
+
* - all errors swallowed (worker exit 0); next invocation retries
|
|
14
|
+
*
|
|
15
|
+
* Never writes to stdout/stderr. Never throws. Cap process time at ~6s
|
|
16
|
+
* via AbortController to avoid lingering processes on flaky networks.
|
|
17
|
+
*/
|
|
18
|
+
import { statSync } from "node:fs";
|
|
19
|
+
import { fetch } from "undici";
|
|
20
|
+
import { UPDATE_CACHE_PATH, writeCachedUpdate, } from "./update_check.js";
|
|
21
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
22
|
+
const FETCH_TIMEOUT_MS = 5_000;
|
|
23
|
+
const REGISTRY_URL = "https://registry.npmjs.org/echopai/latest";
|
|
24
|
+
async function main() {
|
|
25
|
+
const current = process.argv[2] ?? "0.0.0";
|
|
26
|
+
// Skip if cache fresh
|
|
27
|
+
try {
|
|
28
|
+
const stat = statSync(UPDATE_CACHE_PATH);
|
|
29
|
+
if (Date.now() - stat.mtimeMs < CACHE_TTL_MS)
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// missing cache — proceed to fetch
|
|
34
|
+
}
|
|
35
|
+
const ac = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(REGISTRY_URL, {
|
|
39
|
+
headers: { Accept: "application/json" },
|
|
40
|
+
signal: ac.signal,
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
return;
|
|
44
|
+
const body = (await res.json());
|
|
45
|
+
const latest = body?.version;
|
|
46
|
+
if (!latest || typeof latest !== "string")
|
|
47
|
+
return;
|
|
48
|
+
const info = {
|
|
49
|
+
checked_at: new Date().toISOString(),
|
|
50
|
+
current,
|
|
51
|
+
latest,
|
|
52
|
+
};
|
|
53
|
+
writeCachedUpdate(info);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// network / abort / JSON parse — silent
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Don't propagate errors; this worker is best-effort.
|
|
63
|
+
main().catch(() => { });
|
package/dist/runtime/verb_cmd.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { resolveCredentials, AuthMissingError } from "./auth.js";
|
|
13
13
|
import { CallApiError } from "./errors.js";
|
|
14
14
|
import { isTtyHuman, renderError } from "./tty.js";
|
|
15
|
+
import { maybeEmitUpdateBanner } from "./update_check.js";
|
|
15
16
|
import { CLI_VERSION } from "../version.js";
|
|
16
17
|
/**
|
|
17
18
|
* Run a verb handler end-to-end:
|
|
@@ -38,6 +39,7 @@ export async function executeVerb(handler) {
|
|
|
38
39
|
cliVersion: CLI_VERSION,
|
|
39
40
|
});
|
|
40
41
|
process.stdout.write(JSON.stringify(env) + "\n");
|
|
42
|
+
maybeEmitUpdateBanner();
|
|
41
43
|
process.exit(0);
|
|
42
44
|
}
|
|
43
45
|
catch (e) {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `echopai upgrade` — explicit "check for + show upgrade command" entry.
|
|
3
|
+
*
|
|
4
|
+
* Three flavors:
|
|
5
|
+
* - `echopai upgrade` → 用 cache(无网络)+ 打印 envelope
|
|
6
|
+
* (current / latest / command)
|
|
7
|
+
* - `echopai upgrade --check` → 强制走 npm registry,刷新 cache
|
|
8
|
+
* - `echopai upgrade --check --exec` → 刷新 cache 后真跑 `npm i -g echopai@latest`
|
|
9
|
+
* (透传 stdio;非 0 退出码传递)
|
|
10
|
+
*
|
|
11
|
+
* AI-first envelope on stdout(带 meta.checked_at),错误走 stderr JSON。
|
|
12
|
+
* 永远不会 exec npm 除非显式 --exec。
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { Command, Option } from "commander";
|
|
16
|
+
import { fetch } from "undici";
|
|
17
|
+
import { CLI_VERSION, } from "../version.js";
|
|
18
|
+
import { isNewer, readCachedUpdate, writeCachedUpdate, } from "../runtime/update_check.js";
|
|
19
|
+
const REGISTRY_URL = "https://registry.npmjs.org/echopai/latest";
|
|
20
|
+
const FETCH_TIMEOUT_MS = 5_000;
|
|
21
|
+
async function fetchLatest() {
|
|
22
|
+
const ac = new AbortController();
|
|
23
|
+
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(REGISTRY_URL, {
|
|
26
|
+
headers: { Accept: "application/json" },
|
|
27
|
+
signal: ac.signal,
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
return null;
|
|
31
|
+
const body = (await res.json());
|
|
32
|
+
return typeof body?.version === "string" ? body.version : null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function emitJson(data, meta = {}) {
|
|
42
|
+
process.stdout.write(JSON.stringify({ data, meta }) + "\n");
|
|
43
|
+
}
|
|
44
|
+
function emitErr(code, message, exit = 2) {
|
|
45
|
+
process.stderr.write(JSON.stringify({ error: { code, message } }) + "\n");
|
|
46
|
+
process.exit(exit);
|
|
47
|
+
}
|
|
48
|
+
export function buildUpgradeCommand() {
|
|
49
|
+
const cmd = new Command("upgrade").description("Check for + show install command for the latest echopai release (no auto-exec unless --exec).");
|
|
50
|
+
cmd.addOption(new Option("--check", "Force fresh registry fetch (skip 24h cache)"));
|
|
51
|
+
cmd.addOption(new Option("--exec", "Actually run `npm i -g echopai@latest` (default: print command only)"));
|
|
52
|
+
cmd.action(async (opts) => {
|
|
53
|
+
let info = opts.check ? null : readCachedUpdate();
|
|
54
|
+
if (!info || opts.check) {
|
|
55
|
+
const latest = await fetchLatest();
|
|
56
|
+
if (!latest) {
|
|
57
|
+
emitErr("network_error", "Failed to reach npm registry (timeout or non-200). Try again later.");
|
|
58
|
+
}
|
|
59
|
+
info = {
|
|
60
|
+
checked_at: new Date().toISOString(),
|
|
61
|
+
current: CLI_VERSION,
|
|
62
|
+
latest,
|
|
63
|
+
};
|
|
64
|
+
writeCachedUpdate(info);
|
|
65
|
+
}
|
|
66
|
+
const newer = isNewer(info.latest, CLI_VERSION);
|
|
67
|
+
const installCmd = "npm i -g echopai@latest";
|
|
68
|
+
if (!newer) {
|
|
69
|
+
emitJson({
|
|
70
|
+
current: CLI_VERSION,
|
|
71
|
+
latest: info.latest,
|
|
72
|
+
status: "up_to_date",
|
|
73
|
+
message: `Already on the latest version (v${CLI_VERSION}).`,
|
|
74
|
+
}, { checked_at: info.checked_at, source: opts.check ? "registry" : "cache" });
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
// Newer available
|
|
78
|
+
if (opts.exec) {
|
|
79
|
+
// Run npm i -g with inherited stdio so user sees real-time progress.
|
|
80
|
+
// Don't shell-out — use direct args to avoid quoting issues.
|
|
81
|
+
const r = spawnSync("npm", ["i", "-g", `echopai@${info.latest}`], { stdio: "inherit" });
|
|
82
|
+
if (r.status === 0) {
|
|
83
|
+
emitJson({
|
|
84
|
+
current: CLI_VERSION,
|
|
85
|
+
latest: info.latest,
|
|
86
|
+
status: "upgraded",
|
|
87
|
+
message: `Upgraded to v${info.latest}. Re-run any command to load the new version.`,
|
|
88
|
+
}, { checked_at: info.checked_at, exec: true });
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
emitErr("exec_failed", `npm i -g failed with exit ${r.status ?? "unknown"}. Run manually: ${installCmd}`);
|
|
92
|
+
}
|
|
93
|
+
emitJson({
|
|
94
|
+
current: CLI_VERSION,
|
|
95
|
+
latest: info.latest,
|
|
96
|
+
status: "update_available",
|
|
97
|
+
command: installCmd,
|
|
98
|
+
message: `v${info.latest} is available. Run: ${installCmd} (or pass --exec to run it now)`,
|
|
99
|
+
}, { checked_at: info.checked_at, source: opts.check ? "registry" : "cache" });
|
|
100
|
+
process.exit(0);
|
|
101
|
+
});
|
|
102
|
+
return cmd;
|
|
103
|
+
}
|