@syengup/friday-channel-next 0.1.26 → 0.1.27
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/dist/index.js +2 -0
- package/dist/src/http/handlers/health.d.ts +1 -0
- package/dist/src/http/handlers/health.js +2 -0
- package/dist/src/http/handlers/messages.d.ts +5 -0
- package/dist/src/http/handlers/messages.js +19 -8
- package/dist/src/http/handlers/plugin-info.d.ts +11 -0
- package/dist/src/http/handlers/plugin-info.js +32 -0
- package/dist/src/http/handlers/plugin-upgrade.d.ts +11 -0
- package/dist/src/http/handlers/plugin-upgrade.js +94 -0
- package/dist/src/http/handlers/sse.js +2 -0
- package/dist/src/http/handlers/status.js +2 -0
- package/dist/src/http/server.js +10 -0
- package/dist/src/plugin-install-info.d.ts +15 -0
- package/dist/src/plugin-install-info.js +87 -0
- package/dist/src/upgrade-runtime.d.ts +39 -0
- package/dist/src/upgrade-runtime.js +27 -0
- package/dist/src/version.d.ts +5 -0
- package/dist/src/version.js +37 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/http/handlers/health.ts +3 -0
- package/src/http/handlers/messages.test.ts +75 -1
- package/src/http/handlers/messages.ts +19 -7
- package/src/http/handlers/plugin-info.ts +51 -0
- package/src/http/handlers/plugin-upgrade.ts +112 -0
- package/src/http/handlers/sse.ts +2 -0
- package/src/http/handlers/status.ts +2 -0
- package/src/http/server.ts +12 -0
- package/src/plugin-install-info.test.ts +28 -0
- package/src/plugin-install-info.ts +95 -0
- package/src/upgrade-runtime.ts +69 -0
- package/src/version.ts +41 -0
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getFridayNextRuntime } from "./src/runtime.js";
|
|
|
8
8
|
import { sseEmitter } from "./src/sse/emitter.js";
|
|
9
9
|
import { forwardAgentEventRaw, getLastRegisteredFridayDeviceId, resolveFridayDeviceIdForSessionKey, } from "./src/friday-session.js";
|
|
10
10
|
import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
|
|
11
|
+
import { setUpgradeRuntime } from "./src/upgrade-runtime.js";
|
|
11
12
|
import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
|
|
12
13
|
import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
|
|
13
14
|
import { createFridayNextLogger } from "./src/logging.js";
|
|
@@ -78,6 +79,7 @@ export default defineChannelPluginEntry({
|
|
|
78
79
|
setRuntime: setFridayNextRuntime,
|
|
79
80
|
registerFull: (api) => {
|
|
80
81
|
setFridayAgentForwardRuntime(api);
|
|
82
|
+
setUpgradeRuntime(api);
|
|
81
83
|
const sameApi = lastApiRoutesRegistered?.deref() === api;
|
|
82
84
|
if (!sameApi) {
|
|
83
85
|
lastApiRoutesRegistered = new WeakRef(api);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
2
2
|
import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
|
|
3
3
|
import { createFridayNextLogger } from "../../logging.js";
|
|
4
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
4
5
|
const REQUIRED_NODE_CAPS = ["location", "canvas"];
|
|
5
6
|
const REQUIRED_NODE_COMMANDS = [
|
|
6
7
|
"location.get",
|
|
@@ -36,6 +37,7 @@ export async function handleHealth(req, res) {
|
|
|
36
37
|
timestamp: Date.now(),
|
|
37
38
|
deviceId,
|
|
38
39
|
nodeDeviceId,
|
|
40
|
+
pluginVersion: PLUGIN_VERSION,
|
|
39
41
|
};
|
|
40
42
|
const log = createFridayNextLogger("health");
|
|
41
43
|
if (nodeDeviceId) {
|
|
@@ -47,4 +47,9 @@ export interface FridayMessagePayload {
|
|
|
47
47
|
reasoningLevel?: string;
|
|
48
48
|
thinkingLevel?: string;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* 把可选文本与媒体引用拼成给 agent 的最终 body。纯附件(文本为空)场景下
|
|
52
|
+
* 不能带前导空行,否则 agent 收到的是 `\n\n[media…]`——故拆成可测纯函数。
|
|
53
|
+
*/
|
|
54
|
+
export declare function composeBodyWithMediaRefs(text: string, mediaRefs: string[]): string;
|
|
50
55
|
export declare function handleMessages(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -262,6 +262,16 @@ async function resolveRunMetadataFromRuntimeSession(runtime, sessionKey) {
|
|
|
262
262
|
}
|
|
263
263
|
return null;
|
|
264
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* 把可选文本与媒体引用拼成给 agent 的最终 body。纯附件(文本为空)场景下
|
|
267
|
+
* 不能带前导空行,否则 agent 收到的是 `\n\n[media…]`——故拆成可测纯函数。
|
|
268
|
+
*/
|
|
269
|
+
export function composeBodyWithMediaRefs(text, mediaRefs) {
|
|
270
|
+
const trimmed = text.trim();
|
|
271
|
+
if (mediaRefs.length === 0)
|
|
272
|
+
return trimmed;
|
|
273
|
+
return trimmed ? `${trimmed}\n\n${mediaRefs.join("\n")}` : mediaRefs.join("\n");
|
|
274
|
+
}
|
|
265
275
|
async function buildBodyForAgentWithAttachments(text, attachmentIds) {
|
|
266
276
|
if (attachmentIds.length === 0)
|
|
267
277
|
return text.trim();
|
|
@@ -275,9 +285,7 @@ async function buildBodyForAgentWithAttachments(text, attachmentIds) {
|
|
|
275
285
|
mediaRefs.push(`[media attached: file://${saved.path}]`);
|
|
276
286
|
}
|
|
277
287
|
}
|
|
278
|
-
|
|
279
|
-
return text.trim();
|
|
280
|
-
return `${text.trim()}\n\n${mediaRefs.join("\n")}`;
|
|
288
|
+
return composeBodyWithMediaRefs(text, mediaRefs);
|
|
281
289
|
}
|
|
282
290
|
export async function handleMessages(req, res) {
|
|
283
291
|
if (req.method !== "POST") {
|
|
@@ -320,14 +328,17 @@ export async function handleMessages(req, res) {
|
|
|
320
328
|
res.end(JSON.stringify({ error: "Missing required field: deviceId" }));
|
|
321
329
|
return true;
|
|
322
330
|
}
|
|
323
|
-
|
|
324
|
-
|
|
331
|
+
// 允许"只发附件、不发文本":text 与 attachments 不能同时为空,但任一非空即放行。
|
|
332
|
+
const hasText = Boolean(text && text.trim());
|
|
333
|
+
const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
|
|
334
|
+
if (!hasText && !hasAttachments) {
|
|
335
|
+
log("BAD_REQUEST", normalizedDeviceId, undefined, "missing text and attachments", "warn");
|
|
325
336
|
res.statusCode = 400;
|
|
326
337
|
res.setHeader("Content-Type", "application/json");
|
|
327
|
-
res.end(JSON.stringify({ error: "Missing required field: text" }));
|
|
338
|
+
res.end(JSON.stringify({ error: "Missing required field: text or attachments" }));
|
|
328
339
|
return true;
|
|
329
340
|
}
|
|
330
|
-
const trimmedText = text.trim();
|
|
341
|
+
const trimmedText = (text ?? "").trim();
|
|
331
342
|
touchFridayInbound();
|
|
332
343
|
const isSlashCommand = trimmedText.startsWith("/");
|
|
333
344
|
const runId = crypto.randomUUID();
|
|
@@ -363,7 +374,7 @@ export async function handleMessages(req, res) {
|
|
|
363
374
|
registerFridaySessionDeviceMapping(appSessionKey, normalizedDeviceId);
|
|
364
375
|
sseEmitter.trackDeviceForRun(normalizedDeviceId, runId);
|
|
365
376
|
registerRunRoute({ runId, deviceId: normalizedDeviceId, sessionKey: baseSessionKey });
|
|
366
|
-
const bodyForAgent = await buildBodyForAgentWithAttachments(
|
|
377
|
+
const bodyForAgent = await buildBodyForAgentWithAttachments(trimmedText, attachments);
|
|
367
378
|
const msgContext = {
|
|
368
379
|
Body: trimmedText,
|
|
369
380
|
BodyForAgent: bodyForAgent,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export interface PluginInfoResult {
|
|
3
|
+
currentVersion: string;
|
|
4
|
+
latestVersion: string | null;
|
|
5
|
+
installSource: string;
|
|
6
|
+
/** True when the install is npm-managed (the only auto-upgradable source). */
|
|
7
|
+
canAutoUpgrade: boolean;
|
|
8
|
+
/** True when a newer version is published AND the install can be auto-upgraded. */
|
|
9
|
+
upgradable: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function handlePluginInfo(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
2
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
3
|
+
import { fetchLatestVersion, getInstallSource, semverGreater, } from "../../plugin-install-info.js";
|
|
4
|
+
export async function handlePluginInfo(req, res) {
|
|
5
|
+
if (req.method !== "GET") {
|
|
6
|
+
res.statusCode = 405;
|
|
7
|
+
res.setHeader("Content-Type", "application/json");
|
|
8
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (!extractBearerToken(req)) {
|
|
12
|
+
res.statusCode = 401;
|
|
13
|
+
res.setHeader("Content-Type", "application/json");
|
|
14
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
const installSource = getInstallSource();
|
|
18
|
+
const canAutoUpgrade = installSource === "npm";
|
|
19
|
+
const latestVersion = await fetchLatestVersion(Date.now());
|
|
20
|
+
const upgradable = canAutoUpgrade && semverGreater(latestVersion, PLUGIN_VERSION);
|
|
21
|
+
const result = {
|
|
22
|
+
currentVersion: PLUGIN_VERSION,
|
|
23
|
+
latestVersion,
|
|
24
|
+
installSource,
|
|
25
|
+
canAutoUpgrade,
|
|
26
|
+
upgradable,
|
|
27
|
+
};
|
|
28
|
+
res.statusCode = 200;
|
|
29
|
+
res.setHeader("Content-Type", "application/json");
|
|
30
|
+
res.end(JSON.stringify(result));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
/**
|
|
3
|
+
* POST /friday-next/plugin/upgrade
|
|
4
|
+
*
|
|
5
|
+
* Runs `openclaw plugins install @syengup/friday-channel-next@latest --force`
|
|
6
|
+
* (registry-aware, updates the install record), responds 202, then triggers a
|
|
7
|
+
* safe gateway restart so the new version loads. Only npm-installed plugins are
|
|
8
|
+
* eligible — dev (load.paths / source==="path") installs return 409 to protect
|
|
9
|
+
* the dev environment from duplicate npm installs.
|
|
10
|
+
*/
|
|
11
|
+
export declare function handlePluginUpgrade(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
2
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
3
|
+
import { PLUGIN_PACKAGE_NAME, PLUGIN_VERSION } from "../../version.js";
|
|
4
|
+
import { getInstallSource } from "../../plugin-install-info.js";
|
|
5
|
+
import { getUpgradeRuntime } from "../../upgrade-runtime.js";
|
|
6
|
+
const UPGRADE_TIMEOUT_MS = 120_000;
|
|
7
|
+
/** Give the 202 response time to flush before the restart kills the process. */
|
|
8
|
+
const RESTART_DELAY_MS = 500;
|
|
9
|
+
/**
|
|
10
|
+
* POST /friday-next/plugin/upgrade
|
|
11
|
+
*
|
|
12
|
+
* Runs `openclaw plugins install @syengup/friday-channel-next@latest --force`
|
|
13
|
+
* (registry-aware, updates the install record), responds 202, then triggers a
|
|
14
|
+
* safe gateway restart so the new version loads. Only npm-installed plugins are
|
|
15
|
+
* eligible — dev (load.paths / source==="path") installs return 409 to protect
|
|
16
|
+
* the dev environment from duplicate npm installs.
|
|
17
|
+
*/
|
|
18
|
+
export async function handlePluginUpgrade(req, res) {
|
|
19
|
+
if (req.method !== "POST") {
|
|
20
|
+
res.statusCode = 405;
|
|
21
|
+
res.setHeader("Content-Type", "application/json");
|
|
22
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (!extractBearerToken(req)) {
|
|
26
|
+
res.statusCode = 401;
|
|
27
|
+
res.setHeader("Content-Type", "application/json");
|
|
28
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const log = createFridayNextLogger("upgrade");
|
|
32
|
+
const installSource = getInstallSource();
|
|
33
|
+
if (installSource !== "npm") {
|
|
34
|
+
res.statusCode = 409;
|
|
35
|
+
res.setHeader("Content-Type", "application/json");
|
|
36
|
+
res.end(JSON.stringify({
|
|
37
|
+
error: "auto-upgrade not available",
|
|
38
|
+
detail: `install source is "${installSource}"; only npm installs can be auto-upgraded`,
|
|
39
|
+
installSource,
|
|
40
|
+
}));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
const rt = getUpgradeRuntime();
|
|
44
|
+
if (!rt) {
|
|
45
|
+
res.statusCode = 500;
|
|
46
|
+
res.setHeader("Content-Type", "application/json");
|
|
47
|
+
res.end(JSON.stringify({ error: "upgrade runtime unavailable" }));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
const spec = `${PLUGIN_PACKAGE_NAME}@latest`;
|
|
51
|
+
log.info(`Starting plugin upgrade: ${spec} (from ${PLUGIN_VERSION})`);
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await rt.runCommandWithTimeout(["openclaw", "plugins", "install", spec, "--force"], UPGRADE_TIMEOUT_MS);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
58
|
+
log.error(`plugin upgrade command failed to spawn: ${msg}`);
|
|
59
|
+
res.statusCode = 500;
|
|
60
|
+
res.setHeader("Content-Type", "application/json");
|
|
61
|
+
res.end(JSON.stringify({ error: "upgrade command failed", detail: msg }));
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (result.code !== 0) {
|
|
65
|
+
const stderrTail = (result.stderr ?? "").slice(-2000);
|
|
66
|
+
log.error(`plugin upgrade exited code=${result.code}: ${stderrTail}`);
|
|
67
|
+
res.statusCode = 500;
|
|
68
|
+
res.setHeader("Content-Type", "application/json");
|
|
69
|
+
res.end(JSON.stringify({
|
|
70
|
+
error: "upgrade command exited non-zero",
|
|
71
|
+
code: result.code,
|
|
72
|
+
detail: stderrTail,
|
|
73
|
+
}));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
log.info("Plugin upgrade install succeeded; scheduling gateway restart");
|
|
77
|
+
// Respond first so the app receives confirmation before the restart drops the
|
|
78
|
+
// connection, then trigger the safe restart after a short flush delay.
|
|
79
|
+
res.statusCode = 202;
|
|
80
|
+
res.setHeader("Content-Type", "application/json");
|
|
81
|
+
res.end(JSON.stringify({ status: "upgrading", from: PLUGIN_VERSION }));
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
void rt
|
|
84
|
+
.mutateConfigFile({
|
|
85
|
+
afterWrite: { mode: "restart", reason: "friday-next 插件自动升级后重启" },
|
|
86
|
+
mutate: () => { },
|
|
87
|
+
})
|
|
88
|
+
.catch((err) => {
|
|
89
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
90
|
+
log.error(`gateway restart trigger failed: ${msg}`);
|
|
91
|
+
});
|
|
92
|
+
}, RESTART_DELAY_MS).unref?.();
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
@@ -3,6 +3,7 @@ import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
|
3
3
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
4
4
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
5
5
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
6
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
6
7
|
function parseLastEventId(req, url) {
|
|
7
8
|
const query = Number.parseInt(url.searchParams.get("lastEventId") ?? "", 10);
|
|
8
9
|
if (Number.isFinite(query))
|
|
@@ -48,6 +49,7 @@ export async function handleSseStream(req, res) {
|
|
|
48
49
|
deviceId: normalized,
|
|
49
50
|
serverTime: Date.now(),
|
|
50
51
|
lastSeq,
|
|
52
|
+
pluginVersion: PLUGIN_VERSION,
|
|
51
53
|
},
|
|
52
54
|
}, deviceId, true);
|
|
53
55
|
const lastEventId = parseLastEventId(req, url);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getActiveRunIds } from "../../agent/active-runs.js";
|
|
2
2
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
3
3
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
4
5
|
export async function handleStatus(req, res) {
|
|
5
6
|
if (req.method !== "GET") {
|
|
6
7
|
res.statusCode = 405;
|
|
@@ -21,6 +22,7 @@ export async function handleStatus(req, res) {
|
|
|
21
22
|
ok: true,
|
|
22
23
|
channel: "friday-next",
|
|
23
24
|
version: "v2",
|
|
25
|
+
pluginVersion: PLUGIN_VERSION,
|
|
24
26
|
connections: sseEmitter.getConnectionCount(),
|
|
25
27
|
activeRuns,
|
|
26
28
|
activeRunCount: activeRuns.length,
|
package/dist/src/http/server.js
CHANGED
|
@@ -19,6 +19,8 @@ import { handleHistoryMessages } from "./handlers/history-messages.js";
|
|
|
19
19
|
import { handleHistorySetTitle } from "./handlers/history-set-title.js";
|
|
20
20
|
import { handleStatus } from "./handlers/status.js";
|
|
21
21
|
import { handleHealth } from "./handlers/health.js";
|
|
22
|
+
import { handlePluginInfo } from "./handlers/plugin-info.js";
|
|
23
|
+
import { handlePluginUpgrade } from "./handlers/plugin-upgrade.js";
|
|
22
24
|
import { applyCorsHeaders } from "./middleware/cors.js";
|
|
23
25
|
import { resolveFridayNextConfig } from "../config.js";
|
|
24
26
|
import { getHostOpenClawConfigSnapshot } from "../host-config.js";
|
|
@@ -87,6 +89,14 @@ async function handleFridayNextRoute(req, res) {
|
|
|
87
89
|
if (req.method === "GET" && pathname === "/friday-next/health") {
|
|
88
90
|
return await handleHealth(req, res);
|
|
89
91
|
}
|
|
92
|
+
// Route: GET /friday-next/plugin/info (current/latest version + upgradability)
|
|
93
|
+
if (req.method === "GET" && pathname === "/friday-next/plugin/info") {
|
|
94
|
+
return await handlePluginInfo(req, res);
|
|
95
|
+
}
|
|
96
|
+
// Route: POST /friday-next/plugin/upgrade (npm install @latest + safe gateway restart)
|
|
97
|
+
if (req.method === "POST" && pathname === "/friday-next/plugin/upgrade") {
|
|
98
|
+
return await handlePluginUpgrade(req, res);
|
|
99
|
+
}
|
|
90
100
|
// Not found
|
|
91
101
|
return false;
|
|
92
102
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Install source from the OpenClaw config `plugins.installs[<id>].source`. */
|
|
2
|
+
export type InstallSource = "npm" | "path" | "archive" | "clawhub" | "git" | "marketplace" | "unknown";
|
|
3
|
+
/**
|
|
4
|
+
* Read the install source for this plugin from the live config snapshot.
|
|
5
|
+
* Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
|
|
6
|
+
* Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
|
|
7
|
+
* never be npm-upgraded (would duplicate-install and break agent media sends).
|
|
8
|
+
*/
|
|
9
|
+
export declare function getInstallSource(): InstallSource;
|
|
10
|
+
/** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
|
|
11
|
+
export declare function semverGreater(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
12
|
+
/** Fetch the latest published version from the npm registry (cached ~10min). Null on failure. */
|
|
13
|
+
export declare function fetchLatestVersion(nowMs: number): Promise<string | null>;
|
|
14
|
+
/** Vitest-only */
|
|
15
|
+
export declare function resetLatestVersionCacheForTest(): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for the plugin upgrade feature: read this plugin's install record
|
|
3
|
+
* (source: "npm" vs "path"/dev), compare semver, and look up the latest version
|
|
4
|
+
* published to the npm registry (cached).
|
|
5
|
+
*/
|
|
6
|
+
import { getUpgradeRuntime } from "./upgrade-runtime.js";
|
|
7
|
+
import { PLUGIN_ID, PLUGIN_PACKAGE_NAME } from "./version.js";
|
|
8
|
+
/**
|
|
9
|
+
* Read the install source for this plugin from the live config snapshot.
|
|
10
|
+
* Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
|
|
11
|
+
* Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
|
|
12
|
+
* never be npm-upgraded (would duplicate-install and break agent media sends).
|
|
13
|
+
*/
|
|
14
|
+
export function getInstallSource() {
|
|
15
|
+
const rt = getUpgradeRuntime();
|
|
16
|
+
if (!rt)
|
|
17
|
+
return "unknown";
|
|
18
|
+
try {
|
|
19
|
+
const cfg = rt.currentConfig();
|
|
20
|
+
const source = cfg?.plugins?.installs?.[PLUGIN_ID]?.source;
|
|
21
|
+
if (source === "npm" ||
|
|
22
|
+
source === "path" ||
|
|
23
|
+
source === "archive" ||
|
|
24
|
+
source === "clawhub" ||
|
|
25
|
+
source === "git" ||
|
|
26
|
+
source === "marketplace") {
|
|
27
|
+
return source;
|
|
28
|
+
}
|
|
29
|
+
return "unknown";
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
|
|
36
|
+
export function semverGreater(a, b) {
|
|
37
|
+
if (!a || !b)
|
|
38
|
+
return false;
|
|
39
|
+
const pa = parseSemver(a);
|
|
40
|
+
const pb = parseSemver(b);
|
|
41
|
+
for (let i = 0; i < 3; i++) {
|
|
42
|
+
if (pa[i] > pb[i])
|
|
43
|
+
return true;
|
|
44
|
+
if (pa[i] < pb[i])
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
function parseSemver(v) {
|
|
50
|
+
// Strip a leading "v" and any pre-release/build suffix, keep major.minor.patch.
|
|
51
|
+
const core = v.trim().replace(/^v/i, "").split(/[-+]/)[0];
|
|
52
|
+
const parts = core.split(".").map((p) => Number.parseInt(p, 10));
|
|
53
|
+
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
|
54
|
+
}
|
|
55
|
+
let cachedLatest = null;
|
|
56
|
+
const LATEST_TTL_MS = 10 * 60 * 1000;
|
|
57
|
+
/** Fetch the latest published version from the npm registry (cached ~10min). Null on failure. */
|
|
58
|
+
export async function fetchLatestVersion(nowMs) {
|
|
59
|
+
if (cachedLatest && nowMs - cachedLatest.fetchedAt < LATEST_TTL_MS) {
|
|
60
|
+
return cachedLatest.version;
|
|
61
|
+
}
|
|
62
|
+
let version = null;
|
|
63
|
+
try {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`, { signal: controller.signal, headers: { Accept: "application/json" } });
|
|
68
|
+
if (res.ok) {
|
|
69
|
+
const body = (await res.json());
|
|
70
|
+
if (typeof body.version === "string" && body.version)
|
|
71
|
+
version = body.version;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
version = null;
|
|
80
|
+
}
|
|
81
|
+
cachedLatest = { version, fetchedAt: nowMs };
|
|
82
|
+
return version;
|
|
83
|
+
}
|
|
84
|
+
/** Vitest-only */
|
|
85
|
+
export function resetLatestVersionCacheForTest() {
|
|
86
|
+
cachedLatest = null;
|
|
87
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Captures the full plugin runtime (`api.runtime`) at `registerFull` time so the
|
|
3
|
+
* plugin-info / plugin-upgrade HTTP handlers can reach `system.runCommandWithTimeout`
|
|
4
|
+
* and `config.mutateConfigFile` — capabilities the narrow `getFridayNextRuntime()`
|
|
5
|
+
* store does NOT expose (it only carries `config`/`logger`).
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the `setFridayAgentForwardRuntime` capture pattern in agent-forward-runtime.ts.
|
|
8
|
+
*/
|
|
9
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
10
|
+
export type SpawnResultLike = {
|
|
11
|
+
code: number | null;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
};
|
|
16
|
+
export type ConfigAfterWrite = {
|
|
17
|
+
mode: "auto";
|
|
18
|
+
} | {
|
|
19
|
+
mode: "restart";
|
|
20
|
+
reason: string;
|
|
21
|
+
} | {
|
|
22
|
+
mode: "none";
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
|
25
|
+
export type UpgradeRuntime = {
|
|
26
|
+
/** Run a command (argv) with a timeout in ms; resolves with stdout/stderr/code. */
|
|
27
|
+
runCommandWithTimeout: (argv: string[], timeoutMs: number) => Promise<SpawnResultLike>;
|
|
28
|
+
/** Read the current (deep-readonly) OpenClaw config snapshot. */
|
|
29
|
+
currentConfig: () => unknown;
|
|
30
|
+
/** Mutate the config file; `afterWrite: { mode: "restart" }` triggers a safe gateway restart. */
|
|
31
|
+
mutateConfigFile: (params: {
|
|
32
|
+
afterWrite: ConfigAfterWrite;
|
|
33
|
+
mutate: (draft: unknown) => unknown | void;
|
|
34
|
+
}) => Promise<unknown>;
|
|
35
|
+
};
|
|
36
|
+
export declare function setUpgradeRuntime(api: OpenClawPluginApi): void;
|
|
37
|
+
export declare function getUpgradeRuntime(): UpgradeRuntime | null;
|
|
38
|
+
/** Vitest-only */
|
|
39
|
+
export declare function resetUpgradeRuntimeForTest(): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
let upgradeRuntime = null;
|
|
2
|
+
export function setUpgradeRuntime(api) {
|
|
3
|
+
const runtime = api.runtime;
|
|
4
|
+
upgradeRuntime = {
|
|
5
|
+
runCommandWithTimeout: async (argv, timeoutMs) => {
|
|
6
|
+
const run = runtime.system?.runCommandWithTimeout;
|
|
7
|
+
if (!run)
|
|
8
|
+
throw new Error("runtime.system.runCommandWithTimeout unavailable");
|
|
9
|
+
// `runCommandWithTimeout(argv, number | CommandOptions)` — pass the bare ms.
|
|
10
|
+
return run(argv, timeoutMs);
|
|
11
|
+
},
|
|
12
|
+
currentConfig: () => runtime.config.current(),
|
|
13
|
+
mutateConfigFile: async (params) => {
|
|
14
|
+
const mutate = runtime.config.mutateConfigFile;
|
|
15
|
+
if (!mutate)
|
|
16
|
+
throw new Error("runtime.config.mutateConfigFile unavailable");
|
|
17
|
+
return mutate(params);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function getUpgradeRuntime() {
|
|
22
|
+
return upgradeRuntime;
|
|
23
|
+
}
|
|
24
|
+
/** Vitest-only */
|
|
25
|
+
export function resetUpgradeRuntimeForTest() {
|
|
26
|
+
upgradeRuntime = null;
|
|
27
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const PLUGIN_VERSION: string;
|
|
2
|
+
/** npm package name, used for the upgrade spec and registry lookup. */
|
|
3
|
+
export declare const PLUGIN_PACKAGE_NAME = "@syengup/friday-channel-next";
|
|
4
|
+
/** Plugin id as registered with OpenClaw (used to read the install record). */
|
|
5
|
+
export declare const PLUGIN_ID = "friday-next";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin self-version.
|
|
3
|
+
*
|
|
4
|
+
* Resolved at module load by reading this package's own package.json relative to
|
|
5
|
+
* the compiled module URL. `tsc` does NOT copy package.json into `dist/`, and a
|
|
6
|
+
* JSON `import` would rewrite to a non-existent `dist/package.json`, so we read
|
|
7
|
+
* the real file from disk and walk a couple of candidate paths. Falls back to a
|
|
8
|
+
* hardcoded constant if the file can't be located (keep in sync with package.json).
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
/** Keep in sync with package.json "version" as a last-resort fallback. */
|
|
13
|
+
const FALLBACK_VERSION = "0.1.27";
|
|
14
|
+
function resolvePluginVersion() {
|
|
15
|
+
// dist layout: <root>/dist/src/version.js → ../../package.json = <root>/package.json
|
|
16
|
+
// source layout (vitest/jiti): <root>/src/version.ts → ../package.json = <root>/package.json
|
|
17
|
+
const candidates = ["../../package.json", "../package.json"];
|
|
18
|
+
for (const rel of candidates) {
|
|
19
|
+
try {
|
|
20
|
+
const path = fileURLToPath(new URL(rel, import.meta.url));
|
|
21
|
+
const raw = readFileSync(path, "utf8");
|
|
22
|
+
const pkg = JSON.parse(raw);
|
|
23
|
+
if (pkg.name === "@syengup/friday-channel-next" && typeof pkg.version === "string" && pkg.version) {
|
|
24
|
+
return pkg.version;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// try next candidate
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return FALLBACK_VERSION;
|
|
32
|
+
}
|
|
33
|
+
export const PLUGIN_VERSION = resolvePluginVersion();
|
|
34
|
+
/** npm package name, used for the upgrade spec and registry lookup. */
|
|
35
|
+
export const PLUGIN_PACKAGE_NAME = "@syengup/friday-channel-next";
|
|
36
|
+
/** Plugin id as registered with OpenClaw (used to read the install record). */
|
|
37
|
+
export const PLUGIN_ID = "friday-next";
|
package/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
resolveFridayDeviceIdForSessionKey,
|
|
16
16
|
} from "./src/friday-session.js";
|
|
17
17
|
import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
|
|
18
|
+
import { setUpgradeRuntime } from "./src/upgrade-runtime.js";
|
|
18
19
|
import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
|
|
19
20
|
import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
|
|
20
21
|
import { createFridayNextLogger } from "./src/logging.js";
|
|
@@ -86,6 +87,7 @@ export default defineChannelPluginEntry({
|
|
|
86
87
|
setRuntime: setFridayNextRuntime,
|
|
87
88
|
registerFull: (api: OpenClawPluginApi) => {
|
|
88
89
|
setFridayAgentForwardRuntime(api);
|
|
90
|
+
setUpgradeRuntime(api);
|
|
89
91
|
const sameApi = lastApiRoutesRegistered?.deref() === api;
|
|
90
92
|
if (!sameApi) {
|
|
91
93
|
lastApiRoutesRegistered = new WeakRef(api);
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
3
3
|
import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
|
|
4
4
|
import { createFridayNextLogger } from "../../logging.js";
|
|
5
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
5
6
|
|
|
6
7
|
const REQUIRED_NODE_CAPS = ["location", "canvas"];
|
|
7
8
|
const REQUIRED_NODE_COMMANDS = [
|
|
@@ -34,6 +35,7 @@ export interface HealthCheckResult {
|
|
|
34
35
|
timestamp: number;
|
|
35
36
|
deviceId: string;
|
|
36
37
|
nodeDeviceId: string;
|
|
38
|
+
pluginVersion: string;
|
|
37
39
|
nodePairing?: HealthComponentStatus;
|
|
38
40
|
repairActions?: RepairAction[];
|
|
39
41
|
}
|
|
@@ -64,6 +66,7 @@ export async function handleHealth(req: IncomingMessage, res: ServerResponse): P
|
|
|
64
66
|
timestamp: Date.now(),
|
|
65
67
|
deviceId,
|
|
66
68
|
nodeDeviceId,
|
|
69
|
+
pluginVersion: PLUGIN_VERSION,
|
|
67
70
|
};
|
|
68
71
|
|
|
69
72
|
const log = createFridayNextLogger("health");
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import { PassThrough } from "node:stream";
|
|
4
4
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
|
-
import { handleMessages } from "./messages.js";
|
|
5
|
+
import { handleMessages, composeBodyWithMediaRefs } from "./messages.js";
|
|
6
6
|
import { clearFridayNextRuntime, setFridayNextRuntime } from "../../runtime.js";
|
|
7
7
|
import {
|
|
8
8
|
__resetMockFridayDispatchForTests,
|
|
@@ -23,6 +23,24 @@ class MockRes extends EventEmitter {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
describe("composeBodyWithMediaRefs", () => {
|
|
27
|
+
it("returns trimmed text alone when no media refs", () => {
|
|
28
|
+
expect(composeBodyWithMediaRefs(" hi ", [])).toBe("hi");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("joins text and media refs with a blank line", () => {
|
|
32
|
+
expect(composeBodyWithMediaRefs("hi", ["[media attached: file:///a]"])).toBe(
|
|
33
|
+
"hi\n\n[media attached: file:///a]",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("omits the leading blank line when text is empty (attachment-only)", () => {
|
|
38
|
+
expect(composeBodyWithMediaRefs("", ["[media attached: file:///a]", "[media attached: file:///b]"])).toBe(
|
|
39
|
+
"[media attached: file:///a]\n[media attached: file:///b]",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
26
44
|
describe("handleMessages dispatch context (owner fields)", () => {
|
|
27
45
|
afterEach(() => {
|
|
28
46
|
clearFridayNextRuntime();
|
|
@@ -70,6 +88,62 @@ describe("handleMessages dispatch context (owner fields)", () => {
|
|
|
70
88
|
expect(capturedCtx!.From).toBe(want);
|
|
71
89
|
});
|
|
72
90
|
|
|
91
|
+
it("accepts attachment-only messages (empty text + attachments) and dispatches", async () => {
|
|
92
|
+
setFridayNextRuntime({
|
|
93
|
+
config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
|
|
94
|
+
} as never);
|
|
95
|
+
|
|
96
|
+
let dispatched = false;
|
|
97
|
+
const dispatchCalled = new Promise<void>((resolve) => {
|
|
98
|
+
__setMockFridayDispatchForTests(() => {
|
|
99
|
+
dispatched = true;
|
|
100
|
+
resolve();
|
|
101
|
+
return Promise.resolve();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const req = new PassThrough() as unknown as IncomingMessage;
|
|
106
|
+
req.method = "POST";
|
|
107
|
+
req.headers = { authorization: "Bearer tok" };
|
|
108
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
109
|
+
const p = handleMessages(req, res);
|
|
110
|
+
|
|
111
|
+
req.end(
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
deviceId: "AA11",
|
|
114
|
+
text: "",
|
|
115
|
+
attachments: ["att-1"],
|
|
116
|
+
sessionKey: "default",
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await p;
|
|
121
|
+
await dispatchCalled;
|
|
122
|
+
|
|
123
|
+
expect((res as unknown as MockRes).statusCode).toBe(202);
|
|
124
|
+
expect(dispatched).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rejects messages with neither text nor attachments", async () => {
|
|
128
|
+
setFridayNextRuntime({
|
|
129
|
+
config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
|
|
130
|
+
} as never);
|
|
131
|
+
|
|
132
|
+
const req = new PassThrough() as unknown as IncomingMessage;
|
|
133
|
+
req.method = "POST";
|
|
134
|
+
req.headers = { authorization: "Bearer tok" };
|
|
135
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
136
|
+
const p = handleMessages(req, res);
|
|
137
|
+
|
|
138
|
+
req.end(
|
|
139
|
+
JSON.stringify({ deviceId: "AA11", text: " ", attachments: [], sessionKey: "default" }),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
await p;
|
|
143
|
+
|
|
144
|
+
expect((res as unknown as MockRes).statusCode).toBe(400);
|
|
145
|
+
});
|
|
146
|
+
|
|
73
147
|
it("adds fridayNext mediaKind metadata for audio deliver payload", async () => {
|
|
74
148
|
setFridayNextRuntime({
|
|
75
149
|
config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
|
|
@@ -362,6 +362,16 @@ export interface FridayMessagePayload {
|
|
|
362
362
|
thinkingLevel?: string;
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
/**
|
|
366
|
+
* 把可选文本与媒体引用拼成给 agent 的最终 body。纯附件(文本为空)场景下
|
|
367
|
+
* 不能带前导空行,否则 agent 收到的是 `\n\n[media…]`——故拆成可测纯函数。
|
|
368
|
+
*/
|
|
369
|
+
export function composeBodyWithMediaRefs(text: string, mediaRefs: string[]): string {
|
|
370
|
+
const trimmed = text.trim();
|
|
371
|
+
if (mediaRefs.length === 0) return trimmed;
|
|
372
|
+
return trimmed ? `${trimmed}\n\n${mediaRefs.join("\n")}` : mediaRefs.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
365
375
|
async function buildBodyForAgentWithAttachments(text: string, attachmentIds: string[]): Promise<string> {
|
|
366
376
|
if (attachmentIds.length === 0) return text.trim();
|
|
367
377
|
|
|
@@ -376,8 +386,7 @@ async function buildBodyForAgentWithAttachments(text: string, attachmentIds: str
|
|
|
376
386
|
}
|
|
377
387
|
}
|
|
378
388
|
|
|
379
|
-
|
|
380
|
-
return `${text.trim()}\n\n${mediaRefs.join("\n")}`;
|
|
389
|
+
return composeBodyWithMediaRefs(text, mediaRefs);
|
|
381
390
|
}
|
|
382
391
|
|
|
383
392
|
export async function handleMessages(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
@@ -428,15 +437,18 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
428
437
|
return true;
|
|
429
438
|
}
|
|
430
439
|
|
|
431
|
-
|
|
432
|
-
|
|
440
|
+
// 允许"只发附件、不发文本":text 与 attachments 不能同时为空,但任一非空即放行。
|
|
441
|
+
const hasText = Boolean(text && text.trim());
|
|
442
|
+
const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
|
|
443
|
+
if (!hasText && !hasAttachments) {
|
|
444
|
+
log("BAD_REQUEST", normalizedDeviceId, undefined, "missing text and attachments", "warn");
|
|
433
445
|
res.statusCode = 400;
|
|
434
446
|
res.setHeader("Content-Type", "application/json");
|
|
435
|
-
res.end(JSON.stringify({ error: "Missing required field: text" }));
|
|
447
|
+
res.end(JSON.stringify({ error: "Missing required field: text or attachments" }));
|
|
436
448
|
return true;
|
|
437
449
|
}
|
|
438
450
|
|
|
439
|
-
const trimmedText = text.trim();
|
|
451
|
+
const trimmedText = (text ?? "").trim();
|
|
440
452
|
touchFridayInbound();
|
|
441
453
|
|
|
442
454
|
const isSlashCommand = trimmedText.startsWith("/");
|
|
@@ -492,7 +504,7 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
492
504
|
sseEmitter.trackDeviceForRun(normalizedDeviceId, runId);
|
|
493
505
|
registerRunRoute({ runId, deviceId: normalizedDeviceId, sessionKey: baseSessionKey });
|
|
494
506
|
|
|
495
|
-
const bodyForAgent = await buildBodyForAgentWithAttachments(
|
|
507
|
+
const bodyForAgent = await buildBodyForAgentWithAttachments(trimmedText, attachments);
|
|
496
508
|
|
|
497
509
|
const msgContext = {
|
|
498
510
|
Body: trimmedText,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
3
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
4
|
+
import {
|
|
5
|
+
fetchLatestVersion,
|
|
6
|
+
getInstallSource,
|
|
7
|
+
semverGreater,
|
|
8
|
+
} from "../../plugin-install-info.js";
|
|
9
|
+
|
|
10
|
+
export interface PluginInfoResult {
|
|
11
|
+
currentVersion: string;
|
|
12
|
+
latestVersion: string | null;
|
|
13
|
+
installSource: string;
|
|
14
|
+
/** True when the install is npm-managed (the only auto-upgradable source). */
|
|
15
|
+
canAutoUpgrade: boolean;
|
|
16
|
+
/** True when a newer version is published AND the install can be auto-upgraded. */
|
|
17
|
+
upgradable: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function handlePluginInfo(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
21
|
+
if (req.method !== "GET") {
|
|
22
|
+
res.statusCode = 405;
|
|
23
|
+
res.setHeader("Content-Type", "application/json");
|
|
24
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (!extractBearerToken(req)) {
|
|
28
|
+
res.statusCode = 401;
|
|
29
|
+
res.setHeader("Content-Type", "application/json");
|
|
30
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const installSource = getInstallSource();
|
|
35
|
+
const canAutoUpgrade = installSource === "npm";
|
|
36
|
+
const latestVersion = await fetchLatestVersion(Date.now());
|
|
37
|
+
const upgradable = canAutoUpgrade && semverGreater(latestVersion, PLUGIN_VERSION);
|
|
38
|
+
|
|
39
|
+
const result: PluginInfoResult = {
|
|
40
|
+
currentVersion: PLUGIN_VERSION,
|
|
41
|
+
latestVersion,
|
|
42
|
+
installSource,
|
|
43
|
+
canAutoUpgrade,
|
|
44
|
+
upgradable,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
res.statusCode = 200;
|
|
48
|
+
res.setHeader("Content-Type", "application/json");
|
|
49
|
+
res.end(JSON.stringify(result));
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
3
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
4
|
+
import { PLUGIN_PACKAGE_NAME, PLUGIN_VERSION } from "../../version.js";
|
|
5
|
+
import { getInstallSource } from "../../plugin-install-info.js";
|
|
6
|
+
import { getUpgradeRuntime } from "../../upgrade-runtime.js";
|
|
7
|
+
|
|
8
|
+
const UPGRADE_TIMEOUT_MS = 120_000;
|
|
9
|
+
/** Give the 202 response time to flush before the restart kills the process. */
|
|
10
|
+
const RESTART_DELAY_MS = 500;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* POST /friday-next/plugin/upgrade
|
|
14
|
+
*
|
|
15
|
+
* Runs `openclaw plugins install @syengup/friday-channel-next@latest --force`
|
|
16
|
+
* (registry-aware, updates the install record), responds 202, then triggers a
|
|
17
|
+
* safe gateway restart so the new version loads. Only npm-installed plugins are
|
|
18
|
+
* eligible — dev (load.paths / source==="path") installs return 409 to protect
|
|
19
|
+
* the dev environment from duplicate npm installs.
|
|
20
|
+
*/
|
|
21
|
+
export async function handlePluginUpgrade(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
22
|
+
if (req.method !== "POST") {
|
|
23
|
+
res.statusCode = 405;
|
|
24
|
+
res.setHeader("Content-Type", "application/json");
|
|
25
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (!extractBearerToken(req)) {
|
|
29
|
+
res.statusCode = 401;
|
|
30
|
+
res.setHeader("Content-Type", "application/json");
|
|
31
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const log = createFridayNextLogger("upgrade");
|
|
36
|
+
const installSource = getInstallSource();
|
|
37
|
+
if (installSource !== "npm") {
|
|
38
|
+
res.statusCode = 409;
|
|
39
|
+
res.setHeader("Content-Type", "application/json");
|
|
40
|
+
res.end(
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
error: "auto-upgrade not available",
|
|
43
|
+
detail: `install source is "${installSource}"; only npm installs can be auto-upgraded`,
|
|
44
|
+
installSource,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const rt = getUpgradeRuntime();
|
|
51
|
+
if (!rt) {
|
|
52
|
+
res.statusCode = 500;
|
|
53
|
+
res.setHeader("Content-Type", "application/json");
|
|
54
|
+
res.end(JSON.stringify({ error: "upgrade runtime unavailable" }));
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const spec = `${PLUGIN_PACKAGE_NAME}@latest`;
|
|
59
|
+
log.info(`Starting plugin upgrade: ${spec} (from ${PLUGIN_VERSION})`);
|
|
60
|
+
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await rt.runCommandWithTimeout(
|
|
64
|
+
["openclaw", "plugins", "install", spec, "--force"],
|
|
65
|
+
UPGRADE_TIMEOUT_MS,
|
|
66
|
+
);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
69
|
+
log.error(`plugin upgrade command failed to spawn: ${msg}`);
|
|
70
|
+
res.statusCode = 500;
|
|
71
|
+
res.setHeader("Content-Type", "application/json");
|
|
72
|
+
res.end(JSON.stringify({ error: "upgrade command failed", detail: msg }));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (result.code !== 0) {
|
|
77
|
+
const stderrTail = (result.stderr ?? "").slice(-2000);
|
|
78
|
+
log.error(`plugin upgrade exited code=${result.code}: ${stderrTail}`);
|
|
79
|
+
res.statusCode = 500;
|
|
80
|
+
res.setHeader("Content-Type", "application/json");
|
|
81
|
+
res.end(
|
|
82
|
+
JSON.stringify({
|
|
83
|
+
error: "upgrade command exited non-zero",
|
|
84
|
+
code: result.code,
|
|
85
|
+
detail: stderrTail,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
log.info("Plugin upgrade install succeeded; scheduling gateway restart");
|
|
92
|
+
|
|
93
|
+
// Respond first so the app receives confirmation before the restart drops the
|
|
94
|
+
// connection, then trigger the safe restart after a short flush delay.
|
|
95
|
+
res.statusCode = 202;
|
|
96
|
+
res.setHeader("Content-Type", "application/json");
|
|
97
|
+
res.end(JSON.stringify({ status: "upgrading", from: PLUGIN_VERSION }));
|
|
98
|
+
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
void rt
|
|
101
|
+
.mutateConfigFile({
|
|
102
|
+
afterWrite: { mode: "restart", reason: "friday-next 插件自动升级后重启" },
|
|
103
|
+
mutate: () => {},
|
|
104
|
+
})
|
|
105
|
+
.catch((err: unknown) => {
|
|
106
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
107
|
+
log.error(`gateway restart trigger failed: ${msg}`);
|
|
108
|
+
});
|
|
109
|
+
}, RESTART_DELAY_MS).unref?.();
|
|
110
|
+
|
|
111
|
+
return true;
|
|
112
|
+
}
|
package/src/http/handlers/sse.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
|
4
4
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
5
5
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
6
6
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
7
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
7
8
|
|
|
8
9
|
function parseLastEventId(req: IncomingMessage, url: URL): number {
|
|
9
10
|
const query = Number.parseInt(url.searchParams.get("lastEventId") ?? "", 10);
|
|
@@ -56,6 +57,7 @@ export async function handleSseStream(req: IncomingMessage, res: ServerResponse)
|
|
|
56
57
|
deviceId: normalized,
|
|
57
58
|
serverTime: Date.now(),
|
|
58
59
|
lastSeq,
|
|
60
|
+
pluginVersion: PLUGIN_VERSION,
|
|
59
61
|
},
|
|
60
62
|
},
|
|
61
63
|
deviceId,
|
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import { getActiveRunIds } from "../../agent/active-runs.js";
|
|
3
3
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
4
4
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
5
6
|
|
|
6
7
|
export async function handleStatus(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
7
8
|
if (req.method !== "GET") {
|
|
@@ -24,6 +25,7 @@ export async function handleStatus(req: IncomingMessage, res: ServerResponse): P
|
|
|
24
25
|
ok: true,
|
|
25
26
|
channel: "friday-next",
|
|
26
27
|
version: "v2",
|
|
28
|
+
pluginVersion: PLUGIN_VERSION,
|
|
27
29
|
connections: sseEmitter.getConnectionCount(),
|
|
28
30
|
activeRuns,
|
|
29
31
|
activeRunCount: activeRuns.length,
|
package/src/http/server.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { handleHistoryMessages } from "./handlers/history-messages.js";
|
|
|
21
21
|
import { handleHistorySetTitle } from "./handlers/history-set-title.js";
|
|
22
22
|
import { handleStatus } from "./handlers/status.js";
|
|
23
23
|
import { handleHealth } from "./handlers/health.js";
|
|
24
|
+
import { handlePluginInfo } from "./handlers/plugin-info.js";
|
|
25
|
+
import { handlePluginUpgrade } from "./handlers/plugin-upgrade.js";
|
|
24
26
|
import { applyCorsHeaders } from "./middleware/cors.js";
|
|
25
27
|
import { resolveFridayNextConfig } from "../config.js";
|
|
26
28
|
import { getHostOpenClawConfigSnapshot } from "../host-config.js";
|
|
@@ -109,6 +111,16 @@ async function handleFridayNextRoute(
|
|
|
109
111
|
return await handleHealth(req, res);
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
// Route: GET /friday-next/plugin/info (current/latest version + upgradability)
|
|
115
|
+
if (req.method === "GET" && pathname === "/friday-next/plugin/info") {
|
|
116
|
+
return await handlePluginInfo(req, res);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Route: POST /friday-next/plugin/upgrade (npm install @latest + safe gateway restart)
|
|
120
|
+
if (req.method === "POST" && pathname === "/friday-next/plugin/upgrade") {
|
|
121
|
+
return await handlePluginUpgrade(req, res);
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
// Not found
|
|
113
125
|
return false;
|
|
114
126
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { semverGreater } from "./plugin-install-info.js";
|
|
3
|
+
|
|
4
|
+
describe("semverGreater", () => {
|
|
5
|
+
it("returns true when a is a higher patch/minor/major", () => {
|
|
6
|
+
expect(semverGreater("0.1.27", "0.1.26")).toBe(true);
|
|
7
|
+
expect(semverGreater("0.2.0", "0.1.99")).toBe(true);
|
|
8
|
+
expect(semverGreater("1.0.0", "0.9.9")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns false when equal or lower", () => {
|
|
12
|
+
expect(semverGreater("0.1.27", "0.1.27")).toBe(false);
|
|
13
|
+
expect(semverGreater("0.1.26", "0.1.27")).toBe(false);
|
|
14
|
+
expect(semverGreater("0.1.0", "0.1.0")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("tolerates a leading v and pre-release/build suffixes", () => {
|
|
18
|
+
expect(semverGreater("v0.1.27", "0.1.26")).toBe(true);
|
|
19
|
+
expect(semverGreater("0.1.27-beta.1", "0.1.26")).toBe(true);
|
|
20
|
+
expect(semverGreater("0.1.27", "0.1.27-rc.1")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns false for null/undefined inputs", () => {
|
|
24
|
+
expect(semverGreater(null, "0.1.0")).toBe(false);
|
|
25
|
+
expect(semverGreater("0.1.0", null)).toBe(false);
|
|
26
|
+
expect(semverGreater(undefined, undefined)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for the plugin upgrade feature: read this plugin's install record
|
|
3
|
+
* (source: "npm" vs "path"/dev), compare semver, and look up the latest version
|
|
4
|
+
* published to the npm registry (cached).
|
|
5
|
+
*/
|
|
6
|
+
import { getUpgradeRuntime } from "./upgrade-runtime.js";
|
|
7
|
+
import { PLUGIN_ID, PLUGIN_PACKAGE_NAME } from "./version.js";
|
|
8
|
+
|
|
9
|
+
/** Install source from the OpenClaw config `plugins.installs[<id>].source`. */
|
|
10
|
+
export type InstallSource = "npm" | "path" | "archive" | "clawhub" | "git" | "marketplace" | "unknown";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read the install source for this plugin from the live config snapshot.
|
|
14
|
+
* Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
|
|
15
|
+
* Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
|
|
16
|
+
* never be npm-upgraded (would duplicate-install and break agent media sends).
|
|
17
|
+
*/
|
|
18
|
+
export function getInstallSource(): InstallSource {
|
|
19
|
+
const rt = getUpgradeRuntime();
|
|
20
|
+
if (!rt) return "unknown";
|
|
21
|
+
try {
|
|
22
|
+
const cfg = rt.currentConfig() as {
|
|
23
|
+
plugins?: { installs?: Record<string, { source?: string } | undefined> };
|
|
24
|
+
} | undefined;
|
|
25
|
+
const source = cfg?.plugins?.installs?.[PLUGIN_ID]?.source;
|
|
26
|
+
if (
|
|
27
|
+
source === "npm" ||
|
|
28
|
+
source === "path" ||
|
|
29
|
+
source === "archive" ||
|
|
30
|
+
source === "clawhub" ||
|
|
31
|
+
source === "git" ||
|
|
32
|
+
source === "marketplace"
|
|
33
|
+
) {
|
|
34
|
+
return source;
|
|
35
|
+
}
|
|
36
|
+
return "unknown";
|
|
37
|
+
} catch {
|
|
38
|
+
return "unknown";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
|
|
43
|
+
export function semverGreater(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
44
|
+
if (!a || !b) return false;
|
|
45
|
+
const pa = parseSemver(a);
|
|
46
|
+
const pb = parseSemver(b);
|
|
47
|
+
for (let i = 0; i < 3; i++) {
|
|
48
|
+
if (pa[i] > pb[i]) return true;
|
|
49
|
+
if (pa[i] < pb[i]) return false;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseSemver(v: string): [number, number, number] {
|
|
55
|
+
// Strip a leading "v" and any pre-release/build suffix, keep major.minor.patch.
|
|
56
|
+
const core = v.trim().replace(/^v/i, "").split(/[-+]/)[0];
|
|
57
|
+
const parts = core.split(".").map((p) => Number.parseInt(p, 10));
|
|
58
|
+
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let cachedLatest: { version: string | null; fetchedAt: number } | null = null;
|
|
62
|
+
const LATEST_TTL_MS = 10 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
/** Fetch the latest published version from the npm registry (cached ~10min). Null on failure. */
|
|
65
|
+
export async function fetchLatestVersion(nowMs: number): Promise<string | null> {
|
|
66
|
+
if (cachedLatest && nowMs - cachedLatest.fetchedAt < LATEST_TTL_MS) {
|
|
67
|
+
return cachedLatest.version;
|
|
68
|
+
}
|
|
69
|
+
let version: string | null = null;
|
|
70
|
+
try {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(
|
|
75
|
+
`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`,
|
|
76
|
+
{ signal: controller.signal, headers: { Accept: "application/json" } },
|
|
77
|
+
);
|
|
78
|
+
if (res.ok) {
|
|
79
|
+
const body = (await res.json()) as { version?: string };
|
|
80
|
+
if (typeof body.version === "string" && body.version) version = body.version;
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
version = null;
|
|
87
|
+
}
|
|
88
|
+
cachedLatest = { version, fetchedAt: nowMs };
|
|
89
|
+
return version;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Vitest-only */
|
|
93
|
+
export function resetLatestVersionCacheForTest(): void {
|
|
94
|
+
cachedLatest = null;
|
|
95
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Captures the full plugin runtime (`api.runtime`) at `registerFull` time so the
|
|
3
|
+
* plugin-info / plugin-upgrade HTTP handlers can reach `system.runCommandWithTimeout`
|
|
4
|
+
* and `config.mutateConfigFile` — capabilities the narrow `getFridayNextRuntime()`
|
|
5
|
+
* store does NOT expose (it only carries `config`/`logger`).
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the `setFridayAgentForwardRuntime` capture pattern in agent-forward-runtime.ts.
|
|
8
|
+
*/
|
|
9
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
10
|
+
|
|
11
|
+
export type SpawnResultLike = {
|
|
12
|
+
code: number | null;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ConfigAfterWrite =
|
|
19
|
+
| { mode: "auto" }
|
|
20
|
+
| { mode: "restart"; reason: string }
|
|
21
|
+
| { mode: "none"; reason: string };
|
|
22
|
+
|
|
23
|
+
export type UpgradeRuntime = {
|
|
24
|
+
/** Run a command (argv) with a timeout in ms; resolves with stdout/stderr/code. */
|
|
25
|
+
runCommandWithTimeout: (argv: string[], timeoutMs: number) => Promise<SpawnResultLike>;
|
|
26
|
+
/** Read the current (deep-readonly) OpenClaw config snapshot. */
|
|
27
|
+
currentConfig: () => unknown;
|
|
28
|
+
/** Mutate the config file; `afterWrite: { mode: "restart" }` triggers a safe gateway restart. */
|
|
29
|
+
mutateConfigFile: (params: {
|
|
30
|
+
afterWrite: ConfigAfterWrite;
|
|
31
|
+
mutate: (draft: unknown) => unknown | void;
|
|
32
|
+
}) => Promise<unknown>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let upgradeRuntime: UpgradeRuntime | null = null;
|
|
36
|
+
|
|
37
|
+
export function setUpgradeRuntime(api: OpenClawPluginApi): void {
|
|
38
|
+
const runtime = api.runtime as unknown as {
|
|
39
|
+
system?: { runCommandWithTimeout?: (argv: string[], opts: unknown) => Promise<SpawnResultLike> };
|
|
40
|
+
config: {
|
|
41
|
+
current: () => unknown;
|
|
42
|
+
mutateConfigFile?: (params: unknown) => Promise<unknown>;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
upgradeRuntime = {
|
|
47
|
+
runCommandWithTimeout: async (argv, timeoutMs) => {
|
|
48
|
+
const run = runtime.system?.runCommandWithTimeout;
|
|
49
|
+
if (!run) throw new Error("runtime.system.runCommandWithTimeout unavailable");
|
|
50
|
+
// `runCommandWithTimeout(argv, number | CommandOptions)` — pass the bare ms.
|
|
51
|
+
return run(argv, timeoutMs);
|
|
52
|
+
},
|
|
53
|
+
currentConfig: () => runtime.config.current(),
|
|
54
|
+
mutateConfigFile: async (params) => {
|
|
55
|
+
const mutate = runtime.config.mutateConfigFile;
|
|
56
|
+
if (!mutate) throw new Error("runtime.config.mutateConfigFile unavailable");
|
|
57
|
+
return mutate(params);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getUpgradeRuntime(): UpgradeRuntime | null {
|
|
63
|
+
return upgradeRuntime;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Vitest-only */
|
|
67
|
+
export function resetUpgradeRuntimeForTest(): void {
|
|
68
|
+
upgradeRuntime = null;
|
|
69
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin self-version.
|
|
3
|
+
*
|
|
4
|
+
* Resolved at module load by reading this package's own package.json relative to
|
|
5
|
+
* the compiled module URL. `tsc` does NOT copy package.json into `dist/`, and a
|
|
6
|
+
* JSON `import` would rewrite to a non-existent `dist/package.json`, so we read
|
|
7
|
+
* the real file from disk and walk a couple of candidate paths. Falls back to a
|
|
8
|
+
* hardcoded constant if the file can't be located (keep in sync with package.json).
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
/** Keep in sync with package.json "version" as a last-resort fallback. */
|
|
14
|
+
const FALLBACK_VERSION = "0.1.27";
|
|
15
|
+
|
|
16
|
+
function resolvePluginVersion(): string {
|
|
17
|
+
// dist layout: <root>/dist/src/version.js → ../../package.json = <root>/package.json
|
|
18
|
+
// source layout (vitest/jiti): <root>/src/version.ts → ../package.json = <root>/package.json
|
|
19
|
+
const candidates = ["../../package.json", "../package.json"];
|
|
20
|
+
for (const rel of candidates) {
|
|
21
|
+
try {
|
|
22
|
+
const path = fileURLToPath(new URL(rel, import.meta.url));
|
|
23
|
+
const raw = readFileSync(path, "utf8");
|
|
24
|
+
const pkg = JSON.parse(raw) as { name?: string; version?: string };
|
|
25
|
+
if (pkg.name === "@syengup/friday-channel-next" && typeof pkg.version === "string" && pkg.version) {
|
|
26
|
+
return pkg.version;
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// try next candidate
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return FALLBACK_VERSION;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const PLUGIN_VERSION: string = resolvePluginVersion();
|
|
36
|
+
|
|
37
|
+
/** npm package name, used for the upgrade spec and registry lookup. */
|
|
38
|
+
export const PLUGIN_PACKAGE_NAME = "@syengup/friday-channel-next";
|
|
39
|
+
|
|
40
|
+
/** Plugin id as registered with OpenClaw (used to read the install record). */
|
|
41
|
+
export const PLUGIN_ID = "friday-next";
|