@syengup/friday-channel-next 0.1.25 → 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.
Files changed (35) hide show
  1. package/dist/index.js +2 -0
  2. package/dist/src/http/handlers/health.d.ts +1 -0
  3. package/dist/src/http/handlers/health.js +2 -0
  4. package/dist/src/http/handlers/history-messages.js +50 -9
  5. package/dist/src/http/handlers/messages.d.ts +5 -0
  6. package/dist/src/http/handlers/messages.js +19 -8
  7. package/dist/src/http/handlers/plugin-info.d.ts +11 -0
  8. package/dist/src/http/handlers/plugin-info.js +32 -0
  9. package/dist/src/http/handlers/plugin-upgrade.d.ts +11 -0
  10. package/dist/src/http/handlers/plugin-upgrade.js +94 -0
  11. package/dist/src/http/handlers/sse.js +2 -0
  12. package/dist/src/http/handlers/status.js +2 -0
  13. package/dist/src/http/server.js +10 -0
  14. package/dist/src/plugin-install-info.d.ts +15 -0
  15. package/dist/src/plugin-install-info.js +87 -0
  16. package/dist/src/upgrade-runtime.d.ts +39 -0
  17. package/dist/src/upgrade-runtime.js +27 -0
  18. package/dist/src/version.d.ts +5 -0
  19. package/dist/src/version.js +37 -0
  20. package/index.ts +2 -0
  21. package/package.json +1 -1
  22. package/src/http/handlers/health.ts +3 -0
  23. package/src/http/handlers/history-messages.test.ts +33 -0
  24. package/src/http/handlers/history-messages.ts +46 -8
  25. package/src/http/handlers/messages.test.ts +75 -1
  26. package/src/http/handlers/messages.ts +19 -7
  27. package/src/http/handlers/plugin-info.ts +51 -0
  28. package/src/http/handlers/plugin-upgrade.ts +112 -0
  29. package/src/http/handlers/sse.ts +2 -0
  30. package/src/http/handlers/status.ts +2 -0
  31. package/src/http/server.ts +12 -0
  32. package/src/plugin-install-info.test.ts +28 -0
  33. package/src/plugin-install-info.ts +95 -0
  34. package/src/upgrade-runtime.ts +69 -0
  35. package/src/version.ts +41 -0
@@ -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");
@@ -170,6 +170,39 @@ describe("handleHistoryMessages", () => {
170
170
  expect(body.messages[0].text).toBe("from app");
171
171
  });
172
172
 
