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:
Hunter Bown
2026-05-12 01:39:44 -05:00
parent ae42bdb2c0
commit f8a3c6619e
9 changed files with 162 additions and 13 deletions
+13
View File
@@ -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
+9 -2
View File
@@ -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(),
);
}
+66
View File
@@ -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);
});
}
});
+27 -2
View File
@@ -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 });
}
+3 -2
View File
@@ -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
View File
@@ -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) })),
};
}
+5
View File
@@ -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
View File
@@ -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*"],
};
+3
View File
@@ -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: {