#!/usr/bin/env node /** * patch-dsh.mjs * * HF Space persistent storage (/data) does NOT support hard links: dsh's * JSONL session backend and attachment backend publish files with `link()` * (a hard link), which fails with `ENOTSUP` on that filesystem and breaks * every new session ("link ... ENOTSUP: operation not supported on socket"). * * dsh's own storage-json backend already uses `rename()` for atomic publish * and works fine on /data, so we replace the two `link()` publishes with * `rename()`. Both backends write the temp file in the SAME directory as the * target, so rename() is an atomic same-filesystem replace -- safe here. * * It also relaxes the credentials-file owner-only assertion in * `dsh-credentials-local` (dsh >= rc.6 hard-fails when /data/.credentials.yaml * carries group/other bits -- which the bucket filesystem cannot clear), so * boot no longer dies on that check. * * This runs AFTER `npm install -g @deepseek-ai/dsh@latest` (see entrypoint.sh), * so it patches whatever version is actually installed on this start. */ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; const npmBin = process.platform === "win32" ? "npm.cmd" : "npm"; const globalRoot = execFileSync(npmBin, ["root", "-g"], { encoding: "utf8", shell: true }).trim(); // dsh ships its sub-packages nested inside its own node_modules, but a // hoisted layout is possible too. Resolve the real path to each lib/index.js. function resolvePkgIndex(pkg) { const candidates = [ join(globalRoot, "@deepseek-ai", pkg, "lib", "index.js"), join(globalRoot, "@deepseek-ai", "dsh", "node_modules", "@deepseek-ai", pkg, "lib", "index.js"), ]; return candidates.find(existsSync) ?? null; } // A user-installed profile plugin (installed into $DSH_HOME/profiles/web/...) // that ships a CJS-style esbuild bundle loaded as ESM and needs its __require // shim fixed. Returns null when the plugin is not installed. function resolveProfilePluginIndex(pkg) { const home = process.env.DSH_HOME || "/data"; const p = join(home, "profiles", "web", "node_modules", pkg, "lib", "index.js"); return existsSync(p) ? p : null; } const fixes = [ { label: "dsh-session-persistence-jsonl", file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), importFrom: 'import { link, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, stat, truncate } from "node:fs/promises";', importTo: 'import { link, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, truncate } from "node:fs/promises";', callFrom: "await link(tmp, finalPath);", callTo: "await rename(tmp, finalPath);", }, { label: "dsh-attachment-local", file: () => resolvePkgIndex("dsh-attachment-local"), importFrom: 'import { chmod, link, mkdir, open, readFile, unlink } from "node:fs/promises";', importTo: 'import { chmod, link, mkdir, open, readFile, rename, unlink } from "node:fs/promises";', callFrom: "await link(temporary, target);", callTo: "await rename(temporary, target);", }, { // dsh >= rc.6 hard-fails at boot if the credentials file has any // group/other mode bits (assertOwnerOnly). HF bucket storage (/data) // reports 644 for files it cannot truly chmod, so boot always died with // 'credentials-local: ... readable beyond its owner'. This keeps the // security intent (try chmod 600, re-check) but degrades to a warning // when the filesystem cannot enforce POSIX modes -- correct for a // single-user Space behind Basic Auth on a private bucket. label: "dsh-credentials-local", file: () => resolvePkgIndex("dsh-credentials-local"), importFrom: 'import { mkdir, readFile, stat } from "node:fs/promises";', importTo: 'import { chmod, mkdir, readFile, stat } from "node:fs/promises";', callFrom: 'throw new Error(`credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 511).toString(8)}); run "chmod 600 ${filename}" before starting again`);', callTo: '\ttry {\n' + '\t\tawait chmod(filename, 384);\n' + '\t\tif (((await stat(filename)).mode & GROUP_OTHER_BITS) === 0) return;\n' + '\t} catch {}\n' + '\tconsole.warn(`[dsh-hf-patch] ${filename} has group/other-readable mode ${(mode & 511).toString(8)}; proceeding (HF bucket storage cannot always enforce POSIX modes)`);', }, { // HF bucket storage does not populate dirent types (d_type) in readdir, // so dirent.isDirectory() returns false for real directories and dsh's // session scan (listProjectDirs/listSessionDirs) returns an empty list -- // the sidebar shows no sessions after a restart. Fall back to stat() for // entries whose dirent type is UNKNOWN. stat is already imported. label: "dsh-session-persistence-jsonl:listDirs", file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), callFrom: '\t\t\treturn entries.filter((e) => e.isDirectory()).map((e) => join(this.root, e.name));\n' + '\t\t} catch (error) {\n' + '\t\t\tif (isENOENT(error)) return [];\n' + '\t\t\tthrow error;\n' + '\t\t}\n' + '\t}\n' + '\t/** List session-owned directories and reject the obsolete flat-file layout. */\n' + '\tasync listSessionDirs(project, signal) {\n' + '\t\tsignal?.throwIfAborted();\n' + '\t\tconst entries = await readdir(project, { withFileTypes: true });\n' + '\t\tsignal?.throwIfAborted();\n' + '\t\tconst legacy = entries.find((entry) => entry.isFile() && (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.zstd")));\n' + '\t\tif (legacy !== void 0) throw this.legacyLayout(join(project, legacy.name));\n' + '\t\treturn entries.filter((entry) => entry.isDirectory()).map((entry) => join(project, entry.name));\n' + '\t}', callTo: '\t\t\tconst dirs = [];\n' + '\t\t\tfor (const e of entries) {\n' + '\t\t\t\tsignal?.throwIfAborted();\n' + '\t\t\t\tconst p = join(this.root, e.name);\n' + '\t\t\t\tif (e.isDirectory()) { dirs.push(p); continue; }\n' + '\t\t\t\tif (e.type !== 0 || e.isFile()) continue;\n' + '\t\t\t\ttry { if ((await stat(p)).isDirectory()) dirs.push(p); } catch {}\n' + '\t\t\t}\n' + '\t\t\treturn dirs;\n' + '\t\t} catch (error) {\n' + '\t\t\tif (isENOENT(error)) return [];\n' + '\t\t\tthrow error;\n' + '\t\t}\n' + '\t}\n' + '\t/** List session-owned directories and reject the obsolete flat-file layout. */\n' + '\tasync listSessionDirs(project, signal) {\n' + '\t\tsignal?.throwIfAborted();\n' + '\t\tconst entries = await readdir(project, { withFileTypes: true });\n' + '\t\tsignal?.throwIfAborted();\n' + '\t\tconst dirs = [];\n' + '\t\tfor (const entry of entries) {\n' + '\t\t\tsignal?.throwIfAborted();\n' + '\t\t\tconst p = join(project, entry.name);\n' + '\t\t\tif (entry.isFile()) {\n' + '\t\t\t\tif (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.zstd")) throw this.legacyLayout(p);\n' + '\t\t\t\tcontinue;\n' + '\t\t\t}\n' + '\t\t\tif (entry.isDirectory()) { dirs.push(p); continue; }\n' + '\t\t\tif (entry.type !== 0) continue;\n' + '\t\t\tconst s = await stat(p).catch(() => void 0);\n' + '\t\t\tif (s === void 0) continue;\n' + '\t\t\tif (s.isFile()) {\n' + '\t\t\t\tif (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.zstd")) throw this.legacyLayout(p);\n' + '\t\t\t\tcontinue;\n' + '\t\t\t}\n' + '\t\t\tif (s.isDirectory()) dirs.push(p);\n' + '\t\t}\n' + '\t\treturn dirs;\n' + '\t}', }, { // exists() used open(path,"r") which on the HF bucket mount both returns // TRUE for absent paths AND materializes placeholder DIRECTORIES at the // probed name (so probing session.jsonl created a session.jsonl dir that // then tripped encodingMismatch). Switch to stat() and accept only real // files -- all dsh exists() call sites are artifact-file paths. label: "dsh-session-persistence-jsonl:exists-stat", file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), callFrom: '\t\ttry {\n' + '\t\t\tawait (await open(path, "r")).close();\n' + '\t\t\treturn true;\n' + '\t\t} catch (error) {', callTo: '\t\ttry {\n' + '\t\t\tconst st = await stat(path);\n' + '\t\t\treturn st.isFile();\n' + '\t\t} catch (error) {', }, { // A stray opposite-compression artifact (e.g. an orphaned session.jsonl // next to session.jsonl.zstd) used to abort EVERY session op with // encodingMismatch. Degrade to a warning and keep using the real file. label: "dsh-session-persistence-jsonl:lenient-list", file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), callFrom: '\t\t\t\tconst oppositeExists = await this.exists(opposite);\n' + '\t\t\t\tsignal?.throwIfAborted();\n' + '\t\t\t\tif (oppositeExists) throw this.encodingMismatch(opposite);', callTo: '\t\t\t\tconst oppositeExists = await this.exists(opposite);\n' + '\t\t\t\tsignal?.throwIfAborted();\n' + '\t\t\t\tif (oppositeExists) console.warn(`[dsh-hf-patch] ignoring stray opposite artifact ${opposite}`);', }, { label: "dsh-session-persistence-jsonl:lenient-findlog", file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), callFrom: '\t\t\tconst oppositeExists = await this.exists(opposite);\n' + '\t\t\tsignal?.throwIfAborted();\n' + '\t\t\tif (oppositeExists) throw this.encodingMismatch(opposite);', callTo: '\t\t\tconst oppositeExists = await this.exists(opposite);\n' + '\t\t\tsignal?.throwIfAborted();\n' + '\t\t\tif (oppositeExists) console.warn(`[dsh-hf-patch] ignoring stray opposite artifact ${opposite}`);', }, { label: "dsh-session-persistence-jsonl:lenient-rootcheck", file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), callFrom: '\t\t\tconst incompatible = join(dir, `session${logSuffix(this.oppositeCompression())}`);\n' + '\t\t\tif (await this.exists(incompatible)) throw this.encodingMismatch(incompatible);', callTo: '\t\t\tconst incompatible = join(dir, `session${logSuffix(this.oppositeCompression())}`);\n' + '\t\t\tif (await this.exists(incompatible)) console.warn(`[dsh-hf-patch] ignoring stray opposite artifact ${incompatible}`);', }, { // User plugin dsh-local-project (github:w769721503/dsh-local-project) is an // esbuild CJS-style bundle loaded as ESM; its __require shim throws for node // built-ins like "events", crashing the whole plugin tree at boot (502). // Fall back to createRequire via process.getBuiltinModule (Node >=22.3) so // built-ins resolve; optional ws natives (utf-8-validate/bufferutil) still // throw inside their own try/catch and are ignored. label: "dsh-local-project:esm-require", file: () => resolveProfilePluginIndex("dsh-local-project"), callFrom: " throw Error('Dynamic require of \"' + x + '\" is not supported');\n});", callTo: ' var __dshReq = process && typeof process.getBuiltinModule === "function" ? process.getBuiltinModule("node:module").createRequire(import.meta.url) : null;\n' + ' if (__dshReq) return __dshReq(x);\n' + " throw Error('Dynamic require of \"' + x + '\" is not supported');\n});", }, ]; let failed = false; for (const fix of fixes) { const filePath = fix.file(); if (!filePath) { console.warn(`[patch-dsh] SKIP (package not found): ${fix.label}`); failed = true; continue; } let source; try { source = await readFile(filePath, "utf8"); } catch (error) { console.warn(`[patch-dsh] SKIP (cannot read) ${filePath}: ${error.message}`); failed = true; continue; } const hadImport = fix.importFrom === void 0 || source.includes(fix.importFrom); const hadCall = source.includes(fix.callFrom); if (fix.importFrom !== void 0 && hadImport) source = source.replace(fix.importFrom, fix.importTo); if (hadCall) source = source.replace(fix.callFrom, fix.callTo); await writeFile(filePath, source); if (hadImport && hadCall) { console.log(`[patch-dsh] OK link() -> rename() patched: ${filePath}`); } else { console.warn( `[patch-dsh] PARTIAL (import=${hadImport} call=${hadCall}) -- upstream layout may have changed: ${filePath}`, ); failed = true; } } if (failed) { console.error("[patch-dsh] One or more patches did not fully apply; inspect logs."); process.exitCode = 1; } else { console.log("[patch-dsh] All hard-link publishes replaced with rename()."); }