173
+ it("resolves user [media attached: file://] markers into downloadable /friday-next/files URLs", async () => {
174
+ // The server-local source the marker points at (only exists on the gateway host).
175
+ const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "friday-inbound-"));
176
+ const srcFile = path.join(srcDir, "ce1ff405-28ad-48b9-b4a7-4f2228d77649.jpg");
177
+ fs.writeFileSync(srcFile, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]));
178
+ const file = writeTranscript("media.jsonl", [
179
+ {
180
+ type: "message",
181
+ id: "u1",
182
+ message: {
183
+ role: "user",
184
+ content: `你见过这个可乐吗?\n\n[media attached: file://${srcFile}]`,
185
+ },
186
+ },
187
+ ]);
188
+ setForward({ "agent:main:main": { sessionId: "s", sessionFile: file } });
189
+
190
+ const res = new MockRes();
191
+ await handleHistoryMessages(
192
+ makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH),
193
+ res as any,
194
+ );
195
+ expect(res.statusCode).toBe(200);
196
+ const body = JSON.parse(res.body);
197
+ const userMsg = body.messages.find((m: any) => m.role === "user");
198
+ expect(userMsg.images?.length).toBe(1);
199
+ // The raw server-local file:// path must be resolved to a gateway-served URL the
200
+ // app can actually download — otherwise the attachment bubble is lost on history sync.
201
+ expect(userMsg.images[0].url.startsWith("file://")).toBe(false);
202
+ expect(userMsg.images[0].url.startsWith("/friday-next/files/")).toBe(true);
203
+ fs.rmSync(srcDir, { recursive: true, force: true });
204
+ });
205
+
173
206
  it("falls back to getSessionMessages when the transcript is not on disk", async () => {
174
207
  setForward({}); // no entry → disk read yields nothing
175
208
  setRuntime(async () => ({
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { IncomingMessage, ServerResponse } from "node:http";
14
+ import { fileURLToPath } from "node:url";
14
15
  import { getFridayNextRuntime } from "../../runtime.js";
15
16
  import { extractBearerToken } from "../middleware/auth.js";
16
17
  import { normalizeHistoryMessages } from "../../history/normalize-message.js";
@@ -28,6 +29,26 @@ type SubagentSessionApi = {
28
29
  }) => Promise<{ messages?: unknown[] }>;
29
30
  };
30
31
 
32
+ /**
33
+ * For an `images[].url` produced from a `[media attached: …]` marker: returns the
34
+ * server-local filesystem path to resolve (`file://…` or a bare absolute path), or
35
+ * null to leave the image untouched. Already-served `/friday-next/files/…` URLs and
36
+ * remote `http(s)://` / `data:` URLs are NOT local paths — never feed them to
37
+ * `resolveMediaAttachment` (it would mis-treat them as paths and break valid URLs).
38
+ */
39
+ function serverLocalPathForImageUrl(url: string): string | null {
40
+ if (url.startsWith("file://")) {
41
+ try {
42
+ return fileURLToPath(url);
43
+ } catch {
44
+ return url.slice("file://".length);
45
+ }
46
+ }
47
+ if (url.startsWith("/friday-next/files/")) return null;
48
+ if (url.startsWith("/")) return url;
49
+ return null;
50
+ }
51
+
31
52
  function resolveSubagentApi(): SubagentSessionApi | undefined {
32
53
  try {
33
54
  const runtime = getFridayNextRuntime();
@@ -95,15 +116,32 @@ export async function handleHistoryMessages(
95
116
  // (copies the file into the plugin's attachments/ dir — the same mechanism the
96
117
  // live deliver path uses), then drop the raw paths from the wire.
97
118
  for (const message of messages) {
98
- if (!message.mediaPaths?.length) continue;
99
- const resolved = message.mediaPaths
100
- .map((p) => resolveMediaAttachment(p))
101
- .filter((r): r is NonNullable<typeof r> => Boolean(r))
102
- .map((r) => ({ url: r.url, filename: r.fileName }));
103
- if (resolved.length) {
104
- message.images = [...(message.images ?? []), ...resolved];
119
+ if (message.mediaPaths?.length) {
120
+ const resolved = message.mediaPaths
121
+ .map((p) => resolveMediaAttachment(p))
122
+ .filter((r): r is NonNullable<typeof r> => Boolean(r))
123
+ .map((r) => ({ url: r.url, filename: r.fileName }));
124
+ if (resolved.length) {
125
+ message.images = [...(message.images ?? []), ...resolved];
126
+ }
127
+ delete message.mediaPaths;
128
+ }
129
+
130
+ // User attachments arrive as `[media attached: file://<server-path>]` markers,
131
+ // which normalize-message extracts into images[].url as a RAW server-local path.
132
+ // Unlike MEDIA: paths (resolved above), these were never copied into the file
133
+ // store, so the app would try to load a path that only exists on the gateway host
134
+ // and the attachment bubble is lost on history sync. Resolve them the same way.
135
+ if (message.images?.length) {
136
+ message.images = message.images.map((img) => {
137
+ if (!img.url || img.data) return img;
138
+ const local = serverLocalPathForImageUrl(img.url);
139
+ if (!local) return img;
140
+ const resolved = resolveMediaAttachment(local);
141
+ if (!resolved) return img;
142
+ return { ...img, url: resolved.url, filename: img.filename ?? resolved.fileName };
143
+ });
105
144
  }
106
- delete message.mediaPaths;
107
145
  }
108
146
 
109
147
  const sessionId = resolveSessionId(sessionKey);
@@ -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
- if (mediaRefs.length === 0) return text.trim();
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
- if (!text || !text.trim()) {
432
- log("BAD_REQUEST", normalizedDeviceId, undefined, "missing text", "warn");
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(text, attachments);
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
+ }
@@ -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,
@@ -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
+ }