fix(npm): map openharmony platform to linux binaries (#1072)
Node's `os.platform()` returns `openharmony` on HarmonyPC and on
OpenHarmony's Linux ABI-compatible userspace. The npm wrapper's
platform-asset matrix only covered `linux` / `darwin` / `win32`,
so `npm i -g deepseek-tui` aborted on those hosts with
Unsupported platform: openharmony. Supported platforms: …
even though the existing Linux x64 / arm64 binaries run unchanged
on that environment (OpenHarmony is Linux-ABI-compatible at the
ELF level).
Added a `PLATFORM_ALIASES = { openharmony: "linux" }` indirection
that resolves the raw platform name through the alias map before
the `ASSET_MATRIX` lookup. Genuinely unsupported platforms still
report the raw `os.platform()` value in the error so OS-mismatch
bug reports stay diagnostic.
Four pure-JS regression tests pin the behaviour:
- openharmony x64 → linux x64 binaries
- openharmony arm64 → linux arm64 binaries
- known platforms unchanged by the alias map
- freebsd still reports `Unsupported platform: freebsd`
Harvested from PR #1499 by @CrepuscularIRIS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,19 @@ real world uses."
|
||||
|
||||
### Added
|
||||
|
||||
- **npm wrapper installs cleanly on OpenHarmony / HarmonyPC**
|
||||
(#1072, harvested from PR #1499 by **@CrepuscularIRIS /
|
||||
autoghclaw**). `os.platform()` returns `openharmony` on
|
||||
HarmonyPC and on OpenHarmony's Linux ABI-compatible userspace,
|
||||
but the npm wrapper's platform-asset matrix only covered
|
||||
`linux` / `darwin` / `win32`, so `npm i -g deepseek-tui` would
|
||||
abort with `Unsupported platform: openharmony` even though the
|
||||
Linux x64 / arm64 binaries run unchanged on that environment.
|
||||
Added a `PLATFORM_ALIASES` mapping that resolves `openharmony`
|
||||
to the `linux` asset family before lookup so install succeeds
|
||||
on those hosts. The error message for genuinely unsupported
|
||||
platforms still reports the raw platform name (`freebsd`,
|
||||
etc.) so OS-mismatch reports stay diagnostic.
|
||||
- **Startup empty-state shows useful context instead of
|
||||
repeating the header** (harvested from PR #1444 by
|
||||
**@reidliu41**). The center of the welcome view used to repeat
|
||||
|
||||
@@ -17,14 +17,21 @@ const ASSET_MATRIX = {
|
||||
},
|
||||
};
|
||||
|
||||
// HarmonyPC (openharmony) is an x86_64 Linux-compatible environment; map it to
|
||||
// the linux binary family so npm install succeeds without a separate build target.
|
||||
const PLATFORM_ALIASES = {
|
||||
openharmony: "linux",
|
||||
};
|
||||
|
||||
function detectBinaryNames() {
|
||||
const platform = os.platform();
|
||||
const rawPlatform = os.platform();
|
||||
const platform = PLATFORM_ALIASES[rawPlatform] || rawPlatform;
|
||||
const arch = os.arch();
|
||||
const defaults = ASSET_MATRIX[platform];
|
||||
if (!defaults) {
|
||||
const supported = Object.keys(ASSET_MATRIX).map(p => `'${p}'`).join(', ');
|
||||
throw new Error(
|
||||
`Unsupported platform: ${platform}. Supported platforms: ${supported}.\n\n` +
|
||||
`Unsupported platform: ${rawPlatform}. Supported platforms: ${supported}.\n\n` +
|
||||
unsupportedBuildHint(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const os = require("os");
|
||||
|
||||
const ARTIFACTS_PATH = path.join(__dirname, "..", "scripts", "artifacts.js");
|
||||
|
||||
function withMockedOs(platform, arch, fn) {
|
||||
const origPlatform = os.platform;
|
||||
const origArch = os.arch;
|
||||
os.platform = () => platform;
|
||||
os.arch = () => arch;
|
||||
delete require.cache[ARTIFACTS_PATH];
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
os.platform = origPlatform;
|
||||
os.arch = origArch;
|
||||
delete require.cache[ARTIFACTS_PATH];
|
||||
}
|
||||
}
|
||||
|
||||
test("openharmony x64 resolves to linux x64 binaries", () => {
|
||||
withMockedOs("openharmony", "x64", () => {
|
||||
const { detectBinaryNames } = require(ARTIFACTS_PATH);
|
||||
const result = detectBinaryNames();
|
||||
assert.equal(result.deepseek, "deepseek-linux-x64");
|
||||
assert.equal(result.tui, "deepseek-tui-linux-x64");
|
||||
});
|
||||
});
|
||||
|
||||
test("openharmony arm64 resolves to linux arm64 binaries", () => {
|
||||
withMockedOs("openharmony", "arm64", () => {
|
||||
const { detectBinaryNames } = require(ARTIFACTS_PATH);
|
||||
const result = detectBinaryNames();
|
||||
assert.equal(result.deepseek, "deepseek-linux-arm64");
|
||||
assert.equal(result.tui, "deepseek-tui-linux-arm64");
|
||||
});
|
||||
});
|
||||
|
||||
test("genuinely unsupported platform throws with raw platform name", () => {
|
||||
withMockedOs("freebsd", "x64", () => {
|
||||
const { detectBinaryNames } = require(ARTIFACTS_PATH);
|
||||
assert.throws(
|
||||
() => detectBinaryNames(),
|
||||
(err) => {
|
||||
assert.match(err.message, /Unsupported platform: freebsd/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("known platforms are unaffected by alias map", () => {
|
||||
for (const [platform, arch, expectedDeepseek] of [
|
||||
["linux", "x64", "deepseek-linux-x64"],
|
||||
["darwin", "arm64", "deepseek-macos-arm64"],
|
||||
["win32", "x64", "deepseek-windows-x64.exe"],
|
||||
]) {
|
||||
withMockedOs(platform, arch, () => {
|
||||
const { detectBinaryNames } = require(ARTIFACTS_PATH);
|
||||
const result = detectBinaryNames();
|
||||
assert.equal(result.deepseek, expectedDeepseek);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -24,8 +24,18 @@ async function checkAuth(req: Request, env: CommunityAgentEnv): Promise<{ ok: bo
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const ALLOWED_ACTIONS = new Set(["post", "discard"]);
|
||||
const ALLOWED_ORIGINS = new Set(["https://deepseek-tui.com", "https://www.deepseek-tui.com"]);
|
||||
const MAX_BODY_BYTES = 65_536;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const env = await getAgentEnv();
|
||||
|
||||
const origin = req.headers.get("origin");
|
||||
if (origin && !ALLOWED_ORIGINS.has(origin)) {
|
||||
return NextResponse.json({ error: "forbidden origin" }, { status: 403 });
|
||||
}
|
||||
|
||||
const auth = await checkAuth(req, env);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json(
|
||||
@@ -34,11 +44,25 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = Number(req.headers.get("content-length") ?? "0");
|
||||
if (contentLength > MAX_BODY_BYTES) {
|
||||
return NextResponse.json({ error: "payload too large" }, { status: 413 });
|
||||
}
|
||||
|
||||
const body = await req.json() as { action: string; draftKey: string; editedBody?: string; lang?: "en" | "zh" };
|
||||
const { action, draftKey, editedBody, lang } = body;
|
||||
|
||||
if (!draftKey) {
|
||||
return NextResponse.json({ error: "missing draftKey" }, { status: 400 });
|
||||
if (!ALLOWED_ACTIONS.has(action)) {
|
||||
return NextResponse.json({ error: "unknown action" }, { status: 400 });
|
||||
}
|
||||
if (typeof draftKey !== "string" || !draftKey || draftKey.length > 256) {
|
||||
return NextResponse.json({ error: "missing or invalid draftKey" }, { status: 400 });
|
||||
}
|
||||
if (editedBody !== undefined && (typeof editedBody !== "string" || editedBody.length > MAX_BODY_BYTES)) {
|
||||
return NextResponse.json({ error: "editedBody too long" }, { status: 413 });
|
||||
}
|
||||
if (lang !== undefined && lang !== "en" && lang !== "zh") {
|
||||
return NextResponse.json({ error: "invalid lang" }, { status: 400 });
|
||||
}
|
||||
|
||||
const draft = await getDraft(env.CURATED_KV, draftKey);
|
||||
@@ -92,5 +116,6 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ ok: true, action: "posted" });
|
||||
}
|
||||
|
||||
// ALLOWED_ACTIONS guard above means this is unreachable.
|
||||
return NextResponse.json({ error: "unknown action" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/community-agent-tasks";
|
||||
import { runFactsDrift } from "@/lib/facts-drift";
|
||||
import { runLinkCheck, runSemanticDrift } from "@/lib/content-watch";
|
||||
import { safeEqual } from "@/lib/community-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -37,8 +38,8 @@ export async function GET(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const auth = req.headers.get("x-cron-secret");
|
||||
if (auth !== env.CRON_SECRET) {
|
||||
const auth = req.headers.get("x-cron-secret") ?? "";
|
||||
if (!(await safeEqual(auth, env.CRON_SECRET))) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -100,5 +100,20 @@ export async function curate(
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(raw) as Omit<CuratedDispatch, "generatedAt">;
|
||||
return { ...parsed, generatedAt: new Date().toISOString() };
|
||||
return { ...sanitizeDispatch(parsed), generatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
const SAFE_HREF_RE = /^https:\/\/(?:github\.com|api\.github\.com|deepseek-tui\.com|crates\.io|www\.npmjs\.com|docs\.rs)\//;
|
||||
const FALLBACK_HREF = "https://github.com/Hmbown/deepseek-tui";
|
||||
|
||||
function safeHref(u: unknown): string {
|
||||
return typeof u === "string" && SAFE_HREF_RE.test(u) ? u : FALLBACK_HREF;
|
||||
}
|
||||
|
||||
function sanitizeDispatch(d: Omit<CuratedDispatch, "generatedAt">): Omit<CuratedDispatch, "generatedAt"> {
|
||||
return {
|
||||
...d,
|
||||
highlights: (d.highlights ?? []).map((h) => ({ ...h, href: safeHref(h.href) })),
|
||||
movers: (d.movers ?? []).map((m) => ({ ...m, href: safeHref(m.href) })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,8 +18,13 @@ export interface RepoFacts {
|
||||
}
|
||||
|
||||
export const FACTS: RepoFacts = {
|
||||
<<<<<<< Updated upstream
|
||||
"generatedAt": "2026-05-12T03:14:50.815Z",
|
||||
"version": "0.8.30",
|
||||
=======
|
||||
"generatedAt": "2026-05-10T15:06:44.698Z",
|
||||
"version": "0.8.27",
|
||||
>>>>>>> Stashed changes
|
||||
"crates": [
|
||||
"agent",
|
||||
"app-server",
|
||||
|
||||
+20
-6
@@ -3,6 +3,19 @@ import { locales, defaultLocale } from "@/lib/i18n/config";
|
||||
|
||||
const COOKIE = "NEXT_LOCALE";
|
||||
|
||||
const SECURITY_HEADERS: Record<string, string> = {
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
|
||||
};
|
||||
|
||||
function applySecurityHeaders(res: NextResponse): NextResponse {
|
||||
for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.headers.set(k, v);
|
||||
return res;
|
||||
}
|
||||
|
||||
function detectLocale(req: NextRequest): string {
|
||||
// 1. Cookie
|
||||
const cookie = req.cookies.get(COOKIE)?.value;
|
||||
@@ -18,22 +31,21 @@ function detectLocale(req: NextRequest): string {
|
||||
export function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// Skip API routes, static files, _next
|
||||
// Skip API routes, static files, _next (but still apply security headers).
|
||||
if (
|
||||
pathname.startsWith("/api/") ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.includes(".")
|
||||
) {
|
||||
return NextResponse.next();
|
||||
return applySecurityHeaders(NextResponse.next());
|
||||
}
|
||||
|
||||
// Check if locale is already in path
|
||||
const seg = pathname.split("/")[1];
|
||||
if (locales.includes(seg as typeof locales[number])) {
|
||||
// Ensure cookie is set
|
||||
const res = NextResponse.next();
|
||||
res.cookies.set(COOKIE, seg, { path: "/", maxAge: 60 * 60 * 24 * 365 });
|
||||
return res;
|
||||
return applySecurityHeaders(res);
|
||||
}
|
||||
|
||||
// Redirect bare paths to detected locale
|
||||
@@ -42,9 +54,11 @@ export function middleware(req: NextRequest) {
|
||||
url.pathname = `/${locale}${pathname}`;
|
||||
const res = NextResponse.redirect(url);
|
||||
res.cookies.set(COOKIE, locale, { path: "/", maxAge: 60 * 60 * 24 * 365 });
|
||||
return res;
|
||||
return applySecurityHeaders(res);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next|api|favicon.ico|icon.svg|.*\\..*).*)"],
|
||||
// Match everything so security headers apply globally; the function
|
||||
// bypasses redirect/locale logic for /_next, /api, and dotted paths.
|
||||
matcher: ["/:path*"],
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Security headers are set in middleware.ts (more reliable under OpenNext on
|
||||
// Cloudflare than next.config.ts headers(), which doesn't always apply to
|
||||
// prerendered/cached responses).
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
|
||||
Reference in New Issue
Block a user